mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-06 06:39:36 -06:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
@@ -39,4 +39,5 @@ export const getMinimalSurvey = (locale: string): TSurvey => ({
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
87
apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts
Normal file
87
apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -281,6 +281,7 @@ export const LinkSurvey = ({
|
||||
},
|
||||
ttc: responseUpdate.ttc,
|
||||
finished: responseUpdate.finished,
|
||||
endingId: responseUpdate.endingId,
|
||||
language:
|
||||
responseUpdate.language === "default" && defaultLanguageCode
|
||||
? defaultLanguageCode
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
46
apps/web/modules/email/emails/survey/follow-up.tsx
Normal file
46
apps/web/modules/email/emails/survey/follow-up.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
57
packages/database/types/survey-follow-up.ts
Normal file
57
packages/database/types/survey-follow-up.ts
Normal 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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -138,5 +138,6 @@ export const getPreviewSurvey = (locale: string) => {
|
||||
languages: [],
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
} as TSurvey;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)}{" "}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
9
packages/ui/components/ProBadge/index.tsx
Normal file
9
packages/ui/components/ProBadge/index.tsx
Normal 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
203
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user