diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts index 26032f64bf..2a905fc8f5 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts @@ -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 } 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 => { + 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); }); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx index 7599fe138b..e11af4d30e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditEndingCard.tsx @@ -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 = ({ )} + + { + 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")} + /> ); }; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx index 12df9a8610..93d4562594 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx @@ -184,7 +184,6 @@ export const EditorCardMenu = ({ deleteCard(cardIdx); }} /> - @@ -286,7 +285,6 @@ export const EditorCardMenu = ({ - !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, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsStylingSettingsTabs.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsStylingSettingsTabs.tsx index b579b966a7..1e5cd6f81a 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsStylingSettingsTabs.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsStylingSettingsTabs.tsx @@ -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: , - }, - { - id: "styling", - label: "common.styling", - icon: , - }, - { - id: "settings", - label: "common.settings", - icon: , - }, -]; - interface QuestionsAudienceTabsProps { activeId: TSurveyEditorTabs; setActiveId: React.Dispatch>; 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: , + }, + { + id: "styling", + label: "common.styling", + icon: , + }, + { + id: "settings", + label: "common.settings", + icon: , + }, + { + id: "followUps", + label: "environments.surveys.edit.follow_ups", + icon: , + 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 &&
{tab.icon}
} {t(tab.label)} + {tab.isPro && } ))} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index 187fb42e93..1c34290021 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -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")); }; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx index 59edeeae25..f68fb28a8a 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx @@ -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("questions"); const [activeQuestionId, setActiveQuestionId] = useState(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" && ( + + )}