feat: survey follow ups (#4247)

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>
This commit is contained in:
Anshuman Pandey
2024-11-21 12:20:37 +05:30
committed by GitHub
parent f0a4fad878
commit 37ef6be4c3
56 changed files with 2260 additions and 608 deletions

View File

@@ -12,9 +12,11 @@ import {
getProductIdFromSurveyId,
} from "@/lib/utils/helper";
import { getSegment, getSurvey } from "@/lib/utils/services";
import { getSurveyFollowUpsPermission } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getProduct } from "@formbricks/lib/product/service";
import {
cloneSegment,
@@ -26,15 +28,37 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { loadNewSegmentInSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZBaseFilters, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { ZSurvey } from "@formbricks/types/surveys/types";
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param { string } organizationId The ID of the organization to check.
* @returns { Promise<void> } A promise that resolves if the permission is granted.
* @throws { ResourceNotFoundError } If the organization is not found.
* @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.id),
organizationId,
access: [
{
type: "organization",
@@ -48,6 +72,10 @@ export const updateSurveyAction = authenticatedActionClient
],
});
if (parsedInput.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
return await updateSurvey(parsedInput);
});

View File

@@ -13,6 +13,7 @@ import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
import { GripIcon, Handshake, Undo2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
@@ -25,6 +26,7 @@ import {
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ConfirmationModal } from "@formbricks/ui/components/ConfirmationModal";
import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch";
import { TooltipRenderer } from "@formbricks/ui/components/Tooltip";
@@ -65,6 +67,8 @@ export const EditEndingCard = ({
? plan === "free" && endingCard.type !== "redirectToUrl"
: false;
const [openDeleteConfirmationModal, setOpenDeleteConfirmationModal] = useState(false);
const endingCardTypes = [
{ value: "endScreen", label: t("environments.surveys.edit.ending_card") },
{
@@ -77,6 +81,7 @@ export const EditEndingCard = ({
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: endingCard.id,
});
let open = activeQuestionId === endingCard.id;
const setOpen = (e) => {
@@ -97,6 +102,16 @@ export const EditEndingCard = ({
};
const deleteEndingCard = () => {
const isEndingCardUsedInFollowUps = localSurvey.followUps.some((followUp) => {
if (followUp.trigger.type === "endings") {
if (followUp.trigger.properties?.endingIds?.includes(endingCard.id)) {
return true;
}
}
return false;
});
// checking if this ending card is used in logic
const quesIdx = findEndingCardUsedInLogic(localSurvey, endingCard.id);
@@ -105,6 +120,11 @@ export const EditEndingCard = ({
return;
}
if (isEndingCardUsedInFollowUps) {
setOpenDeleteConfirmationModal(true);
return;
}
setLocalSurvey((prevSurvey) => {
const updatedEndings = prevSurvey.endings.filter((_, index) => index !== endingCardIndex);
return { ...prevSurvey, endings: updatedEndings };
@@ -265,6 +285,37 @@ export const EditEndingCard = ({
)}
</Collapsible.CollapsibleContent>
</Collapsible.Root>
<ConfirmationModal
buttonText={t("common.delete")}
onConfirm={() => {
setLocalSurvey((prevSurvey) => {
const updatedEndings = prevSurvey.endings.filter((_, index) => index !== endingCardIndex);
const surveyFollowUps = prevSurvey.followUps.map((f) => {
if (f.trigger.properties?.endingIds?.includes(endingCard.id)) {
return {
...f,
trigger: {
...f.trigger,
properties: {
...f.trigger.properties,
endingIds: f.trigger.properties.endingIds.filter((id) => id !== endingCard.id),
},
},
};
}
return f;
});
return { ...prevSurvey, endings: updatedEndings, followUps: surveyFollowUps };
});
}}
open={openDeleteConfirmationModal}
setOpen={setOpenDeleteConfirmationModal}
text={t("environments.surveys.edit.follow_ups_ending_card_delete_modal_text")}
title={t("environments.surveys.edit.follow_ups_ending_card_delete_modal_title")}
/>
</div>
);
};

View File

@@ -184,7 +184,6 @@ export const EditorCardMenu = ({
deleteCard(cardIdx);
}}
/>
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
@@ -286,7 +285,6 @@ export const EditorCardMenu = ({
</div>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmationModal
open={logicWarningModal}
setOpen={setLogicWarningModal}

View File

@@ -84,6 +84,17 @@ export const HiddenFieldsCard = ({
return;
}
const isHiddenFieldUsedInFollowUp = localSurvey.followUps
.filter((f) => !f.deleted)
.some((followUp) => {
return followUp.action.properties.to === fieldId;
});
if (isHiddenFieldUsedInFollowUp) {
toast.error(t("environments.surveys.edit.follow_ups_hidden_field_error"));
return;
}
updateSurvey(
{
enabled: true,

View File

@@ -1,38 +1,23 @@
import { PaintbrushIcon, Rows3Icon, SettingsIcon } from "lucide-react";
import { MailIcon, PaintbrushIcon, Rows3Icon, SettingsIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { type JSX, useMemo } from "react";
import { cn } from "@formbricks/lib/cn";
import { TSurveyEditorTabs } from "@formbricks/types/surveys/types";
import { ProBadge } from "@formbricks/ui/components/ProBadge";
interface Tab {
id: TSurveyEditorTabs;
label: string;
icon: JSX.Element;
isPro?: boolean;
}
const tabs: Tab[] = [
{
id: "questions",
label: "common.questions",
icon: <Rows3Icon className="h-5 w-5" />,
},
{
id: "styling",
label: "common.styling",
icon: <PaintbrushIcon className="h-5 w-5" />,
},
{
id: "settings",
label: "common.settings",
icon: <SettingsIcon className="h-5 w-5" />,
},
];
interface QuestionsAudienceTabsProps {
activeId: TSurveyEditorTabs;
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
isStylingTabVisible?: boolean;
isCxMode: boolean;
isSurveyFollowUpsAllowed: boolean;
}
export const QuestionsAudienceTabs = ({
@@ -40,7 +25,35 @@ export const QuestionsAudienceTabs = ({
setActiveId,
isStylingTabVisible,
isCxMode,
isSurveyFollowUpsAllowed = false,
}: QuestionsAudienceTabsProps) => {
const tabs: Tab[] = useMemo(
() => [
{
id: "questions",
label: "common.questions",
icon: <Rows3Icon className="h-5 w-5" />,
},
{
id: "styling",
label: "common.styling",
icon: <PaintbrushIcon className="h-5 w-5" />,
},
{
id: "settings",
label: "common.settings",
icon: <SettingsIcon className="h-5 w-5" />,
},
{
id: "followUps",
label: "environments.surveys.edit.follow_ups",
icon: <MailIcon className="h-5 w-5" />,
isPro: !isSurveyFollowUpsAllowed,
},
],
[isSurveyFollowUpsAllowed]
);
const t = useTranslations();
const tabsComputed = useMemo(() => {
if (isStylingTabVisible) {
@@ -69,6 +82,7 @@ export const QuestionsAudienceTabs = ({
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.icon && <div className="mr-2 h-5 w-5">{tab.icon}</div>}
{t(tab.label)}
{tab.isPro && <ProBadge />}
</button>
))}
</nav>

View File

@@ -282,11 +282,13 @@ export const QuestionsView = ({
}
}
});
updatedSurvey.questions.splice(questionIdx, 1);
const firstEndingCard = localSurvey.endings[0];
setLocalSurvey(updatedSurvey);
delete internalQuestionIdMap[questionId];
if (questionId === activeQuestionIdTemp) {
if (questionIdx <= localSurvey.questions.length && localSurvey.questions.length > 0) {
setActiveQuestionId(localSurvey.questions[questionIdx % localSurvey.questions.length].id);
@@ -294,6 +296,7 @@ export const QuestionsView = ({
setActiveQuestionId(firstEndingCard.id);
}
}
toast.success(t("environments.surveys.edit.question_deleted"));
};

View File

@@ -1,5 +1,6 @@
"use client";
import { FollowUpsView } from "@/modules/ee/survey-follow-ups/components/follow-ups-view";
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
import { useCallback, useEffect, useRef, useState } from "react";
import { extractLanguageCodes, getEnabledLanguages } from "@formbricks/lib/i18n/utils";
@@ -40,7 +41,10 @@ interface SurveyEditorProps {
plan: TOrganizationBillingPlan;
isCxMode: boolean;
locale: TUserLocale;
mailFrom: string;
isSurveyFollowUpsAllowed: boolean;
productPermission: TTeamPermission | null;
userEmail: string;
}
export const SurveyEditor = ({
@@ -60,7 +64,10 @@ export const SurveyEditor = ({
plan,
isCxMode = false,
locale,
mailFrom,
isSurveyFollowUpsAllowed = false,
productPermission,
userEmail,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
@@ -161,6 +168,7 @@ export const SurveyEditor = ({
setActiveId={setActiveView}
isCxMode={isCxMode}
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
/>
{activeView === "questions" && (
@@ -215,6 +223,18 @@ export const SurveyEditor = ({
productPermission={productPermission}
/>
)}
{activeView === "followUps" && (
<FollowUpsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
userEmail={userEmail}
locale={locale}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">

View File

@@ -366,6 +366,18 @@ export const logicRules = {
},
],
},
[TSurveyQuestionTypeEnum.ContactInfo]: {
options: [
{
label: "environments.surveys.edit.is_submitted",
value: ZSurveyLogicConditionsOperator.Enum.isSubmitted,
},
{
label: "environments.surveys.edit.is_skipped",
value: ZSurveyLogicConditionsOperator.Enum.isSkipped,
},
],
},
},
["variable.text"]: {
options: [

View File

@@ -0,0 +1,33 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { userCache } from "@formbricks/lib/user/cache";
import { DatabaseError } from "@formbricks/types/errors";
export const getUserEmail = reactCache(
(userId: string): Promise<string | null> =>
cache(
async () => {
try {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user) {
return null;
}
return user.email;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getUserEmail-${userId}`],
{
tags: [userCache.tag.byId(userId)],
}
)()
);

View File

@@ -2,6 +2,7 @@ import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
import { HTMLInputTypeAttribute } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
import { translate } from "@formbricks/lib/templates";
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
@@ -20,6 +21,7 @@ import {
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { TComboboxGroupedOption, TComboboxOption } from "@formbricks/ui/components/InputCombobox";
import { TLogicRuleOption, logicRules } from "./logicRuleEngine";
@@ -1157,6 +1159,10 @@ export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: strin
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
};
export const getSurveyFollowUpActionDefaultBody = (locale: TUserLocale) => {
return translate("follow_ups_modal_action_body", locale) as string;
};
export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string): number => {
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
return action.objective === "jumpToQuestion" && action.target === endingCardId;

View File

@@ -1,6 +1,8 @@
import { getUserEmail } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/user";
import {
getAdvancedTargetingPermission,
getMultiLanguagePermission,
getSurveyFollowUpsPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -12,6 +14,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
import {
DEFAULT_LOCALE,
IS_FORMBRICKS_CLOUD,
MAIL_FROM,
SURVEY_BG_COLORS,
UNSPLASH_ACCESS_KEY,
} from "@formbricks/lib/constants";
@@ -60,6 +63,7 @@ const Page = async (props) => {
getServerSession(authOptions),
getSegments(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
@@ -84,6 +88,9 @@ const Page = async (props) => {
const isUserTargetingAllowed = await getAdvancedTargetingPermission(organization);
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const isSurveyFollowUpsAllowed = await getSurveyFollowUpsPermission(organization);
const userEmail = await getUserEmail(session.user.id);
if (
!survey ||
@@ -91,6 +98,7 @@ const Page = async (props) => {
!actionClasses ||
!attributeClasses ||
!product ||
!userEmail ||
isSurveyCreationDeletionDisabled
) {
return <ErrorComponent />;
@@ -117,6 +125,9 @@ const Page = async (props) => {
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
isCxMode={isCxMode}
locale={locale ?? DEFAULT_LOCALE}
mailFrom={MAIL_FROM ?? "hola@formbricks.com"}
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
userEmail={userEmail}
/>
);
};

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const ZCreateSurveyFollowUpFormSchema = z.object({
followUpName: z.string().trim().min(1, "Name is required"),
triggerType: z.enum(["response", "endings"]),
endingIds: z.array(z.string().cuid2()).nullable(),
emailTo: z.string().trim().min(1, "To is required"),
replyTo: z.array(z.string().email()).min(1, "Replies must have at least one email"),
subject: z.string().trim().min(1, "Subject is required"),
body: z.string().trim().min(1, "Body is required"),
});
export type TCreateSurveyFollowUpForm = z.infer<typeof ZCreateSurveyFollowUpFormSchema>;

View File

@@ -39,4 +39,5 @@ export const getMinimalSurvey = (locale: string): TSurvey => ({
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
variables: [],
followUps: [],
});

View File

@@ -3,12 +3,14 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProductIdFromSurveyId } from "@/lib/utils/helper";
import { getSurveyFollowUpsPermission } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getResponseDownloadUrl, getResponseFilteringValues } from "@formbricks/lib/response/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
@@ -77,16 +79,34 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields };
});
const ZUpdateSurveyAction = z.object({
survey: ZSurvey,
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient
.schema(ZUpdateSurveyAction)
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.survey.id),
organizationId,
access: [
{
type: "organization",
@@ -94,11 +114,17 @@ export const updateSurveyAction = authenticatedActionClient
},
{
type: "productTeam",
productId: await getProductIdFromSurveyId(parsedInput.survey.id),
productId: await getProductIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
return await updateSurvey(parsedInput.survey);
const { followUps } = parsedInput;
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
return await updateSurvey(parsedInput);
});

View File

@@ -33,7 +33,7 @@ export const SurveyStatusDropdown = ({
false;
const handleStatusChange = async (status: TSurvey["status"]) => {
const updateSurveyActionResponse = await updateSurveyAction({ survey: { ...survey, status } });
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) {
toast.success(

View File

@@ -63,10 +63,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
);
}
if (sourceEnvironment.productId !== targetEnvironment.productId) {
throw new Error("Cannot copy survey to environment with different product");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
@@ -83,6 +79,22 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: targetEnvironment.productId,
},
],
});
return await copySurveyToOtherEnvironment(
parsedInput.environmentId,
parsedInput.surveyId,

View File

@@ -0,0 +1,87 @@
import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
type FollowUpResult = {
followUpId: string;
status: "success" | "error" | "skipped";
error?: string;
};
const evaluateFollowUp = async (
followUpId: string,
followUpAction: TSurveyFollowUpAction,
response: TResponse
): Promise<void> => {
const { properties } = followUpAction;
const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to];
if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
}
if (typeof toValueFromResponse === "string") {
// parse this string to check for an email:
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) {
// send email to this email address
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo);
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
} else if (Array.isArray(toValueFromResponse)) {
const emailAddress = toValueFromResponse[2];
if (!emailAddress) {
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo);
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
}
};
export const sendSurveyFollowUps = async (survey: TSurvey, response: TResponse) => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
// Check if we should skip this follow-up based on ending IDs
if (trigger.properties) {
const { endingIds } = trigger.properties;
const { endingId } = response;
if (!endingId || !endingIds.includes(endingId)) {
return Promise.resolve({
followUpId: followUp.id,
status: "skipped",
});
}
}
return evaluateFollowUp(followUp.id, followUp.action, response)
.then(() => ({
followUpId: followUp.id,
status: "success" as const,
}))
.catch((error) => ({
followUpId: followUp.id,
status: "error" as const,
error: error instanceof Error ? error.message : "Something went wrong",
}));
});
const followUpResults = await Promise.all(followUpPromises);
// Log all errors
const errors = followUpResults
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
console.error("Follow-up processing errors:", errors);
}
};

View File

@@ -1,7 +1,9 @@
import { createDocumentAndAssignInsight } from "@/app/api/(internal)/pipeline/lib/documents";
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getIsAIEnabled } from "@/app/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/ee/license-check/lib/utils";
import { sendResponseFinishedEmail } from "@/modules/email";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -44,6 +46,11 @@ export const POST = async (request: Request) => {
const { environmentId, surveyId, event, response } = inputValidation.data;
const attributes = response.person?.id ? await getAttributes(response.person?.id) : {};
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
}
// Fetch webhooks
const getWebhooksForPipeline = cache(
async (environmentId: string, event: TPipelineTrigger, surveyId: string) => {
@@ -158,6 +165,13 @@ export const POST = async (request: Request) => {
select: { email: true, locale: true },
});
// send follow up emails
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization);
if (surveyFollowUpsPermission) {
await sendSurveyFollowUps(survey, response);
}
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(
user.email,
@@ -190,11 +204,6 @@ export const POST = async (request: Request) => {
if (hasSurveyOpenTextQuestions) {
const isAICofigured = IS_AI_CONFIGURED;
if (hasSurveyOpenTextQuestions && isAICofigured) {
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
}
const isAIEnabled = await getIsAIEnabled(organization);
if (isAIEnabled) {

View File

@@ -1,6 +1,8 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getSurveyFollowUpsPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { deleteSurvey, getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
@@ -60,10 +62,17 @@ export const PUT = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyUpdate;
try {
surveyUpdate = await request.json();
@@ -71,16 +80,26 @@ export const PUT = async (
console.error(`Error parsing JSON input: ${error}`);
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const inputValidation = ZSurveyUpdateInput.safeParse({
...survey,
...surveyUpdate,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
}
if (surveyUpdate.followUps && surveyUpdate.followUps.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization);
if (!isSurveyFollowUpsEnabled) {
return responses.forbiddenResponse("Survey follow ups are not enabled for this organization");
}
}
return responses.successResponse(await updateSurvey({ ...inputValidation.data, id: params.surveyId }));
} catch (error) {
return handleErrorResponse(error);

View File

@@ -1,6 +1,8 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getSurveyFollowUpsPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
@@ -29,6 +31,11 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyInput;
try {
surveyInput = await request.json();
@@ -50,6 +57,13 @@ export const POST = async (request: Request): Promise<Response> => {
const environmentId = authentication.environmentId;
const surveyData = { ...inputValidation.data, environmentId: undefined };
if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization);
if (!isSurveyFollowUpsEnabled) {
return responses.forbiddenResponse("Survey follow ups are not enabled allowed for this organization");
}
}
const survey = await createSurvey(environmentId, surveyData);
return responses.successResponse(survey);
} catch (error) {

View File

@@ -281,6 +281,7 @@ export const LinkSurvey = ({
},
ttc: responseUpdate.ttc,
finished: responseUpdate.finished,
endingId: responseUpdate.endingId,
language:
responseUpdate.language === "default" && defaultLanguageCode
? defaultLanguageCode

View File

@@ -254,6 +254,12 @@ export const getRemoveLinkBrandingPermission = (organization: TOrganization): bo
return false;
};
export const getSurveyFollowUpsPermission = async (organization: TOrganization): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return false;
};
export const getRoleManagementPermission = async (organization: TOrganization): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD)
return (

View File

@@ -0,0 +1,100 @@
import React, { useState } from "react";
import { cn } from "@formbricks/ui/lib/utils";
interface FollowUpActionMultiEmailInputProps {
emails: string[];
setEmails: React.Dispatch<React.SetStateAction<string[]>>;
isInvalid?: boolean;
}
const FollowUpActionMultiEmailInput = ({
emails,
setEmails,
isInvalid,
}: FollowUpActionMultiEmailInputProps) => {
const [inputValue, setInputValue] = useState("");
const [error, setError] = useState("");
// Email validation regex
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const handleAddEmail = () => {
const email = inputValue.trim();
if (!email) return;
if (!emailRegex.test(email)) {
setError("Please enter a valid email address");
return;
}
if (emails.includes(email)) {
setError("This email has already been added");
return;
}
setEmails([...emails, email]);
setInputValue("");
setError("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Clear error when user starts typing
if (error) setError("");
// Handle email addition on Space or Comma
if (e.key === " " || e.key === ",") {
e.preventDefault();
handleAddEmail();
}
// Handle backspace to remove last email
if (e.key === "Backspace" && inputValue === "" && emails.length > 0) {
const newEmails = [...emails];
setEmails(newEmails.slice(0, -1));
}
};
const removeEmail = (indexToRemove: number) => {
setEmails(emails.filter((_, index) => index !== indexToRemove));
};
const handleInputBlur = () => {
handleAddEmail();
};
return (
<div className={cn("w-full max-w-2xl")}>
<div
className={cn(
"flex flex-wrap items-center gap-2 rounded-md border px-2 py-1",
isInvalid ? "border-red-500" : "border-slate-300"
)}>
{emails.map((email, index) => (
<div
key={index}
className="group flex items-center gap-1 rounded border border-slate-200 bg-slate-100 px-2 py-1 text-sm">
<span className="text-slate-900">{email}</span>
<button
onClick={() => removeEmail(index)}
className="px-1 text-lg font-medium leading-none text-slate-500">
×
</button>
</div>
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
placeholder={emails.length === 0 ? "Write an email & press space bar" : ""}
className="min-w-[180px] flex-1 border-none p-0 py-1 text-sm placeholder:text-slate-400 focus:ring-0"
/>
</div>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
</div>
);
};
export default FollowUpActionMultiEmailInput;

View File

@@ -0,0 +1,168 @@
import { FollowUpModal } from "@/modules/ee/survey-follow-ups/components/follow-up-modal";
import { TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { Badge } from "@formbricks/ui/components/Badge";
import { Button } from "@formbricks/ui/components/Button";
import { ConfirmationModal } from "@formbricks/ui/components/ConfirmationModal";
interface FollowUpItemProps {
followUp: TSurveyFollowUp;
localSurvey: TSurvey;
selectedLanguageCode: string;
mailFrom: string;
userEmail: string;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
locale: TUserLocale;
}
export const FollowUpItem = ({
followUp,
localSurvey,
mailFrom,
selectedLanguageCode,
userEmail,
setLocalSurvey,
locale,
}: FollowUpItemProps) => {
const t = useTranslations();
const [editFollowUpModalOpen, setEditFollowUpModalOpen] = useState(false);
const [deleteFollowUpModalOpen, setDeleteFollowUpModalOpen] = useState(false);
const isEmailToInvalid = useMemo(() => {
const { to } = followUp.action.properties;
if (!to) return true;
const matchedQuestion = localSurvey.questions.find((question) => question.id === to);
const matchedHiddenField = (localSurvey.hiddenFields?.fieldIds ?? []).find((fieldId) => fieldId === to);
if (!matchedQuestion && !matchedHiddenField) return true;
if (matchedQuestion) {
if (
![TSurveyQuestionTypeEnum.OpenText, TSurveyQuestionTypeEnum.ContactInfo].includes(
matchedQuestion.type
)
) {
return true;
}
if (
matchedQuestion.type === TSurveyQuestionTypeEnum.OpenText &&
matchedQuestion.inputType !== "email"
) {
return true;
}
}
return false;
}, [followUp.action.properties, localSurvey.hiddenFields?.fieldIds, localSurvey.questions]);
const isEndingInvalid = useMemo(() => {
return followUp.trigger.type === "endings" && !followUp.trigger.properties?.endingIds?.length;
}, [followUp.trigger.properties?.endingIds?.length, followUp.trigger.type]);
return (
<>
<div className="relative cursor-pointer rounded-lg border border-slate-300 bg-white p-4 hover:bg-slate-50">
<div
className="flex flex-col space-y-2"
onClick={() => {
setEditFollowUpModalOpen(true);
}}>
<h3 className="text-slate-900">{followUp.name}</h3>
<div className="flex space-x-2">
<Badge
size="normal"
text={
followUp.trigger.type === "response"
? t("environments.surveys.edit.follow_ups_item_response_tag")
: t("environments.surveys.edit.follow_ups_item_ending_tag")
}
type="gray"
/>
<Badge
size="normal"
text={t("environments.surveys.edit.follow_ups_item_send_email_tag")}
type="gray"
/>
{isEmailToInvalid || isEndingInvalid ? (
<Badge
size="normal"
text={t("environments.surveys.edit.follow_ups_item_issue_detected_tag")}
type="warning"
/>
) : null}
</div>
</div>
<div className="absolute right-4 top-4">
<Button
variant="minimal"
size="icon"
tooltip={t("common.delete")}
onClick={async (e) => {
e.stopPropagation();
setDeleteFollowUpModalOpen(true);
}}>
<TrashIcon className="h-4 w-4 text-slate-500" />
</Button>
</div>
</div>
<FollowUpModal
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
open={editFollowUpModalOpen}
setOpen={setEditFollowUpModalOpen}
mailFrom={mailFrom}
selectedLanguageCode={selectedLanguageCode}
defaultValues={{
surveyFollowUpId: followUp.id,
followUpName: followUp.name,
triggerType: followUp.trigger.type,
endingIds: followUp.trigger.type === "endings" ? followUp.trigger.properties?.endingIds : null,
subject: followUp.action.properties.subject,
body: followUp.action.properties.body,
emailTo: followUp.action.properties.to,
replyTo: followUp.action.properties.replyTo,
}}
mode="edit"
userEmail={userEmail}
locale={locale}
/>
<ConfirmationModal
open={deleteFollowUpModalOpen}
setOpen={setDeleteFollowUpModalOpen}
buttonText={t("common.delete")}
onConfirm={async () => {
setLocalSurvey((prev) => {
return {
...prev,
followUps: prev.followUps.map((f) => {
if (f.id === followUp.id) {
return {
...f,
deleted: true,
};
}
return f;
}),
};
});
}}
text={t("environments.surveys.edit.follow_ups_delete_modal_text")}
title={t("environments.surveys.edit.follow_ups_delete_modal_title")}
buttonVariant="warn"
/>
</>
);
};

View File

@@ -0,0 +1,773 @@
import { getSurveyFollowUpActionDefaultBody } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import FollowUpActionMultiEmailInput from "@/modules/ee/survey-follow-ups/components/follow-up-action-multi-email-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { createId } from "@paralleldrive/cuid2";
import DOMpurify from "isomorphic-dompurify";
import { ArrowDownIcon, EyeOffIcon, HandshakeIcon, MailIcon, TriangleAlertIcon, ZapIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/components/Button";
import { Checkbox } from "@formbricks/ui/components/Checkbox";
import { Editor } from "@formbricks/ui/components/Editor";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@formbricks/ui/components/Form";
import { Input } from "@formbricks/ui/components/Input";
import { Label } from "@formbricks/ui/components/Label";
import { Modal } from "@formbricks/ui/components/Modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@formbricks/ui/components/Select";
import { cn } from "@formbricks/ui/lib/utils";
import {
TCreateSurveyFollowUpForm,
ZCreateSurveyFollowUpFormSchema,
} from "../../../../app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/types/survey-follow-up";
interface AddFollowUpModalProps {
localSurvey: TSurvey;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedLanguageCode: string;
mailFrom: string;
defaultValues?: Partial<TCreateSurveyFollowUpForm & { surveyFollowUpId: string }>;
mode?: "create" | "edit";
userEmail: string;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
locale: TUserLocale;
}
type EmailSendToOption = {
type: "openTextQuestion" | "contactInfoQuestion" | "hiddenField";
label: string;
id: string;
};
export const FollowUpModal = ({
localSurvey,
open,
setOpen,
selectedLanguageCode,
mailFrom,
defaultValues,
mode = "create",
userEmail,
setLocalSurvey,
locale,
}: AddFollowUpModalProps) => {
const t = useTranslations();
const containerRef = useRef<HTMLDivElement>(null);
const [firstRender, setFirstRender] = useState(true);
const emailSendToOptions: EmailSendToOption[] = useMemo(() => {
const { questions } = localSurvey;
const openTextAndContactQuestions = questions.filter((question) => {
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
return true;
}
if (question.type === TSurveyQuestionTypeEnum.OpenText) {
if (question.inputType === "email") {
return true;
}
return false;
}
return false;
});
const hiddenFields =
localSurvey.hiddenFields.enabled && localSurvey.hiddenFields.fieldIds
? { fieldIds: localSurvey.hiddenFields.fieldIds }
: { fieldIds: [] };
return [
...openTextAndContactQuestions.map((question) => ({
label: getLocalizedValue(question.headline, selectedLanguageCode),
id: question.id,
type:
question.type === TSurveyQuestionTypeEnum.OpenText
? "openTextQuestion"
: ("contactInfoQuestion" as EmailSendToOption["type"]),
})),
...hiddenFields.fieldIds.map((fieldId: string) => ({
label: fieldId,
id: fieldId,
type: "hiddenField" as EmailSendToOption["type"],
})),
];
}, [localSurvey, selectedLanguageCode]);
const form = useForm<TCreateSurveyFollowUpForm>({
defaultValues: {
followUpName: defaultValues?.followUpName ?? "",
triggerType: defaultValues?.triggerType ?? "response",
endingIds: defaultValues?.endingIds || null,
emailTo: defaultValues?.emailTo ?? emailSendToOptions[0]?.id,
replyTo: defaultValues?.replyTo ?? [userEmail],
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(locale),
},
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
mode: "onChange",
});
const formErrors = form.formState.errors;
const formSubmitting = form.formState.isSubmitting;
const triggerType = form.watch("triggerType");
const handleSubmit = (data: TCreateSurveyFollowUpForm) => {
if (data.triggerType === "endings" && data.endingIds?.length === 0) {
toast.error("Please select at least one ending or change the trigger type");
return;
}
if (!emailSendToOptions.length) {
toast.error(
"No valid options found for sending emails, please add some open-text / contact-info questions or hidden fields"
);
return;
}
if (Object.keys(formErrors).length > 0) {
return;
}
if (data.triggerType === "endings") {
if (!data.endingIds || !data.endingIds?.length) {
form.setError("endingIds", {
type: "manual",
message: "Please select at least one ending",
});
return;
}
}
const getProperties = (): TSurveyFollowUpTrigger["properties"] => {
if (data.triggerType === "response") {
return null;
}
if (data.endingIds && data.endingIds.length > 0) {
return { endingIds: data.endingIds };
}
return null;
};
if (mode === "edit") {
if (!defaultValues?.surveyFollowUpId) {
toast.error(t("environments.surveys.edit.follow_ups_modal_edit_no_id"));
return;
}
const currentFollowUp = localSurvey.followUps.find(
(followUp) => followUp.id === defaultValues.surveyFollowUpId
);
const sanitizedBody = DOMpurify.sanitize(data.body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
});
const updatedFollowUp = {
id: defaultValues.surveyFollowUpId,
createdAt: currentFollowUp?.createdAt ?? new Date(),
updatedAt: new Date(),
surveyId: localSurvey.id,
name: data.followUpName,
trigger: {
type: data.triggerType,
properties: getProperties(),
},
action: {
type: "send-email" as TSurveyFollowUpAction["type"],
properties: {
to: data.emailTo,
from: mailFrom,
replyTo: data.replyTo,
subject: data.subject,
body: sanitizedBody,
},
},
};
toast.success("Survey follow up updated successfully");
setOpen(false);
setLocalSurvey((prev) => {
return {
...prev,
followUps: prev.followUps.map((followUp) => {
if (followUp.id === defaultValues.surveyFollowUpId) {
return updatedFollowUp;
}
return followUp;
}),
};
});
return;
}
const sanitizedBody = DOMpurify.sanitize(data.body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
});
const newFollowUp = {
id: createId(),
createdAt: new Date(),
updatedAt: new Date(),
surveyId: localSurvey.id,
name: data.followUpName,
trigger: {
type: data.triggerType,
properties: getProperties(),
},
action: {
type: "send-email" as TSurveyFollowUpAction["type"],
properties: {
to: data.emailTo,
from: mailFrom,
replyTo: data.replyTo,
subject: data.subject,
body: sanitizedBody,
},
},
};
toast.success("Survey follow up created successfully");
setOpen(false);
form.reset();
setLocalSurvey((prev) => {
return {
...prev,
followUps: [...prev.followUps, newFollowUp],
};
});
};
useEffect(() => {
if (!open) {
setFirstRender(true); // Reset when the modal is closed
}
}, [open]);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (open && containerRef.current) {
timeoutId = setTimeout(() => {
if (!containerRef.current) return;
const scrollTop = containerRef.current.scrollTop;
if (scrollTop > 0) {
containerRef.current.scrollTo(0, 0);
}
}, 0);
}
// Clear the timeout when the effect is cleaned up or when open changes
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [open, firstRender]);
useEffect(() => {
if (open && defaultValues) {
form.reset({
followUpName: defaultValues?.followUpName ?? "",
triggerType: defaultValues?.triggerType ?? "response",
endingIds: defaultValues?.endingIds || null,
emailTo: defaultValues?.emailTo ?? emailSendToOptions[0]?.id,
replyTo: defaultValues?.replyTo ?? [userEmail],
subject: defaultValues?.subject ?? "Thanks for your answers!",
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(locale),
});
}
}, [open, defaultValues, emailSendToOptions, form, userEmail, locale]);
const handleModalClose = () => {
form.reset();
setOpen(false);
};
return (
<Modal open={open} setOpen={handleModalClose} noPadding size="md">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<MailIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{mode === "edit"
? t("environments.surveys.edit.follow_ups_modal_edit_heading")
: t("environments.surveys.edit.follow_ups_modal_create_heading")}
</div>
<div className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_subheading")}
</div>
</div>
</div>
</div>
</div>
</div>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<div className="mb-12 h-full max-h-[600px] overflow-auto px-6 py-4" ref={containerRef}>
<div className="flex flex-col space-y-4">
{/* Follow up name */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="followUpName"
render={({ field }) => {
return (
<FormItem>
<FormLabel htmlFor="follow-up-name">
{t("environments.surveys.edit.follow_ups_modal_name_label")}:
</FormLabel>
<FormControl>
<Input
{...field}
type="text"
className="max-w-80"
isInvalid={!!formErrors.followUpName}
placeholder={t("environments.surveys.edit.follow_ups_modal_name_placeholder")}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
{/* Trigger */}
<div className="flex flex-col rounded-lg border border-slate-300">
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
<div className="rounded-full border border-slate-300 bg-white p-1">
<ZapIcon className="h-3 w-3 text-slate-500" />
</div>
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_trigger_label")}
</h2>
</div>
<div className="flex flex-col gap-4 p-4">
<FormField
control={form.control}
name="triggerType"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel htmlFor="triggerType">
{t("environments.surveys.edit.follow_ups_modal_trigger_description")}
</FormLabel>
<div className="max-w-80">
<Select
defaultValue={field.value}
onValueChange={(value) => field.onChange(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="response">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_response")}
</SelectItem>
{localSurvey.endings.length > 0 ? (
<SelectItem value="endings">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_ending")}
</SelectItem>
) : null}
</SelectContent>
</Select>
{triggerType === "endings" && !localSurvey.endings.length ? (
<div className="mt-4 flex items-start text-yellow-600">
<TriangleAlertIcon
className="mr-2 h-5 min-h-5 w-5 min-w-5"
aria-hidden="true"
/>
<p className="text-sm">
{t(
"environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"
)}
</p>
</div>
) : null}
</div>
</div>
</FormItem>
);
}}
/>
{localSurvey.endings.length > 0 && triggerType === "endings" ? (
<FormField
control={form.control}
name="endingIds"
render={({ field }) => {
return (
<div className="flex flex-col space-y-2">
<h3 className="text-sm font-medium text-slate-700">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_ending_select")}
</h3>
<div className="flex flex-col space-y-2">
{localSurvey.endings.map((ending) => {
const getEndingLabel = (): string => {
if (ending.type === "endScreen") {
return (
getLocalizedValue(ending.headline, selectedLanguageCode) || "Ending"
);
}
return ending.label || ending.url || "Ending";
};
return (
<Label
key={ending.id}
className="w-80 cursor-pointer rounded-md border border-slate-300 bg-slate-50 px-3 py-2 hover:bg-slate-100"
htmlFor={`ending-${ending.id}`}>
<div className="flex items-center space-x-2">
<Checkbox
className="inline"
checked={field.value?.includes(ending.id)}
id={`ending-${ending.id}`}
onCheckedChange={(checked) => {
if (checked) {
form.setValue("endingIds", [...(field.value ?? []), ending.id]);
} else {
form.setValue(
"endingIds",
(field.value ?? []).filter((id) => id !== ending.id)
);
}
}}
/>
<HandshakeIcon className="h-4 min-h-4 w-4 min-w-4" />
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-slate-900">
{getEndingLabel()}
</span>
</div>
</Label>
);
})}
{formErrors.endingIds ? (
<div className="mt-2">
<span className="text-red-500">{formErrors.endingIds.message}</span>
</div>
) : null}
</div>
</div>
);
}}
/>
) : null}
</div>
</div>
{/* Arrow */}
<div className="flex items-center justify-center">
<ArrowDownIcon className="h-4 w-4 text-slate-500" />
</div>
{/* Action */}
<div className="flex flex-col rounded-lg border border-slate-300">
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
<div className="rounded-full border border-slate-300 bg-white p-1">
<MailIcon className="h-3 w-3 text-slate-500" />
</div>
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_label")}
</h2>
</div>
{/* email setup */}
<div className="flex flex-col gap-y-4 p-4">
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_email_settings")}
</h2>
{/* To */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="emailTo"
render={({ field }) => {
return (
<div className="flex flex-col space-y-2">
<FormLabel htmlFor="emailTo" className="font-medium">
{t("environments.surveys.edit.follow_ups_modal_action_to_label")}
</FormLabel>
<FormDescription
className={cn(
"text-sm",
formErrors.emailTo ? "text-red-500" : "text-slate-500"
)}>
{t("environments.surveys.edit.follow_ups_modal_action_to_description")}
</FormDescription>
{emailSendToOptions.length === 0 && (
<div className="mt-4 flex items-start text-yellow-600">
<TriangleAlertIcon
className="mr-2 h-5 min-h-5 w-5 min-w-5"
aria-hidden="true"
/>
<p className="text-sm">
{t("environments.surveys.edit.follow_ups_modal_action_to_warning")}
</p>
</div>
)}
{emailSendToOptions.length > 0 && (
<div className="max-w-80">
<FormControl>
<Select
defaultValue={field.value}
onValueChange={(value) => {
const selectedOption = emailSendToOptions.find(
(option) => option.id === value
);
if (!selectedOption) return;
field.onChange(selectedOption.id);
}}>
<SelectTrigger className="overflow-hidden text-ellipsis whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
{emailSendToOptions.map((option) => {
return (
<SelectItem key={option.id} value={option.id}>
{option.type !== "hiddenField" ? (
<div className="flex items-center space-x-2">
<div className="h-4 w-4">
{
QUESTIONS_ICON_MAP[
option.type === "openTextQuestion"
? "openText"
: "contactInfo"
]
}
</div>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{option.label}
</span>
</div>
) : (
<div className="flex items-center space-x-2">
<EyeOffIcon className="h-4 w-4" />
<span>{option.label}</span>
</div>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</FormControl>
</div>
)}
</div>
);
}}
/>
</div>
{/* From */}
<div className="flex flex-col space-y-2">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_from_label")}
</h3>
<p className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_action_from_description")}
</p>
<div className="w-fit rounded-md border border-slate-200 bg-slate-100 px-2 py-1">
<span className="text-sm text-slate-900">{mailFrom}</span>
</div>
</div>
{/* Reply To */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="replyTo"
render={({ field }) => {
return (
<FormItem>
<FormLabel htmlFor="replyTo">
{t("environments.surveys.edit.follow_ups_modal_action_replyTo_label")}
</FormLabel>
<FormDescription className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_action_replyTo_description")}
</FormDescription>
<FormControl>
<FollowUpActionMultiEmailInput
emails={field.value}
setEmails={field.onChange}
isInvalid={!!formErrors.replyTo}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</div>
{/* email content */}
<div className="flex flex-col space-y-4 p-4">
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_email_content")}
</h2>
<FormField
control={form.control}
name="subject"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel>
{t("environments.surveys.edit.follow_ups_modal_action_subject_label")}
</FormLabel>
<FormControl>
<Input
{...field}
className="max-w-80"
placeholder={t(
"environments.surveys.edit.follow_ups_modal_action_subject_placeholder"
)}
isInvalid={!!formErrors.subject}
/>
</FormControl>
</div>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel
className={cn(
"font-medium",
formErrors.body ? "text-red-500" : "text-slate-700"
)}>
{t("environments.surveys.edit.follow_ups_modal_action_body_label")}
</FormLabel>
<FormControl>
<Editor
disableLists
excludedToolbarItems={["blockType"]}
getText={() => field.value}
setText={(v: string) => {
field.onChange(v);
}}
firstRender={firstRender}
setFirstRender={setFirstRender}
placeholder={t(
"environments.surveys.edit.follow_ups_modal_action_body_placeholder"
)}
onEmptyChange={(isEmpty) => {
if (isEmpty) {
if (!formErrors.body) {
form.setError("body", {
type: "manual",
message: "Body is required",
});
}
} else {
if (formErrors.body) {
form.clearErrors("body");
}
}
}}
isInvalid={!!formErrors.body}
/>
</FormControl>
{formErrors.body ? (
<div>
<span className="text-sm text-red-500">{formErrors.body.message}</span>
</div>
) : null}
</div>
</FormItem>
);
}}
/>
</div>
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 z-20 h-12 w-full bg-white p-2">
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="minimal"
size="sm"
onClick={() => {
setOpen(false);
form.reset();
}}>
{t("common.cancel")}
</Button>
<Button loading={formSubmitting} variant="primary" size="sm">
{t("common.save")}
</Button>
</div>
</div>
</form>
</FormProvider>
</Modal>
);
};

View File

@@ -0,0 +1,133 @@
import { FollowUpItem } from "@/modules/ee/survey-follow-ups/components/follow-up-item";
import { FollowUpModal } from "@/modules/ee/survey-follow-ups/components/follow-up-modal";
import { LockIcon, MailIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/components/Button";
interface FollowUpsViewProps {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
selectedLanguageCode: string;
mailFrom: string;
isSurveyFollowUpsAllowed: boolean;
userEmail: string;
locale: TUserLocale;
}
export const FollowUpsView = ({
localSurvey,
setLocalSurvey,
selectedLanguageCode,
mailFrom,
isSurveyFollowUpsAllowed,
userEmail,
locale,
}: FollowUpsViewProps) => {
const t = useTranslations();
const [addFollowUpModalOpen, setAddFollowUpModalOpen] = useState(false);
const surveyFollowUps: TSurveyFollowUp[] = localSurvey.followUps.filter((f) => !f.deleted);
if (!isSurveyFollowUpsAllowed) {
return (
<div className="mt-12 space-y-4 p-5">
<div className="flex flex-col items-center gap-y-4 rounded-xl border border-dashed border-slate-300 bg-white p-6 text-center">
<div className="flex items-center justify-center rounded-full border border-slate-200 bg-slate-100 p-2">
<LockIcon className="h-6 w-6 text-slate-500" />
</div>
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.edit.follow_ups_empty_heading")}
</p>
<p className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_empty_description")}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() =>
window.open(
`/environments/${localSurvey.environmentId}/settings/billing`,
"_blank",
"noopener,noreferrer"
)
}>
{t("environments.surveys.edit.follow_ups_upgrade_button_text")}
</Button>
</div>
</div>
);
}
return (
<div className="mt-12 space-y-4 p-5">
<div className="flex justify-end">
{surveyFollowUps.length ? (
<Button variant="primary" size="sm" onClick={() => setAddFollowUpModalOpen(true)}>
+ {t("environments.surveys.edit.follow_ups_new")}
</Button>
) : null}
</div>
<div>
{!surveyFollowUps.length && (
<div className="flex flex-col items-center gap-y-4 rounded-xl border border-dashed border-slate-300 bg-white p-6 text-center">
<div className="flex items-center justify-center rounded-full border border-slate-200 bg-slate-100 p-2">
<MailIcon className="h-6 w-6 text-slate-500" />
</div>
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.edit.follow_ups_empty_heading")}
</p>
<p className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_empty_description")}
</p>
</div>
<Button
className="w-fit"
variant="primary"
size="sm"
onClick={() => setAddFollowUpModalOpen(true)}>
{t("environments.surveys.edit.follow_ups_new")}
</Button>
</div>
)}
</div>
<div className="flex flex-col space-y-2">
{surveyFollowUps.map((followUp) => {
return (
<FollowUpItem
key={followUp.id}
followUp={followUp}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
userEmail={userEmail}
locale={locale}
/>
);
})}
</div>
<FollowUpModal
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
open={addFollowUpModalOpen}
setOpen={setAddFollowUpModalOpen}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
userEmail={userEmail}
locale={locale}
/>
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { Body, Container, Html, Link, Section, Tailwind } from "@react-email/components";
import { sanitize } from "isomorphic-dompurify";
import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
interface FollowUpEmailProps {
html: string;
}
export function FollowUpEmail({ html }: FollowUpEmailProps): React.JSX.Element {
return (
<Html>
<Tailwind>
<Body
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-base font-medium text-slate-800"
style={{
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
<Container className="mx-auto my-8 max-w-xl bg-white p-4 text-left">
<div
dangerouslySetInnerHTML={{
__html: sanitize(html, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
}),
}}
/>
</Container>
<Section className="mt-4 text-center">
powered by Formbricks
<br />
<Link href={IMPRINT_URL} target="_blank" rel="noopener noreferrer">
Imprint
</Link>{" "}
|{" "}
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer">
Privacy Policy
</Link>
</Section>
</Body>
</Tailwind>
</Html>
);
}

View File

@@ -25,6 +25,7 @@ import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { OnboardingInviteEmail } from "./emails/invite/onboarding-invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { FollowUpEmail } from "./emails/survey/follow-up";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
import { NoLiveSurveyNotificationEmail } from "./emails/weekly-summary/no-live-survey-notification-email";
@@ -294,3 +295,23 @@ export const sendNoLiveSurveyNotificationEmail = async (
html,
});
};
export const sendFollowUpEmail = async (
html: string,
subject: string,
to: string,
replyTo: string[]
): Promise<void> => {
const emailHtmlBody = await render(
FollowUpEmail({
html,
})
);
await sendEmail({
to,
replyTo: replyTo.join(", "),
subject,
html: emailHtmlBody,
});
};

View File

@@ -3,9 +3,12 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper";
import { getSurveyFollowUpsPermission } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { createSurvey } from "@formbricks/lib/survey/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
const ZCreateSurveyAction = z.object({
@@ -13,12 +16,33 @@ const ZCreateSurveyAction = z.object({
surveyBody: ZSurveyCreateInput,
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param { string } organizationId The ID of the organization to check.
* @returns { Promise<void> } A promise that resolves if the permission is granted.
* @throws { ResourceNotFoundError } If the organization is not found.
* @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const createSurveyAction = authenticatedActionClient
.schema(ZCreateSurveyAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
organizationId,
access: [
{
type: "organization",
@@ -32,5 +56,9 @@ export const createSurveyAction = authenticatedActionClient
],
});
if (parsedInput.surveyBody.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
return await createSurvey(parsedInput.environmentId, parsedInput.surveyBody);
});

View File

@@ -23,6 +23,7 @@ export class ResponseAPI {
async update({
responseId,
finished,
endingId,
data,
ttc,
variables,
@@ -30,6 +31,7 @@ export class ResponseAPI {
}: TResponseUpdateInputWithResponseId): Promise<Result<object, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
endingId,
data,
ttc,
variables,

View File

@@ -1,14 +1,12 @@
/* eslint-disable import/no-relative-packages -- required for importing types */
/* eslint-disable @typescript-eslint/no-namespace -- using namespaces is required for prisma-json-types-generator */
import { type TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { type TIntegrationConfig } from "@formbricks/types/integration";
import { type TOrganizationBilling } from "@formbricks/types/organizations";
import { type TProductConfig, type TProductStyling } from "@formbricks/types/product";
import {
type TResponseData,
type TResponseMeta,
type TResponsePersonAttributes,
} from "@formbricks/types/responses";
import { type TBaseFilters } from "@formbricks/types/segment";
import { type TActionClassNoCodeConfig } from "../types/action-classes";
import { type TIntegrationConfig } from "../types/integration";
import { type TOrganizationBilling } from "../types/organizations";
import { type TProductConfig, type TProductStyling } from "../types/product";
import { type TResponseData, type TResponseMeta, type TResponsePersonAttributes } from "../types/responses";
import { type TBaseFilters } from "../types/segment";
import {
type TSurveyClosedMessage,
type TSurveyEnding,
@@ -19,8 +17,9 @@ import {
type TSurveyStyling,
type TSurveyVariables,
type TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { type TUserLocale, type TUserNotificationSettings } from "@formbricks/types/user";
} from "../types/surveys/types";
import { type TUserLocale, type TUserNotificationSettings } from "../types/user";
import type { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "./types/survey-follow-up";
declare global {
namespace PrismaJson {
@@ -45,5 +44,7 @@ declare global {
export type SegmentFilter = TBaseFilters;
export type Styling = TProductStyling;
export type Locale = TUserLocale;
export type SurveyFollowUpTrigger = TSurveyFollowUpTrigger;
export type SurveyFollowUpAction = TSurveyFollowUpAction;
}
}

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "SurveyFollowUp" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"surveyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"trigger" JSONB NOT NULL,
"action" JSONB NOT NULL,
CONSTRAINT "SurveyFollowUp_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SurveyFollowUp" ADD CONSTRAINT "SurveyFollowUp_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "Survey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Response" ADD COLUMN "endingId" TEXT;

View File

@@ -65,9 +65,8 @@
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/types": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"@formbricks/eslint-config": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"prisma": "5.20.0",
"prisma-dbml-generator": "0.12.0",
"prisma-json-types-generator": "3.1.1",

View File

@@ -112,6 +112,7 @@ model Response {
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
endingId String?
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
@@ -333,11 +334,27 @@ model Survey {
languages SurveyLanguage[]
showLanguageSwitch Boolean?
documents Document[]
followUps SurveyFollowUp[]
@@index([environmentId, updatedAt])
@@index([segmentId])
}
model SurveyFollowUp {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
name String
/// [SurveyFollowUpTrigger]
/// @zod.custom(imports.ZSurveyFollowUpTrigger)
trigger Json
/// [SurveyFollowUpAction]
/// @zod.custom(imports.ZSurveyFollowUpAction)
action Json
}
enum ActionType {
code
noCode

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
export const ZSurveyFollowUpTrigger = z
.object({
type: z.enum(["response", "endings"]),
properties: z
.object({
endingIds: z.array(z.string().cuid2()),
})
.nullable(),
})
.superRefine((trigger, ctx) => {
if (trigger.type === "response") {
if (trigger.properties) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Properties should be null for response type",
});
}
}
if (trigger.type === "endings") {
if (!trigger.properties) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Properties must be defined for endings type",
});
}
}
});
export type TSurveyFollowUpTrigger = z.infer<typeof ZSurveyFollowUpTrigger>;
export const ZSurveyFollowUpAction = z.object({
type: z.literal("send-email"),
properties: z.object({
to: z.string(),
from: z.string().email(),
replyTo: z.array(z.string().email()),
subject: z.string(),
body: z.string(),
}),
});
export type TSurveyFollowUpAction = z.infer<typeof ZSurveyFollowUpAction>;
export const ZSurveyFollowUp = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
trigger: ZSurveyFollowUpTrigger,
action: ZSurveyFollowUpAction,
surveyId: z.string().cuid2(),
});
export type TSurveyFollowUp = z.infer<typeof ZSurveyFollowUp>;

View File

@@ -1,15 +1,11 @@
/* eslint-disable import/no-relative-packages -- required for importing types */
import { z } from "zod";
export const ZActionProperties = z.record(z.string());
export { ZActionClassNoCodeConfig } from "@formbricks/types/action-classes";
export { ZIntegrationConfig } from "@formbricks/types/integration";
export { ZActionClassNoCodeConfig } from "../types/action-classes";
export { ZIntegrationConfig } from "../types/integration";
export {
ZResponseData,
ZResponsePersonAttributes,
ZResponseMeta,
ZResponseTtc,
} from "@formbricks/types/responses";
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta, ZResponseTtc } from "../types/responses";
export {
ZSurveyWelcomeCard,
@@ -22,8 +18,10 @@ export {
ZSurveySingleUse,
ZSurveyInlineTriggers,
ZSurveyEnding,
} from "@formbricks/types/surveys/types";
} from "../types/surveys/types";
export { ZSegmentFilters } from "@formbricks/types/segment";
export { ZOrganizationBilling } from "@formbricks/types/organizations";
export { ZUserNotificationSettings } from "@formbricks/types/user";
export { ZSurveyFollowUpAction, ZSurveyFollowUpTrigger } from "./types/survey-follow-up";
export { ZSegmentFilters } from "../types/segment";
export { ZOrganizationBilling } from "../types/organizations";
export { ZUserNotificationSettings } from "../types/user";

View File

@@ -1,421 +0,0 @@
export const Permissions = {
owner: {
environment: {
read: true,
},
product: {
create: true,
read: true,
update: true,
delete: true,
},
organization: {
read: true,
update: true,
delete: true,
},
membership: {
create: true,
update: true,
delete: true,
},
person: {
read: true,
delete: true,
},
response: {
read: true,
update: true,
delete: true,
},
survey: {
create: true,
read: true,
update: true,
delete: true,
},
tag: {
create: true,
update: true,
delete: true,
},
responseNote: {
create: true,
update: true,
delete: true,
},
segment: {
create: true,
read: true,
update: true,
delete: true,
},
actionClass: {
create: true,
delete: true,
},
integration: {
create: true,
update: true,
delete: true,
},
webhook: {
create: true,
update: true,
delete: true,
},
apiKey: {
create: true,
delete: true,
},
subscription: {
create: true,
read: true,
update: true,
delete: true,
},
invite: {
create: true,
read: true,
update: true,
delete: true,
},
language: {
create: true,
update: true,
delete: true,
},
team: {
create: true,
read: true,
update: true,
delete: true,
},
teamMembership: {
create: true,
read: true,
update: true,
delete: true,
},
productTeam: {
create: true,
update: true,
delete: true,
},
},
manager: {
environment: {
read: true,
},
product: {
create: true,
read: true,
update: true,
delete: true,
},
organization: {
read: true,
update: true,
delete: false,
},
membership: {
create: true,
update: true,
delete: true,
},
person: {
read: true,
delete: true,
},
response: {
read: true,
update: true,
delete: true,
},
survey: {
create: true,
read: true,
update: true,
delete: true,
},
tag: {
create: true,
update: true,
delete: true,
},
responseNote: {
create: true,
update: true,
delete: true,
},
segment: {
create: true,
read: true,
update: true,
delete: true,
},
actionClass: {
create: true,
delete: true,
},
integration: {
create: true,
update: true,
delete: true,
},
webhook: {
create: true,
update: true,
delete: true,
},
apiKey: {
create: true,
delete: true,
},
subscription: {
create: true,
read: true,
update: true,
delete: true,
},
invite: {
create: true,
read: true,
update: true,
delete: true,
},
language: {
create: true,
update: true,
delete: true,
},
team: {
create: true,
read: true,
update: true,
delete: true,
},
teamMembership: {
create: true,
read: true,
update: true,
delete: true,
},
productTeam: {
create: true,
update: true,
delete: true,
},
},
billing: {
environment: {
read: true,
},
product: {
create: false,
read: true,
update: false,
delete: false,
},
organization: {
read: true,
update: false,
delete: false,
},
membership: {
create: false,
update: false,
delete: false,
},
person: {
read: false,
delete: false,
},
response: {
read: false,
update: false,
delete: false,
},
survey: {
create: false,
read: false,
update: false,
delete: false,
},
tag: {
create: false,
update: false,
delete: false,
},
responseNote: {
create: false,
update: false,
delete: false,
},
segment: {
create: false,
read: false,
update: false,
delete: false,
},
actionClass: {
create: false,
delete: false,
},
integration: {
create: false,
update: false,
delete: false,
},
webhook: {
create: false,
update: false,
delete: false,
},
apiKey: {
create: false,
delete: false,
},
subscription: {
create: true,
read: true,
update: true,
delete: true,
},
invite: {
create: false,
read: false,
update: false,
delete: false,
},
language: {
create: false,
update: false,
delete: false,
},
team: {
create: false,
read: true,
update: false,
delete: false,
},
teamMembership: {
create: false,
read: false,
update: false,
delete: false,
},
productTeam: {
create: false,
update: false,
delete: false,
},
},
member: {
environment: {
read: true,
},
product: {
create: false,
read: true,
update: true,
delete: false,
},
organization: {
read: false,
update: false,
delete: false,
},
membership: {
create: false,
update: false,
delete: false,
},
person: {
read: true,
delete: true,
},
response: {
read: true,
update: true,
delete: true,
},
survey: {
create: true,
read: true,
update: true,
delete: true,
},
tag: {
create: true,
update: true,
delete: true,
},
responseNote: {
create: true,
update: true,
delete: true,
},
segment: {
create: true,
read: true,
update: true,
delete: true,
},
actionClass: {
create: true,
delete: true,
},
integration: {
create: true,
update: true,
delete: true,
},
webhook: {
create: true,
update: true,
delete: true,
},
apiKey: {
create: true,
delete: true,
},
subscription: {
create: false,
read: false,
update: false,
delete: false,
},
invite: {
create: false,
read: false,
update: false,
delete: false,
},
language: {
create: true,
update: true,
delete: true,
},
team: {
create: false,
read: true,
update: false,
delete: false,
},
teamMembership: {
create: true,
read: true,
update: true,
delete: true,
},
productTeam: {
create: false,
update: false,
delete: false,
},
},
};

View File

@@ -221,7 +221,7 @@
"invite_them": "Lade sie ein",
"join_discord": "Discord beitreten",
"key": "Schlüssel",
"label": "Etikett",
"label": "Bezeichnung",
"language": "Sprache",
"languages": "Sprachen",
"license": "Lizenz",
@@ -1376,6 +1376,48 @@
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
"first_name": "Vorname",
"five_points_recommended": "5 Punkte (empfohlen)",
"follow_ups": "Follow-ups",
"follow_ups_delete_modal_text": "Bist du sicher, dass du dieses Follow-up löschen möchtest?",
"follow_ups_delete_modal_title": "Follow-up löschen?",
"follow_ups_empty_description": "Sende Nachrichten an Teilnehmer der Umfrage, dich selbst oder Teammitglieder.",
"follow_ups_empty_heading": "Automatische Follow-ups versenden",
"follow_ups_ending_card_delete_modal_text": "Dieser Abschluss wird in Follow-ups verwendet. Wenn Sie ihn löschen, wird er aus allen Follow-ups entfernt. Sind Sie sicher, dass Sie ihn löschen möchten?",
"follow_ups_ending_card_delete_modal_title": "Abschlusskarte löschen?",
"follow_ups_hidden_field_error": "Verstecktes Feld wird in einem Follow-up verwendet. Bitte entfernen Sie es zuerst aus dem Follow-up.",
"follow_ups_item_ending_tag": "Abschluss",
"follow_ups_item_issue_detected_tag": "Problem erkannt",
"follow_ups_item_response_tag": "Jede Antwort",
"follow_ups_item_send_email_tag": "E-Mail senden",
"follow_ups_modal_action_body_label": "Inhalt",
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
"follow_ups_modal_action_email_content": "E-Mail Inhalt",
"follow_ups_modal_action_email_settings": "E-Mail Einstellungen",
"follow_ups_modal_action_from_description": "Absender E-Mail",
"follow_ups_modal_action_from_label": "Von",
"follow_ups_modal_action_label": "Aktion",
"follow_ups_modal_action_replyTo_description": "Wenn der Empfänger antwortet, geht die Antwort an diese E-Mail-Adresse",
"follow_ups_modal_action_replyTo_label": "Antwort an",
"follow_ups_modal_action_replyTo_placeholder": "E-Mail-Adresse eingeben & Leertaste drücken",
"follow_ups_modal_action_subject": "Danke für deine Antworten!",
"follow_ups_modal_action_subject_label": "Betreff",
"follow_ups_modal_action_subject_placeholder": "Betreff der E-Mail",
"follow_ups_modal_action_to_description": "Empfänger-E-Mail-Adresse",
"follow_ups_modal_action_to_label": "An",
"follow_ups_modal_action_to_warning": "Kein E-Mail-Feld in der Umfrage gefunden.",
"follow_ups_modal_create_heading": "Neues Follow-up erstellen",
"follow_ups_modal_edit_heading": "Follow-up bearbeiten",
"follow_ups_modal_edit_no_id": "Keine Survey Follow-up-ID angegeben, das Survey-Follow-up kann nicht aktualisiert werden",
"follow_ups_modal_name_label": "Name des Follow-ups",
"follow_ups_modal_name_placeholder": "Benenne dein Follow-up",
"follow_ups_modal_subheading": "Sende Nachrichten an Teilnehmer, dich selbst oder Teammitglieder",
"follow_ups_modal_trigger_description": "Wann soll dieses Follow-up ausgelöst werden?",
"follow_ups_modal_trigger_label": "Auslöser",
"follow_ups_modal_trigger_type_ending": "Teilnehmer sieht einen bestimmten Abschluss",
"follow_ups_modal_trigger_type_ending_select": "Abschlüsse auswählen: ",
"follow_ups_modal_trigger_type_ending_warning": "Keine Abschlüsse in der Umfrage gefunden!",
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"for_advanced_targeting_please": "Für fortgeschrittenes Targeting, bitte",
"form_styling": "Umfrage Styling",
"formbricks_ai_description": "Beschreibe deine Umfrage und lass Formbricks KI die Umfrage für Dich erstellen",
@@ -2320,6 +2362,7 @@
"file_upload_description": "Ermögliche es den Befragten, Dokumente, Bilder oder andere Dateien hochzuladen",
"file_upload_headline": "Datei hochladen",
"finish": "Fertigstellen",
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey 👋</span><br><br><span style=\"white-space: pre-wrap;\">Danke, dass du dir die Zeit genommen hast zu antworten. Wir melden uns bald bei dir.</span><br><br><span style=\"white-space: pre-wrap;\">Hab noch einen schönen Tag!</span></p>",
"free_text": "Freitext",
"free_text_description": "Sammle offenes Feedback",
"free_text_headline": "Was möchtest Du mit uns teilen?",

View File

@@ -1376,8 +1376,50 @@
"field_name_eg_score_price": "Field name e.g, score, price",
"first_name": "First Name",
"five_points_recommended": "5 points (recommended)",
"follow_ups": "Follow-ups",
"follow_ups_delete_modal_text": "Are you sure you want to delete this follow-up?",
"follow_ups_delete_modal_title": "Delete follow-up?",
"follow_ups_empty_description": "Send messages to respondents, yourself or team mates.",
"follow_ups_empty_heading": "Send automatic follow-ups",
"follow_ups_ending_card_delete_modal_text": "This ending card is used in follow-ups. Deleting it will remove it from all follow-ups. Are you sure you want to delete it?",
"follow_ups_ending_card_delete_modal_title": "Delete ending card?",
"follow_ups_hidden_field_error": "Hidden field is used in a follow-up. Please remove it from follow-up first.",
"follow_ups_item_ending_tag": "Ending(s)",
"follow_ups_item_issue_detected_tag": "Issue detected",
"follow_ups_item_response_tag": "Any response",
"follow_ups_item_send_email_tag": "Send email",
"follow_ups_modal_action_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
"follow_ups_modal_action_email_content": "Email content",
"follow_ups_modal_action_email_settings": "Email settings",
"follow_ups_modal_action_from_description": "Email address to send the email from",
"follow_ups_modal_action_from_label": "From",
"follow_ups_modal_action_label": "Action",
"follow_ups_modal_action_replyTo_description": "If the recipient hits reply, the following email address will receive it",
"follow_ups_modal_action_replyTo_label": "Reply To",
"follow_ups_modal_action_replyTo_placeholder": "Write an email address & press space bar",
"follow_ups_modal_action_subject": "Thanks for your answers!",
"follow_ups_modal_action_subject_label": "Subject",
"follow_ups_modal_action_subject_placeholder": "Subject of the email",
"follow_ups_modal_action_to_description": "Email address to send the email to",
"follow_ups_modal_action_to_label": "To",
"follow_ups_modal_action_to_warning": "No email field detected in the survey",
"follow_ups_modal_create_heading": "Create a new follow-up",
"follow_ups_modal_edit_heading": "Edit this follow-up",
"follow_ups_modal_edit_no_id": "No survey follow up id provided, can't update the survey follow up",
"follow_ups_modal_name_label": "Follow-up name",
"follow_ups_modal_name_placeholder": "Name your follow-up",
"follow_ups_modal_subheading": "Send messages to respondents, yourself or team mates",
"follow_ups_modal_trigger_description": "When should this follow-up be triggered?",
"follow_ups_modal_trigger_label": "Trigger",
"follow_ups_modal_trigger_type_ending": "Respondent sees a specific ending",
"follow_ups_modal_trigger_type_ending_select": "Select endings: ",
"follow_ups_modal_trigger_type_ending_warning": "No endings found in the survey!",
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"for_advanced_targeting_please": "For advanced targeting, please",
"form_styling": "Form Styling",
"form_styling": "Form styling",
"formbricks_ai_description": "Describe your survey and let Formbricks AI create the survey for you",
"formbricks_ai_generate": "Generate",
"formbricks_ai_prompt_placeholder": "Enter survey information (e.g. key topics to cover)",
@@ -1385,7 +1427,7 @@
"four_points": "4 points",
"heading": "Heading",
"hidden_field_added_successfully": "Hidden field added successfully",
"hide_advanced_settings": "Hide Advanced settings",
"hide_advanced_settings": "Hide advanced settings",
"hide_logo": "Hide logo",
"hide_progress_bar": "Hide progress bar",
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
@@ -2320,6 +2362,7 @@
"file_upload_description": "Enable respondents to upload documents, images, or other files",
"file_upload_headline": "File Upload",
"finish": "Finish",
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey 👋</span><br><br><span style=\"white-space: pre-wrap;\">Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span style=\"white-space: pre-wrap;\">Have a great day!</span></p>",
"free_text": "Free text",
"free_text_description": "Collect open-ended feedback",
"free_text_headline": "Who let the dogs out?",

View File

@@ -1376,6 +1376,48 @@
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
"first_name": "Primeiro Nome",
"five_points_recommended": "5 pontos (recomendado)",
"follow_ups": "Acompanhamentos",
"follow_ups_delete_modal_text": "Tem certeza de que deseja excluir este acompanhamento?",
"follow_ups_delete_modal_title": "Excluir acompanhamento?",
"follow_ups_empty_description": "Envie mensagens para os entrevistados, para você mesmo ou para os colegas de equipe.",
"follow_ups_empty_heading": "Enviar acompanhamentos automáticos",
"follow_ups_ending_card_delete_modal_text": "Este final é usado em acompanhamentos. Excluí-lo o removerá de todos os acompanhamentos. Tem certeza de que deseja excluí-lo?",
"follow_ups_ending_card_delete_modal_title": "Excluir cartão de final?",
"follow_ups_hidden_field_error": "O campo oculto está sendo usado em um acompanhamento. Por favor, remova-o do acompanhamento primeiro.",
"follow_ups_item_ending_tag": "Final(is)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar e-mail",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
"follow_ups_modal_action_email_content": "Conteúdo do e-mail",
"follow_ups_modal_action_email_settings": "Configuração de e-mail",
"follow_ups_modal_action_from_description": "Endereço de e-mail de onde o e-mail será enviado",
"follow_ups_modal_action_from_label": "De",
"follow_ups_modal_action_label": "Ação",
"follow_ups_modal_action_replyTo_description": "Se o destinatário responder, o seguinte endereço de e-mail receberá a resposta",
"follow_ups_modal_action_replyTo_label": "Responder para",
"follow_ups_modal_action_replyTo_placeholder": "Escreva um endereço de e-mail e pressione a barra de espaço",
"follow_ups_modal_action_subject": "Valeu pelas respostas!",
"follow_ups_modal_action_subject_label": "Assunto",
"follow_ups_modal_action_subject_placeholder": "Assunto do e-mail",
"follow_ups_modal_action_to_description": "Endereço de e-mail para enviar o e-mail para",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de e-mail detectado na pesquisa",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento da pesquisa fornecido, não é possível atualizar o acompanhamento da pesquisa",
"follow_ups_modal_name_label": "Nome do acompanhamento",
"follow_ups_modal_name_placeholder": "Nomeie seu acompanhamento",
"follow_ups_modal_subheading": "Envie mensagens para os entrevistados, para você mesmo ou para os colegas de equipe",
"follow_ups_modal_trigger_description": "Quando este acompanhamento deve ser acionado?",
"follow_ups_modal_trigger_label": "Gatilho",
"follow_ups_modal_trigger_type_ending": "Respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecione os finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Nenhum final encontrado na pesquisa!",
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"for_advanced_targeting_please": "Para uma segmentação avançada, por favor",
"form_styling": "Estilização de Formulários",
"formbricks_ai_description": "Descreva sua pesquisa e deixe a Formbricks AI criar a pesquisa pra você",
@@ -2320,6 +2362,7 @@
"file_upload_description": "Permitir que os respondentes façam upload de documentos, imagens ou outros arquivos",
"file_upload_headline": "Enviar Arquivo",
"finish": "Terminar",
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Oi 👋</span><br><br><span style=\"white-space: pre-wrap;\">Valeu por tirar um tempinho pra responder. A gente vai entrar em contato em breve.</span><br><br><span style=\"white-space: pre-wrap;\">Tenha um ótimo dia!</span></p>",
"free_text": "Texto livre",
"free_text_description": "Coletar feedback aberto",
"free_text_headline": "Quem deixou os cachorros saírem?",

View File

@@ -51,6 +51,7 @@ export const responseSelection = {
updatedAt: true,
surveyId: true,
finished: true,
endingId: true,
data: true,
meta: true,
ttc: true,
@@ -253,6 +254,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
surveyId,
displayId,
finished,
endingId,
data,
meta,
singleUseId,
@@ -292,7 +294,8 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
finished,
endingId,
data: data,
language: language,
...(person?.id && {
@@ -673,6 +676,7 @@ export const updateResponse = async (
},
data: {
finished: responseInput.finished,
endingId: responseInput.endingId,
data,
ttc,
language,

View File

@@ -138,5 +138,6 @@ export const getPreviewSurvey = (locale: string) => {
languages: [],
triggers: [],
showLanguageSwitch: false,
followUps: [],
} as TSurvey;
};

View File

@@ -140,6 +140,7 @@ export const selectSurvey = {
},
},
},
followUps: true,
} satisfies Prisma.SurveySelect;
const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: TActionClass[]) => {
@@ -391,6 +392,7 @@ export const getInProgressSurveyCount = reactCache(
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
validateInputs([updatedSurvey, ZSurvey]);
try {
const surveyId = updatedSurvey.id;
let data: any = {};
@@ -402,7 +404,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
throw new ResourceNotFoundError("Survey", surveyId);
}
const { triggers, environmentId, segment, questions, languages, type, ...surveyData } = updatedSurvey;
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
if (languages) {
// Process languages update logic here
@@ -545,6 +548,53 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
}
}
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
@@ -812,6 +862,19 @@ export const createSurvey = async (
}
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
create: restSurveyBody.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
};
} else {
delete data.followUps;
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -1038,6 +1101,15 @@ export const copySurveyToOtherEnvironment = async (
: Prisma.JsonNull,
styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull,
segment: undefined,
followUps: {
createMany: {
data: existingSurvey.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
},
},
};
// Handle segment

View File

@@ -251,6 +251,7 @@ export const mockSurveyOutput: SurveyMock = {
resultShareKey: null,
inlineTriggers: null,
languages: mockSurveyLanguages,
followUps: [],
...baseSurveyProperties,
};
@@ -277,6 +278,7 @@ export const updateSurveyInput: TSurvey = {
languages: [],
showLanguageSwitch: null,
variables: [],
followUps: [],
...commonMockProperties,
...baseSurveyProperties,
};

View File

@@ -286,6 +286,10 @@ export const Survey = ({
nextQuestionId === undefined ||
!localSurvey.questions.map((question) => question.id).includes(nextQuestionId);
const endingId = nextQuestionId
? localSurvey.endings.find((ending) => ending.id === nextQuestionId)?.id
: undefined;
onChange(responseData);
onChangeVariables(calculatedVariables);
onResponse({
@@ -294,6 +298,7 @@ export const Survey = ({
finished,
variables: calculatedVariables,
language: selectedLanguage,
endingId,
});
if (finished) {
// Post a message to the parent window indicating that the survey is completed.

View File

@@ -9,7 +9,8 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*"
"@formbricks/config-typescript": "workspace:*",
"@formbricks/database": "workspace:*"
},
"dependencies": {
"zod": "3.23.8"

View File

@@ -259,6 +259,7 @@ export const ZResponse = z.object({
person: ZResponsePerson.nullable(),
personAttributes: ZResponsePersonAttributes,
finished: z.boolean(),
endingId: z.string().nullish(),
data: ZResponseData,
variables: ZResponseVariables,
ttc: ZResponseTtc.optional(),
@@ -280,6 +281,7 @@ export const ZResponseInput = z.object({
displayId: z.string().nullish(),
singleUseId: z.string().nullable().optional(),
finished: z.boolean(),
endingId: z.string().nullish(),
language: z.string().optional(),
data: ZResponseData,
variables: ZResponseVariables.optional(),
@@ -305,6 +307,7 @@ export type TResponseInput = z.infer<typeof ZResponseInput>;
export const ZResponseUpdateInput = z.object({
finished: z.boolean(),
endingId: z.string().nullish(),
data: ZResponseData,
variables: ZResponseVariables.optional(),
ttc: ZResponseTtc.optional(),
@@ -337,6 +340,7 @@ export const ZResponseUpdate = z.object({
.optional(),
hiddenFields: ZResponseHiddenFieldValue.optional(),
displayId: z.string().nullish(),
endingId: z.string().nullish(),
});
export type TResponseUpdate = z.infer<typeof ZResponseUpdate>;

View File

@@ -1,4 +1,5 @@
import { type ZodIssue, z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZAttributes } from "../attributes";
import { ZAllowedFileExtension, ZColor, ZId, ZPlacement } from "../common";
@@ -769,6 +770,11 @@ export const ZSurvey = z
});
}
}),
followUps: z.array(
ZSurveyFollowUp.extend({
deleted: z.boolean().optional(),
})
),
delay: z.number(),
autoComplete: z.number().min(1, { message: "Response limit must be greater than 0" }).nullable(),
runOnDate: z.date().nullable(),
@@ -1156,6 +1162,52 @@ export const ZSurvey = z
}
}
});
if (survey.followUps.length) {
survey.followUps
.filter((followUp) => !followUp.deleted)
.forEach((followUp, index) => {
if (followUp.action.properties.to) {
const validOptions = [
...survey.questions
.filter((q) => {
if (q.type === TSurveyQuestionTypeEnum.OpenText) {
if (q.inputType === "email") {
return true;
}
}
if (q.type === TSurveyQuestionTypeEnum.ContactInfo) {
return true;
}
return false;
})
.map((q) => q.id),
...(survey.hiddenFields.fieldIds ?? []),
];
if (validOptions.findIndex((option) => option === followUp.action.properties.to) === -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The action in follow up ${String(index + 1)} has an invalid email field`,
path: ["followUps"],
});
}
if (followUp.trigger.type === "endings") {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- endingIds is always defined
if (!followUp.trigger.properties?.endingIds?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The trigger in follow up ${String(index + 1)} has no ending selected`,
path: ["followUps"],
});
}
}
}
});
}
});
const isInvalidOperatorsForQuestionType = (
@@ -2111,7 +2163,19 @@ const validateLogic = (survey: TSurvey, questionIndex: number, logic: TSurveyLog
// ZSurvey is a refinement, so to extend it to ZSurveyUpdateInput, we need to transform the innerType and then apply the same refinements.
export const ZSurveyUpdateInput = ZSurvey.innerType()
.omit({ createdAt: true, updatedAt: true })
.omit({ createdAt: true, updatedAt: true, followUps: true })
.extend({
followUps: z
.array(
ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true }).and(
z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
})
)
)
.default([]),
})
.and(
z.object({
createdAt: z.coerce.date(),
@@ -2136,6 +2200,7 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType())
updatedAt: true,
productOverwrites: true,
languages: true,
followUps: true,
})
.extend({
name: z.string(), // Keep name required
@@ -2146,6 +2211,7 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType())
}),
endings: ZSurveyEndings.default([]),
type: ZSurveyType.default("link"),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).default([]),
})
.superRefine(ZSurvey._def.effect.type === "refinement" ? ZSurvey._def.effect.refinement : () => null);
@@ -2160,7 +2226,7 @@ export interface TSurveyDates {
export type TSurveyCreateInput = z.input<typeof ZSurveyCreateInput>;
export type TSurveyEditorTabs = "questions" | "settings" | "styling";
export type TSurveyEditorTabs = "questions" | "settings" | "styling" | "followUps";
export const ZSurveyQuestionSummaryOpenText = z.object({
type: z.literal("openText"),

View File

@@ -1,3 +1,4 @@
import { useTranslations } from "next-intl";
import React from "react";
import { Button } from "../Button";
import { Modal } from "../Modal";
@@ -29,6 +30,7 @@ export const ConfirmationModal = ({
closeOnOutsideClick = true,
hideCloseButton,
}: ConfirmationModalProps) => {
const t = useTranslations();
const handleButtonAction = async () => {
if (isButtonDisabled) return;
await onConfirm();
@@ -47,7 +49,7 @@ export const ConfirmationModal = ({
<div className="mt-4 space-x-2 text-right">
<Button variant="minimal" onClick={() => setOpen(false)}>
Cancel
{t("common.cancel")}
</Button>
<Button
loading={buttonLoading}

View File

@@ -11,13 +11,14 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
import type { Dispatch, SetStateAction } from "react";
import { type Dispatch, type SetStateAction, useRef } from "react";
import { cn } from "@formbricks/lib/cn";
import { PlaygroundAutoLinkPlugin as AutoLinkPlugin } from "../components/AutoLinkPlugin";
import { ToolbarPlugin } from "../components/ToolbarPlugin";
import { exampleTheme } from "../lib/ExampleTheme";
import "../stylesEditor.css";
import "../stylesEditorFrontend.css";
import { PlaygroundAutoLinkPlugin as AutoLinkPlugin } from "./AutoLinkPlugin";
import { EditorContentChecker } from "./EditorContentChecker";
import { ToolbarPlugin } from "./ToolbarPlugin";
/*
Detault toolbar items:
@@ -38,6 +39,8 @@ export type TextEditorProps = {
firstRender?: boolean;
setFirstRender?: Dispatch<SetStateAction<boolean>>;
editable?: boolean;
onEmptyChange?: (isEmpty: boolean) => void;
isInvalid?: boolean;
};
const editorConfig = {
@@ -63,11 +66,14 @@ const editorConfig = {
export const Editor = (props: TextEditorProps) => {
const editable = props.editable ?? true;
const editorContainerRef = useRef<HTMLDivElement>(null);
return (
<div className="editor cursor-text rounded-md">
<LexicalComposer initialConfig={{ ...editorConfig, editable }}>
<div className="editor-container rounded-md p-0">
<div
ref={editorContainerRef}
className={cn("editor-container rounded-md p-0", props.isInvalid && "!border !border-red-500")}>
<ToolbarPlugin
getText={props.getText}
setText={props.setText}
@@ -77,14 +83,16 @@ export const Editor = (props: TextEditorProps) => {
updateTemplate={props.updateTemplate}
firstRender={props.firstRender}
setFirstRender={props.setFirstRender}
container={editorContainerRef.current}
/>
{props.onEmptyChange ? <EditorContentChecker onEmptyChange={props.onEmptyChange} /> : null}
<div
className={cn("editor-inner scroll-bar", !editable && "bg-muted")}
style={{ height: props.height }}>
<RichTextPlugin
contentEditable={<ContentEditable style={{ height: props.height }} className="editor-input" />}
placeholder={
<div className="text-muted -mt-11 cursor-text p-3 text-sm">{props.placeholder || ""}</div>
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">{props.placeholder || ""}</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>

View File

@@ -0,0 +1,25 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $getRoot } from "lexical";
import { useEffect } from "react";
export const EditorContentChecker = ({ onEmptyChange }: { onEmptyChange: (isEmpty: boolean) => void }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const checkIfEmpty = () => {
editor.update(() => {
const root = $getRoot();
const isEmpty = root.getChildren().length === 0 || root.getTextContent().trim() === "";
onEmptyChange(isEmpty);
});
};
// Check initially and subscribe to editor updates
checkIfEmpty();
const unregister = editor.registerUpdateListener(() => checkIfEmpty());
return () => unregister();
}, [editor, onEmptyChange]);
return null;
};

View File

@@ -222,7 +222,7 @@ const getSelectedNode = (selection: RangeSelection) => {
}
};
export const ToolbarPlugin = (props: TextEditorProps) => {
export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement | null }) => {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
@@ -450,6 +450,7 @@ export const ToolbarPlugin = (props: TextEditorProps) => {
}, [editor]);
if (!props.editable) return <></>;
return (
<div className="toolbar flex" ref={toolbarRef}>
<>
@@ -525,7 +526,8 @@ export const ToolbarPlugin = (props: TextEditorProps) => {
onClick={insertLink}
className={isLink ? "bg-subtle active-button" : "inactive-button"}
/>
{isLink && createPortal(<FloatingLinkEditor editor={editor} />, document.body)}{" "}
{isLink &&
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)}{" "}
</>
)}
</>

View File

@@ -215,8 +215,8 @@ pre::-webkit-scrollbar-thumb {
.link-editor {
position: absolute;
z-index: 100;
top: -10000px;
left: -10000px;
top: 10px !important;
left: 10px !important;
margin-left: 80px;
margin-top: -6px;
max-width: 300px;

View File

@@ -84,7 +84,14 @@ const FormLabel = React.forwardRef<
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-error", className)} htmlFor={formItemId} {...props} />;
return (
<Label
ref={ref}
className={cn(error ? "text-red-500" : "text-slate-800", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";

View File

@@ -0,0 +1,9 @@
import { CrownIcon } from "lucide-react";
export const ProBadge = () => {
return (
<div className="ml-2 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100 p-0.5 text-slate-500">
<CrownIcon className="h-3 w-3" />
</div>
);
};

203
pnpm-lock.yaml generated
View File

@@ -133,19 +133,19 @@ importers:
version: 2.1.9(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)
'@headlessui/tailwindcss':
specifier: 0.2.1
version: 0.2.1(tailwindcss@3.4.13(ts-node@10.9.2(typescript@5.4.5)))
version: 0.2.1(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))
'@mapbox/rehype-prism':
specifier: 0.9.0
version: 0.9.0
'@mdx-js/loader':
specifier: 3.0.1
version: 3.0.1(webpack@5.95.0)
version: 3.0.1(webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13)))
'@mdx-js/react':
specifier: 3.0.1
version: 3.0.1(@types/react@18.3.11)(react@19.0.0-rc-ed15d500-20241110)
'@next/mdx':
specifier: 15.0.3
version: 15.0.3(@mdx-js/loader@3.0.1(webpack@5.95.0))(@mdx-js/react@3.0.1(@types/react@18.3.11)(react@19.0.0-rc-ed15d500-20241110))
version: 15.0.3(@mdx-js/loader@3.0.1(webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13))))(@mdx-js/react@3.0.1(@types/react@18.3.11)(react@19.0.0-rc-ed15d500-20241110))
'@paralleldrive/cuid2':
specifier: 2.2.2
version: 2.2.2
@@ -154,7 +154,7 @@ importers:
version: 2.2.1
'@tailwindcss/typography':
specifier: 0.5.15
version: 0.5.15(tailwindcss@3.4.13(ts-node@10.9.2(typescript@5.4.5)))
version: 0.5.15(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))
acorn:
specifier: 8.12.1
version: 8.12.1
@@ -250,7 +250,7 @@ importers:
version: 1.2.1
tailwindcss:
specifier: 3.4.13
version: 3.4.13(ts-node@10.9.2)
version: 3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
unist-util-filter:
specifier: 5.0.1
version: 5.0.1
@@ -318,7 +318,7 @@ importers:
version: 8.3.5(@storybook/test@8.3.5(storybook@8.3.5))(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)(storybook@8.3.5)(typescript@5.4.5)
'@storybook/react-vite':
specifier: 8.3.5
version: 8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(@storybook/test@8.3.5(storybook@8.3.5))(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)(rollup@4.24.0)(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)
version: 8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(@storybook/test@8.3.5(storybook@8.3.5))(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)(rollup@4.24.0)(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)
'@storybook/test':
specifier: 8.3.5
version: 8.3.5(storybook@8.3.5)
@@ -345,7 +345,7 @@ importers:
version: 8.3.5
tsup:
specifier: 8.3.0
version: 8.3.0(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101)(jiti@2.3.3)(postcss@8.4.47)(tsx@4.16.5)(typescript@5.4.5)(yaml@2.5.1)
version: 8.3.0(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101(@swc/helpers@0.5.13))(jiti@2.3.3)(postcss@8.4.47)(tsx@4.16.5)(typescript@5.4.5)(yaml@2.5.1)
vite:
specifier: 5.4.8
version: 5.4.8(@types/node@22.3.0)(terser@5.31.6)
@@ -423,7 +423,7 @@ importers:
version: 8.20.5(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)
'@vercel/functions':
specifier: 1.5.0
version: 1.5.0(@aws-sdk/credential-provider-web-identity@3.621.0)
version: 1.5.0(@aws-sdk/credential-provider-web-identity@3.621.0(@aws-sdk/client-sts@3.631.0(aws-crt@1.21.3)))
'@vercel/og':
specifier: 0.6.3
version: 0.6.3
@@ -604,7 +604,7 @@ importers:
version: 8.0.0(eslint@8.57.0)(typescript@5.4.5)
'@vercel/style-guide':
specifier: 6.0.0
version: 6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.4.5)(vitest@2.0.5)
version: 6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.4.5)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6))
eslint-config-next:
specifier: 14.2.5
version: 14.2.5(eslint@8.57.0)(typescript@5.4.5)
@@ -675,9 +675,6 @@ importers:
'@formbricks/eslint-config':
specifier: workspace:*
version: link:../config-eslint
'@formbricks/types':
specifier: workspace:*
version: link:../types
'@paralleldrive/cuid2':
specifier: 2.2.2
version: 2.2.2
@@ -692,7 +689,7 @@ importers:
version: 3.1.1(prisma@5.20.0)(typescript@5.4.5)
ts-node:
specifier: 10.9.2
version: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.4.5)
version: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)
zod:
specifier: 3.23.8
version: 3.23.8
@@ -844,7 +841,7 @@ importers:
version: 16.4.5
ts-node:
specifier: 10.9.2
version: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.4.5)
version: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)
vitest:
specifier: 2.0.5
version: 2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6)
@@ -939,7 +936,7 @@ importers:
version: 14.2.3
tailwindcss:
specifier: 3.4.10
version: 3.4.10(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.4.5))
version: 3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
terser:
specifier: 5.31.6
version: 5.31.6
@@ -962,6 +959,9 @@ importers:
'@formbricks/config-typescript':
specifier: workspace:*
version: link:../config-typescript
'@formbricks/database':
specifier: workspace:*
version: link:../database
packages/ui:
dependencies:
@@ -1045,10 +1045,10 @@ importers:
version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)
'@tailwindcss/forms':
specifier: 0.5.9
version: 0.5.9(tailwindcss@3.4.13(ts-node@10.9.2))
version: 0.5.9(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))
'@tailwindcss/typography':
specifier: 0.5.13
version: 0.5.13(tailwindcss@3.4.13(ts-node@10.9.2))
version: 0.5.13(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))
autoprefixer:
specifier: 10.4.20
version: 10.4.20(postcss@8.4.41)
@@ -1096,7 +1096,7 @@ importers:
version: 2.5.2
tailwindcss:
specifier: 3.4.13
version: 3.4.13(ts-node@10.9.2)
version: 3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -15980,9 +15980,9 @@ snapshots:
react: 19.0.0-rc-ed15d500-20241110
react-dom: 19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110)
'@headlessui/tailwindcss@0.2.1(tailwindcss@3.4.13(ts-node@10.9.2(typescript@5.4.5)))':
'@headlessui/tailwindcss@0.2.1(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))':
dependencies:
tailwindcss: 3.4.13(ts-node@10.9.2)
tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
'@hookform/resolvers@3.9.0(react-hook-form@7.53.0(react@19.0.0-rc-ed15d500-20241110))':
dependencies:
@@ -16362,11 +16362,11 @@ snapshots:
refractor: 3.6.0
unist-util-visit: 2.0.3
'@mdx-js/loader@3.0.1(webpack@5.95.0)':
'@mdx-js/loader@3.0.1(webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13)))':
dependencies:
'@mdx-js/mdx': 3.0.1
source-map: 0.7.4
webpack: 5.95.0
webpack: 5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13))
transitivePeerDependencies:
- supports-color
@@ -16460,11 +16460,11 @@ snapshots:
dependencies:
glob: 10.3.10
'@next/mdx@15.0.3(@mdx-js/loader@3.0.1(webpack@5.95.0))(@mdx-js/react@3.0.1(@types/react@18.3.11)(react@19.0.0-rc-ed15d500-20241110))':
'@next/mdx@15.0.3(@mdx-js/loader@3.0.1(webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13))))(@mdx-js/react@3.0.1(@types/react@18.3.11)(react@19.0.0-rc-ed15d500-20241110))':
dependencies:
source-map: 0.7.4
optionalDependencies:
'@mdx-js/loader': 3.0.1(webpack@5.95.0)
'@mdx-js/loader': 3.0.1(webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13)))
'@mdx-js/react': 3.0.1(@types/react@18.3.11)(react@19.0.0-rc-ed15d500-20241110)
'@next/swc-darwin-arm64@15.0.3':
@@ -19255,7 +19255,7 @@ snapshots:
react: 19.0.0-rc-ed15d500-20241110
react-dom: 19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110)
'@storybook/builder-vite@8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)':
'@storybook/builder-vite@8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)':
dependencies:
'@storybook/csf-plugin': 8.3.5(storybook@8.3.5)(webpack-sources@3.2.3)
'@types/find-cache-dir': 3.2.1
@@ -19353,11 +19353,11 @@ snapshots:
react-dom: 19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110)
storybook: 8.3.5
'@storybook/react-vite@8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(@storybook/test@8.3.5(storybook@8.3.5))(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)(rollup@4.24.0)(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)':
'@storybook/react-vite@8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(@storybook/test@8.3.5(storybook@8.3.5))(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)(rollup@4.24.0)(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))
'@rollup/pluginutils': 5.1.2(rollup@4.24.0)
'@storybook/builder-vite': 8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)
'@storybook/builder-vite': 8.3.5(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.3.5)(typescript@5.4.5)(vite@5.4.8(@types/node@22.3.0)(terser@5.31.6))(webpack-sources@3.2.3)
'@storybook/react': 8.3.5(@storybook/test@8.3.5(storybook@8.3.5))(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)(storybook@8.3.5)(typescript@5.4.5)
find-up: 5.0.0
magic-string: 0.30.11
@@ -19455,7 +19455,7 @@ snapshots:
'@swc/core-win32-x64-msvc@1.3.101':
optional: true
'@swc/core@1.3.101':
'@swc/core@1.3.101(@swc/helpers@0.5.13)':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.12
@@ -19470,6 +19470,7 @@ snapshots:
'@swc/core-win32-arm64-msvc': 1.3.101
'@swc/core-win32-ia32-msvc': 1.3.101
'@swc/core-win32-x64-msvc': 1.3.101
'@swc/helpers': 0.5.13
optional: true
'@swc/counter@0.1.3': {}
@@ -19496,26 +19497,26 @@ snapshots:
optionalDependencies:
typescript: 5.4.5
'@tailwindcss/forms@0.5.9(tailwindcss@3.4.13(ts-node@10.9.2))':
'@tailwindcss/forms@0.5.9(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))':
dependencies:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.4.13(ts-node@10.9.2)
tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
'@tailwindcss/typography@0.5.13(tailwindcss@3.4.13(ts-node@10.9.2))':
'@tailwindcss/typography@0.5.13(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))':
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.13(ts-node@10.9.2)
tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.13(ts-node@10.9.2(typescript@5.4.5)))':
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)))':
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.13(ts-node@10.9.2)
tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
'@tanstack/react-table@8.20.5(react-dom@19.0.0-rc-ed15d500-20241110(react@19.0.0-rc-ed15d500-20241110))(react@19.0.0-rc-ed15d500-20241110)':
dependencies:
@@ -20192,7 +20193,7 @@ snapshots:
graphql: 15.8.0
wonka: 4.0.15
'@vercel/functions@1.5.0(@aws-sdk/credential-provider-web-identity@3.621.0)':
'@vercel/functions@1.5.0(@aws-sdk/credential-provider-web-identity@3.621.0(@aws-sdk/client-sts@3.631.0(aws-crt@1.21.3)))':
optionalDependencies:
'@aws-sdk/credential-provider-web-identity': 3.621.0(@aws-sdk/client-sts@3.631.0(aws-crt@1.21.3))
@@ -20219,7 +20220,7 @@ snapshots:
svelte: 4.2.19
vue: 3.5.11(typescript@5.4.5)
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.4.5)(vitest@2.0.5)':
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.4.5)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6))':
dependencies:
'@babel/core': 7.25.2
'@babel/eslint-parser': 7.25.8(@babel/core@7.25.2)(eslint@8.57.0)
@@ -20239,7 +20240,7 @@ snapshots:
eslint-plugin-testing-library: 6.3.0(eslint@8.57.0)(typescript@5.4.5)
eslint-plugin-tsdoc: 0.2.17
eslint-plugin-unicorn: 51.0.1(eslint@8.57.0)
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.0.5)
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6))
prettier-plugin-packagejson: 2.5.3(prettier@3.3.3)
optionalDependencies:
'@next/eslint-plugin-next': 14.2.5
@@ -22262,7 +22263,7 @@ snapshots:
debug: 4.3.7
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.8.1
is-bun-module: 1.2.1
@@ -22305,6 +22306,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.31.0)(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
@@ -22528,13 +22539,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.0.5):
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6)):
dependencies:
'@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
vitest: 2.0.5
vitest: 2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6)
transitivePeerDependencies:
- supports-color
- typescript
@@ -26089,13 +26100,13 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.4.41
postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2):
postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)):
dependencies:
lilconfig: 3.1.2
yaml: 2.5.1
optionalDependencies:
postcss: 8.4.41
ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.4.5)
ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)
postcss-load-config@6.0.1(jiti@2.3.3)(postcss@8.4.47)(tsx@4.16.5)(yaml@2.5.1):
dependencies:
@@ -27781,7 +27792,7 @@ snapshots:
tailwind-merge@2.5.2: {}
tailwindcss@3.4.10(ts-node@10.9.2(@types/node@22.3.0)(typescript@5.4.5)):
tailwindcss@3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@@ -27800,7 +27811,7 @@ snapshots:
postcss: 8.4.41
postcss-import: 15.1.0(postcss@8.4.41)
postcss-js: 4.0.1(postcss@8.4.41)
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2)
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
postcss-nested: 6.2.0(postcss@8.4.41)
postcss-selector-parser: 6.1.2
resolve: 1.22.8
@@ -27808,7 +27819,7 @@ snapshots:
transitivePeerDependencies:
- ts-node
tailwindcss@3.4.13(ts-node@10.9.2):
tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5)):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@@ -27827,7 +27838,7 @@ snapshots:
postcss: 8.4.41
postcss-import: 15.1.0(postcss@8.4.41)
postcss-js: 4.0.1(postcss@8.4.41)
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2)
postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5))
postcss-nested: 6.2.0(postcss@8.4.41)
postcss-selector-parser: 6.1.2
resolve: 1.22.8
@@ -27903,6 +27914,17 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.13))(webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13))):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 3.3.0
serialize-javascript: 6.0.2
terser: 5.31.6
webpack: 5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13))
optionalDependencies:
'@swc/core': 1.3.101(@swc/helpers@0.5.13)
terser-webpack-plugin@5.3.10(webpack@5.95.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
@@ -28042,7 +28064,7 @@ snapshots:
'@ts-morph/common': 0.12.3
code-block-writer: 11.0.3
ts-node@10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.4.5):
ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.13))(@types/node@22.3.0)(typescript@5.4.5):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
@@ -28060,7 +28082,7 @@ snapshots:
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.3.101
'@swc/core': 1.3.101(@swc/helpers@0.5.13)
ts-pattern@4.3.0: {}
@@ -28085,7 +28107,7 @@ snapshots:
tslib@2.7.0: {}
tsup@8.3.0(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101)(jiti@2.3.3)(postcss@8.4.47)(tsx@4.16.5)(typescript@5.4.5)(yaml@2.5.1):
tsup@8.3.0(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101(@swc/helpers@0.5.13))(jiti@2.3.3)(postcss@8.4.47)(tsx@4.16.5)(typescript@5.4.5)(yaml@2.5.1):
dependencies:
bundle-require: 5.0.0(esbuild@0.23.1)
cac: 6.7.14
@@ -28105,7 +28127,7 @@ snapshots:
tree-kill: 1.2.2
optionalDependencies:
'@microsoft/api-extractor': 7.43.0(@types/node@22.3.0)
'@swc/core': 1.3.101
'@swc/core': 1.3.101(@swc/helpers@0.5.13)
postcss: 8.4.47
typescript: 5.4.5
transitivePeerDependencies:
@@ -28480,25 +28502,6 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.2
vite-node@2.0.5:
dependencies:
cac: 6.7.14
debug: 4.3.7
pathe: 1.1.2
tinyrainbow: 1.2.0
vite: 5.4.8(@types/node@22.3.0)(terser@5.31.3)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
optional: true
vite-node@2.0.5(@types/node@22.3.0)(terser@5.31.6):
dependencies:
cac: 6.7.14
@@ -28596,38 +28599,6 @@ snapshots:
typescript: 5.4.5
vitest: 2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6)
vitest@2.0.5:
dependencies:
'@ampproject/remapping': 2.3.0
'@vitest/expect': 2.0.5
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.0.5
'@vitest/snapshot': 2.0.5
'@vitest/spy': 2.0.5
'@vitest/utils': 2.0.5
chai: 5.1.1
debug: 4.3.7
execa: 8.0.1
magic-string: 0.30.11
pathe: 1.1.2
std-env: 3.7.0
tinybench: 2.9.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.4.8(@types/node@22.3.0)(terser@5.31.3)
vite-node: 2.0.5
why-is-node-running: 2.3.0
transitivePeerDependencies:
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
optional: true
vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.3)(terser@5.31.6):
dependencies:
'@ampproject/remapping': 2.3.0
@@ -28757,6 +28728,36 @@ snapshots:
- esbuild
- uglify-js
webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13)):
dependencies:
'@types/estree': 1.0.6
'@webassemblyjs/ast': 1.12.1
'@webassemblyjs/wasm-edit': 1.12.1
'@webassemblyjs/wasm-parser': 1.12.1
acorn: 8.12.1
acorn-import-attributes: 1.9.5(acorn@8.12.1)
browserslist: 4.24.0
chrome-trace-event: 1.0.4
enhanced-resolve: 5.17.1
es-module-lexer: 1.5.4
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.0
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.2.1
terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.13))(webpack@5.95.0(@swc/core@1.3.101(@swc/helpers@0.5.13)))
watchpack: 2.4.2
webpack-sources: 3.2.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3