mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-27 08:50:38 -06:00
Compare commits
9 Commits
action-env
...
v2.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8ab4aaf2e | ||
|
|
78dca7a2bf | ||
|
|
844ea40c3a | ||
|
|
7a6dedf452 | ||
|
|
b641b37308 | ||
|
|
8c1f8bfb42 | ||
|
|
1f1563401d | ||
|
|
9fd585ee07 | ||
|
|
609dcabf77 |
@@ -41,8 +41,8 @@ export const CardStylingSettings = ({
|
||||
const surveyTypeDerived = isAppSurvey ? "App" : "Link";
|
||||
const isLogoVisible = !!product.logo?.url;
|
||||
|
||||
const linkCardArrangement = form.watch("cardArrangement.linkSurveys") ?? "simple";
|
||||
const appCardArrangement = form.watch("cardArrangement.appSurveys") ?? "simple";
|
||||
const linkCardArrangement = form.watch("cardArrangement.linkSurveys") ?? "straight";
|
||||
const appCardArrangement = form.watch("cardArrangement.appSurveys") ?? "straight";
|
||||
const roundness = form.watch("roundness") ?? 8;
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
@@ -244,7 +244,13 @@ export function LogicEditorConditions({
|
||||
const conditionOperatorOptions = getConditionOperatorOptions(condition, localSurvey);
|
||||
const { show, options, showInput = false, inputType } = getMatchValueProps(condition, localSurvey, t);
|
||||
|
||||
const allowMultiSelect = ["equalsOneOf", "includesAllOf", "includesOneOf"].includes(condition.operator);
|
||||
const allowMultiSelect = [
|
||||
"equalsOneOf",
|
||||
"includesAllOf",
|
||||
"includesOneOf",
|
||||
"doesNotIncludeOneOf",
|
||||
"doesNotIncludeAllOf",
|
||||
].includes(condition.operator);
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-x-2">
|
||||
<div className="w-10 shrink-0">
|
||||
|
||||
@@ -2,12 +2,10 @@ import { RotateCcwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
import { UseFormReturn, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct, TProductStyling } from "@formbricks/types/product";
|
||||
import { TBaseStyling } from "@formbricks/types/styling";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { AlertDialog } from "@formbricks/ui/components/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
@@ -53,50 +51,8 @@ export const StylingView = ({
|
||||
}: StylingViewProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
const stylingDefaults: TBaseStyling = useMemo(() => {
|
||||
let stylingDefaults: TBaseStyling;
|
||||
const isOverwriteEnabled = localSurvey.styling?.overwriteThemeStyling ?? false;
|
||||
|
||||
if (isOverwriteEnabled) {
|
||||
const { overwriteThemeStyling, ...baseSurveyStyles } = localSurvey.styling ?? {};
|
||||
stylingDefaults = baseSurveyStyles;
|
||||
} else {
|
||||
const { allowStyleOverwrite, ...baseProductStyles } = product.styling ?? {};
|
||||
stylingDefaults = baseProductStyles;
|
||||
}
|
||||
|
||||
return {
|
||||
brandColor: { light: stylingDefaults.brandColor?.light ?? COLOR_DEFAULTS.brandColor },
|
||||
questionColor: { light: stylingDefaults.questionColor?.light ?? COLOR_DEFAULTS.questionColor },
|
||||
inputColor: { light: stylingDefaults.inputColor?.light ?? COLOR_DEFAULTS.inputColor },
|
||||
inputBorderColor: { light: stylingDefaults.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor },
|
||||
cardBackgroundColor: {
|
||||
light: stylingDefaults.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
|
||||
},
|
||||
cardBorderColor: { light: stylingDefaults.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor },
|
||||
cardShadowColor: { light: stylingDefaults.cardShadowColor?.light ?? COLOR_DEFAULTS.cardShadowColor },
|
||||
highlightBorderColor: stylingDefaults.highlightBorderColor?.light
|
||||
? {
|
||||
light: stylingDefaults.highlightBorderColor.light,
|
||||
}
|
||||
: undefined,
|
||||
isDarkModeEnabled: stylingDefaults.isDarkModeEnabled ?? false,
|
||||
roundness: stylingDefaults.roundness ?? 8,
|
||||
cardArrangement: stylingDefaults.cardArrangement ?? {
|
||||
linkSurveys: "simple",
|
||||
appSurveys: "simple",
|
||||
},
|
||||
background: stylingDefaults.background,
|
||||
hideProgressBar: stylingDefaults.hideProgressBar ?? false,
|
||||
isLogoHidden: stylingDefaults.isLogoHidden ?? false,
|
||||
};
|
||||
}, [localSurvey.styling, product.styling]);
|
||||
|
||||
const form = useForm<TSurveyStyling>({
|
||||
defaultValues: {
|
||||
...localSurvey.styling,
|
||||
...stylingDefaults,
|
||||
},
|
||||
defaultValues: localSurvey.styling ?? product.styling,
|
||||
});
|
||||
|
||||
const overwriteThemeStyling = form.watch("overwriteThemeStyling");
|
||||
@@ -133,20 +89,17 @@ export const StylingView = ({
|
||||
}
|
||||
}, [overwriteThemeStyling]);
|
||||
|
||||
const watchedValues = useWatch({
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error
|
||||
setLocalSurvey((prev) => ({
|
||||
...prev,
|
||||
styling: {
|
||||
...prev.styling,
|
||||
...watchedValues,
|
||||
},
|
||||
}));
|
||||
}, [watchedValues, setLocalSurvey]);
|
||||
form.watch((data: TSurveyStyling) => {
|
||||
setLocalSurvey((prev) => ({
|
||||
...prev,
|
||||
styling: {
|
||||
...prev.styling,
|
||||
...data,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, [setLocalSurvey]);
|
||||
|
||||
const defaultProductStyling = useMemo(() => {
|
||||
const { styling: productStyling } = product;
|
||||
|
||||
@@ -116,6 +116,14 @@ export const logicRules = {
|
||||
label: "environments.surveys.edit.does_not_equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "environments.surveys.edit.does_not_include_one_of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotIncludeOneOf,
|
||||
},
|
||||
{
|
||||
label: "environments.surveys.edit.does_not_include_all_of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotIncludeAllOf,
|
||||
},
|
||||
{
|
||||
label: "environments.surveys.edit.includes_all_of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
@@ -144,6 +152,14 @@ export const logicRules = {
|
||||
label: "environments.surveys.edit.does_not_equal",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEqual,
|
||||
},
|
||||
{
|
||||
label: "environments.surveys.edit.does_not_include_one_of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotIncludeOneOf,
|
||||
},
|
||||
{
|
||||
label: "environments.surveys.edit.does_not_include_all_of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotIncludeAllOf,
|
||||
},
|
||||
{
|
||||
label: "environments.surveys.edit.includes_all_of",
|
||||
value: ZSurveyLogicConditionsOperator.Enum.includesAllOf,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { createActionClassAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { LoadingSpinner } from "@formbricks/ui/components/LoadingSpinner";
|
||||
@@ -15,15 +19,25 @@ import { getActiveInactiveSurveysAction } from "../actions";
|
||||
interface ActivityTabProps {
|
||||
actionClass: TActionClass;
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
otherEnvironment: TEnvironment;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabProps) => {
|
||||
export const ActionActivityTab = ({
|
||||
actionClass,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
environmentId,
|
||||
environment,
|
||||
isReadOnly,
|
||||
}: ActivityTabProps) => {
|
||||
const t = useTranslations();
|
||||
const [activeSurveys, setActiveSurveys] = useState<string[] | undefined>();
|
||||
const [inactiveSurveys, setInactiveSurveys] = useState<string[] | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -45,6 +59,57 @@ export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabPro
|
||||
updateState();
|
||||
}, [actionClass.id, environmentId]);
|
||||
|
||||
const actionClassNames = useMemo(
|
||||
() => otherEnvActionClasses.map((actionClass) => actionClass.name),
|
||||
[otherEnvActionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useMemo(() => {
|
||||
const codeActionClasses: TActionClassInputCode[] = otherEnvActionClasses.filter(
|
||||
(actionClass) => actionClass.type === "code"
|
||||
) as TActionClassInputCode[];
|
||||
|
||||
return codeActionClasses.map((actionClass) => actionClass.key);
|
||||
}, [otherEnvActionClasses]);
|
||||
|
||||
const copyAction = async (data: TActionClassInput) => {
|
||||
const { type } = data;
|
||||
let copyName = data.name;
|
||||
try {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
|
||||
if (copyName && actionClassNames.includes(copyName)) {
|
||||
while (actionClassNames.includes(copyName)) {
|
||||
copyName += " (copy)";
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "code" && data.key && actionClassKeys.includes(data.key)) {
|
||||
throw new Error(t("environments.actions.action_with_key_already_exists", { key: data.key }));
|
||||
}
|
||||
|
||||
let updatedAction = {
|
||||
...data,
|
||||
name: copyName.trim(),
|
||||
environmentId: otherEnvironment.id,
|
||||
};
|
||||
|
||||
const createActionClassResponse = await createActionClassAction({
|
||||
action: updatedAction as TActionClassInput,
|
||||
});
|
||||
|
||||
if (!createActionClassResponse?.data) {
|
||||
throw new Error(t("environments.actions.action_copy_failed", {}));
|
||||
}
|
||||
|
||||
toast.success(t("environments.actions.action_copied_successfully"));
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <ErrorComponent />;
|
||||
|
||||
@@ -98,6 +163,22 @@ export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabPro
|
||||
<p className="text-sm text-slate-700">{capitalizeFirstLetter(actionClass.type)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<Label className="text-xs font-normal text-slate-500">Environment</Label>
|
||||
<div className="items-center-center flex gap-2">
|
||||
<p className="text-xs text-slate-700">
|
||||
{environment.type === "development" ? "Development" : "Production"}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
copyAction(actionClass);
|
||||
}}
|
||||
className="m-0 p-0 text-xs font-medium text-black underline underline-offset-4 focus:ring-0 focus:ring-offset-0"
|
||||
variant="minimal">
|
||||
{environment.type === "development" ? "Copy to Production" : "Copy to Development"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,20 +2,27 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionDetailModal } from "./ActionDetailModal";
|
||||
|
||||
interface ActionClassesTableProps {
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
environment: TEnvironment;
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
isReadOnly: boolean;
|
||||
otherEnvironment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export const ActionClassesTable = ({
|
||||
environmentId,
|
||||
actionClasses,
|
||||
environment,
|
||||
children: [TableHeading, actionRows],
|
||||
isReadOnly,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionClassesTableProps) => {
|
||||
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||
|
||||
@@ -48,11 +55,14 @@ export const ActionClassesTable = ({
|
||||
{activeActionClass && (
|
||||
<ActionDetailModal
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
open={isActionDetailModalOpen}
|
||||
setOpen={setActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
isReadOnly={isReadOnly}
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
otherEnvironment={otherEnvironment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
interface ActionDetailModalProps {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
isReadOnly: boolean;
|
||||
otherEnvironment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export const ActionDetailModal = ({
|
||||
@@ -20,13 +24,25 @@ export const ActionDetailModal = ({
|
||||
setOpen,
|
||||
actionClass,
|
||||
actionClasses,
|
||||
environment,
|
||||
isReadOnly,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionDetailModalProps) => {
|
||||
const t = useTranslations();
|
||||
const tabs = [
|
||||
{
|
||||
title: t("common.activity"),
|
||||
children: <ActionActivityTab actionClass={actionClass} environmentId={environmentId} />,
|
||||
children: (
|
||||
<ActionActivityTab
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isReadOnly={isReadOnly}
|
||||
environment={environment}
|
||||
actionClass={actionClass}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("common.settings"),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
@@ -44,6 +45,17 @@ const Page = async ({ params }) => {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const currentEnvironment = environments.find((env) => env.id === params.environmentId);
|
||||
|
||||
if (!currentEnvironment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
|
||||
|
||||
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
@@ -69,6 +81,9 @@ const Page = async ({ params }) => {
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
||||
<ActionClassesTable
|
||||
environment={currentEnvironment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}>
|
||||
|
||||
@@ -72,8 +72,8 @@ export const ThemeStyling = ({
|
||||
isDarkModeEnabled: product.styling.isDarkModeEnabled ?? false,
|
||||
roundness: product.styling.roundness ?? 8,
|
||||
cardArrangement: product.styling.cardArrangement ?? {
|
||||
linkSurveys: "simple",
|
||||
appSurveys: "simple",
|
||||
linkSurveys: "straight",
|
||||
appSurveys: "straight",
|
||||
},
|
||||
background: product.styling.background,
|
||||
hideProgressBar: product.styling.hideProgressBar ?? false,
|
||||
@@ -119,8 +119,8 @@ export const ThemeStyling = ({
|
||||
},
|
||||
roundness: 8,
|
||||
cardArrangement: {
|
||||
linkSurveys: "simple",
|
||||
appSurveys: "simple",
|
||||
linkSurveys: "straight",
|
||||
appSurveys: "straight",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ const Loading = () => {
|
||||
<ProductConfigNavigation activeId="look" loading />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title="environments.product.look.theme"
|
||||
title={t("environments.product.look.theme")}
|
||||
className="max-w-7xl"
|
||||
description="environments.product.look.theme_settings_description">
|
||||
description={t("environments.product.look.theme_settings_description")}>
|
||||
<div className="flex animate-pulse">
|
||||
<div className="w-1/2">
|
||||
<div className="flex flex-col gap-4 pr-6">
|
||||
|
||||
@@ -55,7 +55,7 @@ export const IndividualInviteTab = ({
|
||||
const submitEventClass = async () => {
|
||||
const data = getValues();
|
||||
data.role = data.role || OrganizationRole.owner;
|
||||
await onSubmit([data]);
|
||||
onSubmit([data]);
|
||||
setOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
differenceInDays,
|
||||
endOfMonth,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
subQuarters,
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IsPasswordValid } from "@/modules/auth/components/SignupOptions/components/IsPasswordValid";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -8,7 +9,6 @@ import { toast } from "react-hot-toast";
|
||||
import { resetPassword } from "@formbricks/lib/utils/users";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { PasswordInput } from "@formbricks/ui/components/PasswordInput";
|
||||
import { IsPasswordValid } from "@formbricks/ui/components/SignupOptions/components/IsPasswordValid";
|
||||
|
||||
export const ResetPasswordForm = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormWrapper } from "@/app/(auth)/auth/components/FormWrapper";
|
||||
import { Testimonial } from "@/app/(auth)/auth/components/Testimonial";
|
||||
import { SigninForm } from "@/app/(auth)/auth/login/components/SigninForm";
|
||||
import { SigninForm } from "@/modules/auth/components/SigninForm";
|
||||
import { Metadata } from "next";
|
||||
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { SignupOptions } from "@/modules/auth/components/SignupOptions";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { SignupOptions } from "@formbricks/ui/components/SignupOptions";
|
||||
|
||||
interface SignupFormProps {
|
||||
webAppUrl: string;
|
||||
|
||||
@@ -2,12 +2,13 @@ import { FormWrapper } from "@/app/(auth)/auth/components/FormWrapper";
|
||||
import { RequestVerificationEmail } from "@/app/(auth)/auth/verification-requested/components/RequestVerificationEmail";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { z } from "zod";
|
||||
import { getEmailFromEmailToken } from "@formbricks/lib/jwt";
|
||||
|
||||
const VerificationPageSchema = z.string().email();
|
||||
|
||||
const Page = async ({ searchParams }) => {
|
||||
const t = await getTranslations();
|
||||
const email = searchParams.email;
|
||||
const email = getEmailFromEmailToken(searchParams.token);
|
||||
try {
|
||||
const parsedEmail = VerificationPageSchema.parse(email).toLowerCase();
|
||||
return (
|
||||
|
||||
@@ -11,11 +11,10 @@ export const POST = async (request: Request) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
return Response.json({ error: "No user with this email found" }, { status: 409 });
|
||||
if (foundUser) {
|
||||
await sendForgotPasswordEmail(foundUser, foundUser.locale);
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(foundUser, foundUser.locale);
|
||||
return Response.json({});
|
||||
} catch (e) {
|
||||
return Response.json(
|
||||
|
||||
@@ -9,7 +9,7 @@ export const POST = async (request: Request) => {
|
||||
const { id } = await verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { rateLimit } from "@/app/middleware/rateLimit";
|
||||
import {
|
||||
CLIENT_SIDE_API_RATE_LIMIT,
|
||||
FORGET_PASSWORD_RATE_LIMIT,
|
||||
LOGIN_RATE_LIMIT,
|
||||
RESET_PASSWORD_RATE_LIMIT,
|
||||
SHARE_RATE_LIMIT,
|
||||
SIGNUP_RATE_LIMIT,
|
||||
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
|
||||
VERIFY_EMAIL_RATE_LIMIT,
|
||||
} from "@formbricks/lib/constants";
|
||||
|
||||
export const signUpLimiter = rateLimit({
|
||||
interval: SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const forgetPasswordLimiter = rateLimit({
|
||||
interval: FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const resetPasswordLimiter = rateLimit({
|
||||
interval: RESET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: RESET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const verifyEmailLimiter = rateLimit({
|
||||
interval: VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const loginLimiter = rateLimit({
|
||||
interval: LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
|
||||
@@ -2,6 +2,12 @@ export const loginRoute = (url: string) => url === "/api/auth/callback/credentia
|
||||
|
||||
export const signupRoute = (url: string) => url === "/api/v1/users";
|
||||
|
||||
export const resetPasswordRoute = (url: string) => url === "/api/v1/users/reset-password";
|
||||
|
||||
export const forgetPasswordRoute = (url: string) => url === "/api/v1/users/forgot-password";
|
||||
|
||||
export const verifyEmailRoute = (url: string) => url === "/api/v1/users/verification-email";
|
||||
|
||||
export const clientSideApiRoute = (url: string): boolean => {
|
||||
if (url.includes("/api/packages/")) return true;
|
||||
if (url.includes("/api/v1/js/actions")) return true;
|
||||
|
||||
@@ -255,12 +255,16 @@ export const LinkSurvey = ({
|
||||
apiHost: webAppUrl,
|
||||
environmentId: survey.environmentId,
|
||||
});
|
||||
|
||||
const res = await api.client.display.create({
|
||||
surveyId: survey.id,
|
||||
...(userId && { userId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(t("s.could_not_create_display"));
|
||||
}
|
||||
|
||||
const { id } = res.data;
|
||||
|
||||
surveyState.updateDisplayId(id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SignupOptions } from "@/modules/auth/components/SignupOptions";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
OIDC_OAUTH_ENABLED,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { SignupOptions } from "@formbricks/ui/components/SignupOptions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign up",
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import {
|
||||
clientSideApiEndpointsLimiter,
|
||||
forgetPasswordLimiter,
|
||||
loginLimiter,
|
||||
resetPasswordLimiter,
|
||||
shareUrlLimiter,
|
||||
signUpLimiter,
|
||||
syncUserIdentificationLimiter,
|
||||
verifyEmailLimiter,
|
||||
} from "@/app/middleware/bucket";
|
||||
import {
|
||||
clientSideApiRoute,
|
||||
forgetPasswordRoute,
|
||||
isAuthProtectedRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
loginRoute,
|
||||
resetPasswordRoute,
|
||||
shareUrlRoute,
|
||||
signupRoute,
|
||||
verifyEmailRoute,
|
||||
} from "@/app/middleware/endpointValidator";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { NextResponse } from "next/server";
|
||||
@@ -50,6 +56,12 @@ export const middleware = async (request: NextRequest) => {
|
||||
await loginLimiter(`login-${ip}`);
|
||||
} else if (signupRoute(request.nextUrl.pathname)) {
|
||||
await signUpLimiter(`signup-${ip}`);
|
||||
} else if (forgetPasswordRoute(request.nextUrl.pathname)) {
|
||||
await forgetPasswordLimiter(`forget-password-${ip}`);
|
||||
} else if (verifyEmailRoute(request.nextUrl.pathname)) {
|
||||
await verifyEmailLimiter(`verify-email-${ip}`);
|
||||
} else if (resetPasswordRoute(request.nextUrl.pathname)) {
|
||||
await resetPasswordLimiter(`reset-password-${ip}`);
|
||||
} else if (clientSideApiRoute(request.nextUrl.pathname)) {
|
||||
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
|
||||
@@ -74,6 +86,9 @@ export const config = {
|
||||
matcher: [
|
||||
"/api/auth/callback/credentials",
|
||||
"/api/v1/users",
|
||||
"/api/v1/users/forgot-password",
|
||||
"/api/v1/users/verification-email",
|
||||
"/api/v1/users/reset-password",
|
||||
"/api/(.*)/client/:path*",
|
||||
"/api/v1/js/actions",
|
||||
"/api/v1/client/storage",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { TwoFactor } from "@/app/(auth)/auth/login/components/TwoFactor";
|
||||
import { TwoFactorBackup } from "@/app/(auth)/auth/login/components/TwoFactorBackup";
|
||||
import { TwoFactor } from "@/modules/auth/components/SigninForm/components/TwoFactor";
|
||||
import { TwoFactorBackup } from "@/modules/auth/components/SigninForm/components/TwoFactorBackup";
|
||||
import { createEmailTokenAction } from "@/modules/auth/components/SignupOptions/actions";
|
||||
import { AzureButton } from "@/modules/auth/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@/modules/auth/components/SignupOptions/components/GithubButton";
|
||||
import { GoogleButton } from "@/modules/auth/components/SignupOptions/components/GoogleButton";
|
||||
import { OpenIdButton } from "@/modules/auth/components/SignupOptions/components/OpenIdButton";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
@@ -16,10 +21,6 @@ import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { PasswordInput } from "@formbricks/ui/components/PasswordInput";
|
||||
import { AzureButton } from "@formbricks/ui/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@formbricks/ui/components/SignupOptions/components/GithubButton";
|
||||
import { GoogleButton } from "@formbricks/ui/components/SignupOptions/components/GoogleButton";
|
||||
import { OpenIdButton } from "@formbricks/ui/components/SignupOptions/components/OpenIdButton";
|
||||
|
||||
interface TSigninFormState {
|
||||
email: string;
|
||||
@@ -96,7 +97,12 @@ export const SigninForm = ({
|
||||
}
|
||||
|
||||
if (signInResponse?.error === "Email Verification is Pending") {
|
||||
router.push(`/auth/verification-requested?email=${data.email}`);
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
if (emailTokenActionResponse?.serverError) {
|
||||
setSignInError(emailTokenActionResponse.serverError);
|
||||
return;
|
||||
}
|
||||
router.push(`/auth/verification-requested?token=${emailTokenActionResponse?.data}`);
|
||||
return;
|
||||
}
|
||||
|
||||
21
apps/web/modules/auth/components/SignupOptions/actions.ts
Normal file
21
apps/web/modules/auth/components/SignupOptions/actions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { z } from "zod";
|
||||
import { createEmailToken } from "@formbricks/lib/jwt";
|
||||
import { getUserByEmail } from "@formbricks/lib/user/service";
|
||||
|
||||
const ZCreateEmailTokenAction = z.object({
|
||||
email: z.string().min(5).max(255).email({ message: "Invalid email" }),
|
||||
});
|
||||
|
||||
export const createEmailTokenAction = actionClient
|
||||
.schema(ZCreateEmailTokenAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
if (!user) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
return createEmailToken(parsedInput.email);
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { Button } from "../../Button";
|
||||
import { MicrosoftIcon } from "../../icons";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { MicrosoftIcon } from "@formbricks/ui/components/icons";
|
||||
|
||||
export const AzureButton = ({
|
||||
text = "Continue with Azure",
|
||||
@@ -3,8 +3,8 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { Button } from "../../Button";
|
||||
import { GithubIcon } from "../../icons";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { GithubIcon } from "@formbricks/ui/components/icons";
|
||||
|
||||
export const GithubButton = ({
|
||||
text = "Continue with Github",
|
||||
@@ -3,8 +3,8 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { Button } from "../../Button";
|
||||
import { GoogleIcon } from "../../icons";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { GoogleIcon } from "@formbricks/ui/components/icons";
|
||||
|
||||
export const GoogleButton = ({
|
||||
text = "Continue with Google",
|
||||
@@ -2,7 +2,7 @@ import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { Button } from "../../Button";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
|
||||
export const OpenIdButton = ({
|
||||
text = "Continue with OpenId Connect",
|
||||
@@ -1,22 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { createEmailTokenAction } from "@/modules/auth/components/SignupOptions/actions";
|
||||
import { AzureButton } from "@/modules/auth/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@/modules/auth/components/SignupOptions/components/GithubButton";
|
||||
import { GoogleButton } from "@/modules/auth/components/SignupOptions/components/GoogleButton";
|
||||
import { IsPasswordValid } from "@/modules/auth/components/SignupOptions/components/IsPasswordValid";
|
||||
import { OpenIdButton } from "@/modules/auth/components/SignupOptions/components/OpenIdButton";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { createUser } from "@formbricks/lib/utils/users";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { Button } from "../Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "../Form";
|
||||
import { Input } from "../Input";
|
||||
import { PasswordInput } from "../PasswordInput";
|
||||
import { AzureButton } from "./components/AzureButton";
|
||||
import { GithubButton } from "./components/GithubButton";
|
||||
import { GoogleButton } from "./components/GoogleButton";
|
||||
import { IsPasswordValid } from "./components/IsPasswordValid";
|
||||
import { OpenIdButton } from "./components/OpenIdButton";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
import { PasswordInput } from "@formbricks/ui/components/PasswordInput";
|
||||
|
||||
interface SignupOptionsProps {
|
||||
emailAuthEnabled: boolean;
|
||||
@@ -84,9 +86,15 @@ export const SignupOptions = ({
|
||||
|
||||
try {
|
||||
await createUser(data.name, data.email, data.password, userLocale, inviteToken || "");
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
if (emailTokenActionResponse?.serverError) {
|
||||
toast.error(emailTokenActionResponse.serverError);
|
||||
return;
|
||||
}
|
||||
const token = emailTokenActionResponse?.data;
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success`
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(data.email)}`;
|
||||
: `/auth/verification-requested?token=${token}`;
|
||||
|
||||
router.push(url);
|
||||
} catch (e: any) {
|
||||
@@ -124,6 +124,7 @@ export const getDocumentsByInsightIdSurveyIdQuestionId = reactCache(
|
||||
{
|
||||
tags: [
|
||||
documentCache.tag.byInsightIdSurveyIdQuestionId(insightId, surveyId, questionId),
|
||||
documentCache.tag.byInsightId(insightId),
|
||||
insightCache.tag.byId(insightId),
|
||||
],
|
||||
}
|
||||
@@ -159,17 +160,27 @@ export const getDocument = reactCache(
|
||||
)()
|
||||
);
|
||||
|
||||
export const updateDocument = async (documentId: string, data: Partial<TDocument>): Promise<TDocument> => {
|
||||
export const updateDocument = async (documentId: string, data: Partial<TDocument>): Promise<void> => {
|
||||
validateInputs([documentId, ZId], [data, ZDocument.partial()]);
|
||||
try {
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: { id: documentId },
|
||||
data,
|
||||
select: {
|
||||
environmentId: true,
|
||||
documentInsights: {
|
||||
select: {
|
||||
insightId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
documentCache.revalidate({ environmentId: updatedDocument.environmentId });
|
||||
|
||||
return updatedDocument;
|
||||
for (const { insightId } of updatedDocument.documentInsights) {
|
||||
documentCache.revalidate({ insightId });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -95,6 +95,16 @@ export const InsightView = ({
|
||||
setVisibleInsights((prevVisibleInsights) => Math.min(prevVisibleInsights + 10, insights.length));
|
||||
};
|
||||
|
||||
const updateLocalInsight = (insightId: string, updates: Partial<TInsight>) => {
|
||||
setLocalInsights((prevInsights) =>
|
||||
prevInsights.map((insight) => (insight.id === insightId ? { ...insight, ...updates } : insight))
|
||||
);
|
||||
};
|
||||
|
||||
const onCategoryChange = async (insightId: string, newCategory: TInsight["category"]) => {
|
||||
updateLocalInsight(insightId, { category: newCategory });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mt-2")}>
|
||||
<Tabs defaultValue="all" onValueChange={handleFilterSelect}>
|
||||
@@ -147,7 +157,11 @@ export const InsightView = ({
|
||||
{insight.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CategoryBadge category={insight.category} insightId={insight.id} />
|
||||
<CategoryBadge
|
||||
category={insight.category}
|
||||
insightId={insight.id}
|
||||
onCategoryChange={onCategoryChange}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -160,7 +174,7 @@ export const InsightView = ({
|
||||
{visibleInsights < localInsights.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
Load more
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TInsight } from "@formbricks/types/insights";
|
||||
@@ -7,6 +8,7 @@ import { updateInsightAction } from "../actions";
|
||||
interface CategoryBadgeProps {
|
||||
category: TInsight["category"];
|
||||
insightId: string;
|
||||
onCategoryChange?: (insightId: string, category: TInsight["category"]) => void;
|
||||
}
|
||||
|
||||
const categoryOptions: TBadgeSelectOption[] = [
|
||||
@@ -36,17 +38,18 @@ const getCategoryIndex = (category: TInsight["category"]) => {
|
||||
}
|
||||
};
|
||||
|
||||
const CategoryBadge = ({ category, insightId }: CategoryBadgeProps) => {
|
||||
const CategoryBadge = ({ category, insightId, onCategoryChange }: CategoryBadgeProps) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
const handleUpdateCategory = async (newCategory: TInsight["category"]) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateInsightAction({ insightId, data: { category: newCategory } });
|
||||
toast.success("Category updated successfully!");
|
||||
onCategoryChange?.(insightId, newCategory);
|
||||
toast.success(t("environments.experience.category_updated_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to update insight:", error);
|
||||
toast.error("Failed to update category.");
|
||||
console.error(t("environments.experience.failed_to_update_category"), error);
|
||||
toast.error(t("environments.experience.failed_to_update_category"));
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ export const Dashboard = ({
|
||||
value={statsPeriod}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
console.log("Stats period changed to:", value);
|
||||
setStatsPeriod(value as TStatsPeriod);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { INSIGHTS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -85,13 +86,36 @@ export const getInsights = reactCache(
|
||||
|
||||
export const updateInsight = async (insightId: string, updates: Partial<TInsight>): Promise<void> => {
|
||||
try {
|
||||
await prisma.insight.update({
|
||||
const updatedInsight = await prisma.insight.update({
|
||||
where: { id: insightId },
|
||||
data: updates,
|
||||
select: {
|
||||
environmentId: true,
|
||||
documentInsights: {
|
||||
select: {
|
||||
document: {
|
||||
select: {
|
||||
surveyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate the cache for the updated insight
|
||||
insightCache.revalidate({ id: insightId });
|
||||
const uniqueSurveyIds = Array.from(
|
||||
new Set(updatedInsight.documentInsights.map((di) => di.document.surveyId))
|
||||
);
|
||||
|
||||
insightCache.revalidate({ id: insightId, environmentId: updatedInsight.environmentId });
|
||||
|
||||
for (const surveyId of uniqueSurveyIds) {
|
||||
if (surveyId) {
|
||||
responseCache.revalidate({
|
||||
surveyId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in updateInsight:", error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -79,9 +79,7 @@ export const sendVerificationEmail = async (user: TEmailUser): Promise<void> =>
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?email=${encodeURIComponent(
|
||||
user.email
|
||||
)}`;
|
||||
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?token=${encodeURIComponent(token)}`;
|
||||
const html = await render(VerificationEmail({ verificationRequestLink, verifyLink, locale: user.locale }));
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
|
||||
@@ -153,7 +153,18 @@ export const SHARE_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 30,
|
||||
};
|
||||
|
||||
export const FORGET_PASSWORD_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 5, // Limit to 5 requests per hour
|
||||
};
|
||||
export const RESET_PASSWORD_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 5, // Limit to 5 requests per hour
|
||||
};
|
||||
export const VERIFY_EMAIL_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 10, // Limit to 10 requests per hour
|
||||
};
|
||||
export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
|
||||
interval: 60, // 1 minute
|
||||
allowedPerInterval: 5,
|
||||
|
||||
@@ -1,48 +1,86 @@
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "./crypto";
|
||||
import { env } from "./env";
|
||||
|
||||
export const createToken = (userId: string, userEmail: string, options = {}): string => {
|
||||
return jwt.sign({ id: userId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
return jwt.sign({ email: userEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return payload.email;
|
||||
}
|
||||
};
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||
return jwt.sign({ inviteId, email }, env.NEXTAUTH_SECRET, options);
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
|
||||
};
|
||||
|
||||
export const verifyTokenForLinkSurvey = (token: string, surveyId: string) => {
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.NEXTAUTH_SECRET + surveyId);
|
||||
return (payload as jwt.JwtPayload).email || null;
|
||||
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return email;
|
||||
}
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string, userEmail: string = ""): Promise<JwtPayload> => {
|
||||
if (!token) {
|
||||
throw new Error("No token found");
|
||||
}
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
// First decode to get the ID
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
|
||||
const { id } = payload;
|
||||
|
||||
if (!userEmail) {
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
userEmail = foundUser.email;
|
||||
if (!id) {
|
||||
throw new Error("Token missing required field: id");
|
||||
}
|
||||
|
||||
return jwt.verify(token, env.NEXTAUTH_SECRET + userEmail) as JwtPayload;
|
||||
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
|
||||
let decryptedId: string;
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = id;
|
||||
}
|
||||
|
||||
// If no email provided, look up the user
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: { id: decryptedId },
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const userEmail = foundUser.email;
|
||||
|
||||
return { id: decryptedId, email: userEmail };
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
@@ -52,9 +90,22 @@ export const verifyInviteToken = (token: string): { inviteId: string; email: str
|
||||
|
||||
const { inviteId, email } = payload;
|
||||
|
||||
let decryptedInviteId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
// If decryption fails, use original values (for older tokens)
|
||||
decryptedInviteId = inviteId;
|
||||
decryptedEmail = email;
|
||||
}
|
||||
|
||||
return {
|
||||
inviteId,
|
||||
email,
|
||||
inviteId: decryptedInviteId,
|
||||
email: decryptedEmail,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error verifying invite token: ${error}`);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"back_to_login": "Zurück zum Login",
|
||||
"email-sent": {
|
||||
"heading": "Passwort erfolgreich angefordert",
|
||||
"text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Klicke auf den Link, um dein Passwort zurückzusetzen klickst:"
|
||||
"text": "Wenn ein Konto mit dieser E-Mail-Adresse existiert, erhälst du in Kürze Anweisungen zum Zurücksetzen deines Passworts."
|
||||
},
|
||||
"reset": {
|
||||
"confirm_password": "Passwort bestätigen",
|
||||
@@ -488,6 +488,8 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Aktion erfolgreich kopiert",
|
||||
"action_copy_failed": "Aktion konnte nicht kopiert werden",
|
||||
"action_created_successfully": "Aktion erfolgreich erstellt",
|
||||
"action_deleted_successfully": "Aktion erfolgreich gelöscht",
|
||||
"action_type": "Aktionstyp",
|
||||
@@ -570,8 +572,10 @@
|
||||
"all_time": "Gesamt",
|
||||
"analysed_feedbacks": "Analysierte Rückmeldungen",
|
||||
"category": "Kategorie",
|
||||
"category_updated_successfully": "Kategorie erfolgreich aktualisiert!",
|
||||
"complaint": "Beschwerde",
|
||||
"did_you_find_this_insight_helpful": "War diese Erkenntnis hilfreich?",
|
||||
"failed_to_update_category": "Kategorie konnte nicht aktualisiert werden",
|
||||
"feature_request": "Anfrage",
|
||||
"good_afternoon": "🌤️ Guten Nachmittag",
|
||||
"good_evening": "🌙 Guten Abend",
|
||||
@@ -1347,6 +1351,8 @@
|
||||
"does_not_contain": "Enthält nicht",
|
||||
"does_not_end_with": "Endet nicht mit",
|
||||
"does_not_equal": "Ungleich",
|
||||
"does_not_include_all_of": "Enthält nicht alle von",
|
||||
"does_not_include_one_of": "Enthält nicht eines von",
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_segment": "Segment bearbeiten",
|
||||
@@ -1935,34 +1941,34 @@
|
||||
"build_product_roadmap_question_2_placeholder": "Tippe deine Antwort hier...",
|
||||
"card_abandonment_survey": "Umfrage zum Warenkorbabbruch",
|
||||
"card_abandonment_survey_description": "Verstehe die Gründe für Warenkorbabbrüche in deinem Webshop.",
|
||||
"card_abandonment_survey_question_1_button_label": "Klar!",
|
||||
"card_abandonment_survey_question_1_dismiss_button_label": "Nein, danke.",
|
||||
"card_abandonment_survey_question_1_headline": "Haben Sie 2 Minuten Zeit, um uns bei der Verbesserung zu helfen?",
|
||||
"card_abandonment_survey_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Wir haben bemerkt, dass Du einige Artikel in deinem Warenkorb gelassen hast. Wir würden gerne verstehen, warum.</span></p>",
|
||||
"card_abandonment_survey_question_2_button_label": "Klar!",
|
||||
"card_abandonment_survey_question_2_dismiss_button_label": "Nein, danke.",
|
||||
"card_abandonment_survey_question_2_headline": "Was ist der Hauptgrund, warum Du deinen Kauf nicht abgeschlossen hast?",
|
||||
"card_abandonment_survey_question_3_choice_1": "Hohe Versandkosten",
|
||||
"card_abandonment_survey_question_3_choice_2": "Woanders einen besseren Preis gefunden",
|
||||
"card_abandonment_survey_question_3_choice_3": "Ich schaue mich nur um",
|
||||
"card_abandonment_survey_question_3_choice_4": "Entschieden, nicht zu kaufen",
|
||||
"card_abandonment_survey_question_3_choice_5": "Problem mit der Zahlung",
|
||||
"card_abandonment_survey_question_3_choice_6": "Andere",
|
||||
"card_abandonment_survey_question_3_headline": "Was war der Hauptgrund, warum Du deinen Kauf nicht abgeschlossen hast?",
|
||||
"card_abandonment_survey_question_3_subheader": "Bitte wähle eine der folgenden Optionen aus:",
|
||||
"card_abandonment_survey_question_4_headline": "Bitte erläutere deinen Grund für den Abbruch des Kaufs:",
|
||||
"card_abandonment_survey_question_5_headline": "Wie würdest Du dein gesamtes Einkaufserlebnis bewerten?",
|
||||
"card_abandonment_survey_question_5_lower_label": "Sehr unzufrieden",
|
||||
"card_abandonment_survey_question_5_upper_label": "Sehr zufrieden",
|
||||
"card_abandonment_survey_question_6_choice_1": "Niedrigere Versandkosten",
|
||||
"card_abandonment_survey_question_6_choice_2": "Rabatte oder Aktionen",
|
||||
"card_abandonment_survey_question_6_choice_3": "Mehr Zahlungsmöglichkeiten",
|
||||
"card_abandonment_survey_question_6_choice_4": "Bessere Produktbeschreibungen",
|
||||
"card_abandonment_survey_question_6_choice_5": "Verbesserte Website-Navigation",
|
||||
"card_abandonment_survey_question_6_choice_6": "Andere",
|
||||
"card_abandonment_survey_question_6_headline": "Welche Faktoren würden Dich dazu ermutigen, deinen Kauf zukünftig abzuschließen?",
|
||||
"card_abandonment_survey_question_6_subheader": "Bitte wähle alle zutreffenden Optionen aus:",
|
||||
"card_abandonment_survey_question_7_headline": "Möchtest Du einen Rabattcode per E-Mail erhalten?",
|
||||
"card_abandonment_survey_question_7_label": "Ja, sehr gerne.",
|
||||
"card_abandonment_survey_question_8_headline": "Bitte teile deine E-Mail-Adresse:",
|
||||
"card_abandonment_survey_question_9_headline": "Weitere Kommentare oder Vorschläge?",
|
||||
"card_abandonment_survey_question_2_choice_1": "Hohe Versandkosten",
|
||||
"card_abandonment_survey_question_2_choice_2": "Woanders einen besseren Preis gefunden",
|
||||
"card_abandonment_survey_question_2_choice_3": "Ich schaue mich nur um",
|
||||
"card_abandonment_survey_question_2_choice_4": "Entschieden, nicht zu kaufen",
|
||||
"card_abandonment_survey_question_2_choice_5": "Problem mit der Zahlung",
|
||||
"card_abandonment_survey_question_2_choice_6": "Andere",
|
||||
"card_abandonment_survey_question_2_headline": "Was war der Hauptgrund, warum Du deinen Kauf nicht abgeschlossen hast?",
|
||||
"card_abandonment_survey_question_2_subheader": "Bitte wähle eine der folgenden Optionen aus:",
|
||||
"card_abandonment_survey_question_3_headline": "Bitte erläutere deinen Grund für den Abbruch des Kaufs:",
|
||||
"card_abandonment_survey_question_4_headline": "Wie würdest Du dein gesamtes Einkaufserlebnis bewerten?",
|
||||
"card_abandonment_survey_question_4_lower_label": "Sehr unzufrieden",
|
||||
"card_abandonment_survey_question_4_upper_label": "Sehr zufrieden",
|
||||
"card_abandonment_survey_question_5_choice_1": "Niedrigere Versandkosten",
|
||||
"card_abandonment_survey_question_5_choice_2": "Rabatte oder Aktionen",
|
||||
"card_abandonment_survey_question_5_choice_3": "Mehr Zahlungsmöglichkeiten",
|
||||
"card_abandonment_survey_question_5_choice_4": "Bessere Produktbeschreibungen",
|
||||
"card_abandonment_survey_question_5_choice_5": "Verbesserte Website-Navigation",
|
||||
"card_abandonment_survey_question_5_choice_6": "Andere",
|
||||
"card_abandonment_survey_question_5_headline": "Welche Faktoren würden Dich dazu ermutigen, deinen Kauf zukünftig abzuschließen?",
|
||||
"card_abandonment_survey_question_5_subheader": "Bitte wähle alle zutreffenden Optionen aus:",
|
||||
"card_abandonment_survey_question_6_headline": "Möchtest Du einen Rabattcode per E-Mail erhalten?",
|
||||
"card_abandonment_survey_question_6_label": "Ja, sehr gerne.",
|
||||
"card_abandonment_survey_question_7_headline": "Bitte teile deine E-Mail-Adresse:",
|
||||
"card_abandonment_survey_question_8_headline": "Weitere Kommentare oder Vorschläge?",
|
||||
"career_development_survey_description": "Bewerte die Mitarbeiterzufriedenheit anhand von Möglichkeiten der Weiterentwicklung.",
|
||||
"career_development_survey_name": "Umfrage zur Karriereentwicklung",
|
||||
"career_development_survey_question_1_headline": "Ich bin zufrieden mit den Möglichkeiten zur persönlichen und beruflichen Entwicklung bei {{productName}}.",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"back_to_login": "Back to login",
|
||||
"email-sent": {
|
||||
"heading": "Password reset successfully requested",
|
||||
"text": "You have requested a link to change your password. You can do this by clicking the link below:"
|
||||
"text": "If an account with this email exists, you will receive password reset instructions shortly."
|
||||
},
|
||||
"reset": {
|
||||
"confirm_password": "Confirm password",
|
||||
@@ -488,6 +488,8 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Action copied successfully",
|
||||
"action_copy_failed": "Action copy failed",
|
||||
"action_created_successfully": "Action created successfully",
|
||||
"action_deleted_successfully": "Action deleted successfully",
|
||||
"action_type": "Action Type",
|
||||
@@ -570,8 +572,10 @@
|
||||
"all_time": "All time",
|
||||
"analysed_feedbacks": "Analysed Free Text Answers",
|
||||
"category": "Category",
|
||||
"category_updated_successfully": "Category updated successfully!",
|
||||
"complaint": "Complaint",
|
||||
"did_you_find_this_insight_helpful": "Did you find this insight helpful?",
|
||||
"failed_to_update_category": "Failed to update category",
|
||||
"feature_request": "Request",
|
||||
"good_afternoon": "🌤️ Good afternoon",
|
||||
"good_evening": "🌙 Good evening",
|
||||
@@ -1347,6 +1351,8 @@
|
||||
"does_not_contain": "Does not contain",
|
||||
"does_not_end_with": "Does not end with",
|
||||
"does_not_equal": "Does not equal",
|
||||
"does_not_include_all_of": "Does not include all of",
|
||||
"does_not_include_one_of": "Does not include one of",
|
||||
"does_not_start_with": "Does not start with",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_segment": "Edit Segment",
|
||||
@@ -1935,34 +1941,34 @@
|
||||
"build_product_roadmap_question_2_placeholder": "Type your answer here...",
|
||||
"card_abandonment_survey": "Cart Abandonment Survey",
|
||||
"card_abandonment_survey_description": "Understand the reasons behind cart abandonment in your web shop.",
|
||||
"card_abandonment_survey_question_1_button_label": "Sure!",
|
||||
"card_abandonment_survey_question_1_dismiss_button_label": "No, thanks.",
|
||||
"card_abandonment_survey_question_1_headline": "Do you have 2 minutes to help us improve?",
|
||||
"card_abandonment_survey_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We noticed you left some items in your cart. We would love to understand why.</span></p>",
|
||||
"card_abandonment_survey_question_2_button_label": "Sure!",
|
||||
"card_abandonment_survey_question_2_dismiss_button_label": "No, thanks.",
|
||||
"card_abandonment_survey_question_2_choice_1": "High shipping costs",
|
||||
"card_abandonment_survey_question_2_choice_2": "Found a better price elsewhere",
|
||||
"card_abandonment_survey_question_2_choice_3": "Just browsing",
|
||||
"card_abandonment_survey_question_2_choice_4": "Decided not to buy",
|
||||
"card_abandonment_survey_question_2_choice_5": "Payment issues",
|
||||
"card_abandonment_survey_question_2_choice_6": "Other",
|
||||
"card_abandonment_survey_question_2_headline": "What was the primary reason you didn't complete your purchase?",
|
||||
"card_abandonment_survey_question_3_choice_1": "High shipping costs",
|
||||
"card_abandonment_survey_question_3_choice_2": "Found a better price elsewhere",
|
||||
"card_abandonment_survey_question_3_choice_3": "Just browsing",
|
||||
"card_abandonment_survey_question_3_choice_4": "Decided not to buy",
|
||||
"card_abandonment_survey_question_3_choice_5": "Payment issues",
|
||||
"card_abandonment_survey_question_3_choice_6": "Other",
|
||||
"card_abandonment_survey_question_3_headline": "What was the primary reason you didn't complete your purchase?",
|
||||
"card_abandonment_survey_question_3_subheader": "Please select one of the following options:",
|
||||
"card_abandonment_survey_question_4_headline": "Please elaborate on your reason for not completing the purchase:",
|
||||
"card_abandonment_survey_question_5_headline": "How would you rate your overall shopping experience?",
|
||||
"card_abandonment_survey_question_5_lower_label": "Very dissatisfied",
|
||||
"card_abandonment_survey_question_5_upper_label": "Very satisfied",
|
||||
"card_abandonment_survey_question_6_choice_1": "Lower shipping costs",
|
||||
"card_abandonment_survey_question_6_choice_2": "Discounts or promotions",
|
||||
"card_abandonment_survey_question_6_choice_3": "More payment options",
|
||||
"card_abandonment_survey_question_6_choice_4": "Better product descriptions",
|
||||
"card_abandonment_survey_question_6_choice_5": "Improved website navigation",
|
||||
"card_abandonment_survey_question_6_choice_6": "Other",
|
||||
"card_abandonment_survey_question_6_headline": "What factors would encourage you to complete your purchase in the future?",
|
||||
"card_abandonment_survey_question_6_subheader": "Please select all that apply:",
|
||||
"card_abandonment_survey_question_7_headline": "Would you like to receive a discount code via email?",
|
||||
"card_abandonment_survey_question_7_label": "Yes, please reach out.",
|
||||
"card_abandonment_survey_question_8_headline": "Please share your email address:",
|
||||
"card_abandonment_survey_question_9_headline": "Any additional comments or suggestions?",
|
||||
"card_abandonment_survey_question_2_subheader": "Please select one of the following options:",
|
||||
"card_abandonment_survey_question_3_headline": "Please elaborate on your reason for not completing the purchase:",
|
||||
"card_abandonment_survey_question_4_headline": "How would you rate your overall shopping experience?",
|
||||
"card_abandonment_survey_question_4_lower_label": "Very dissatisfied",
|
||||
"card_abandonment_survey_question_4_upper_label": "Very satisfied",
|
||||
"card_abandonment_survey_question_5_choice_1": "Lower shipping costs",
|
||||
"card_abandonment_survey_question_5_choice_2": "Discounts or promotions",
|
||||
"card_abandonment_survey_question_5_choice_3": "More payment options",
|
||||
"card_abandonment_survey_question_5_choice_4": "Better product descriptions",
|
||||
"card_abandonment_survey_question_5_choice_5": "Improved website navigation",
|
||||
"card_abandonment_survey_question_5_choice_6": "Other",
|
||||
"card_abandonment_survey_question_5_headline": "What factors would encourage you to complete your purchase in the future?",
|
||||
"card_abandonment_survey_question_5_subheader": "Please select all that apply:",
|
||||
"card_abandonment_survey_question_6_headline": "Would you like to receive a discount code via email?",
|
||||
"card_abandonment_survey_question_6_label": "Yes, please reach out.",
|
||||
"card_abandonment_survey_question_7_headline": "Please share your email address:",
|
||||
"card_abandonment_survey_question_8_headline": "Any additional comments or suggestions?",
|
||||
"career_development_survey_description": "Assess employee satisfaction with career growth and development opportunities.",
|
||||
"career_development_survey_name": "Career Development Survey",
|
||||
"career_development_survey_question_1_headline": "I am satisfied with the opportunities for personal and professional growth at {{productName}}.",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"an_error_occurred_when_logging": "Ocorreu um erro ao fazer login",
|
||||
"back_to_login": "Voltar para o login",
|
||||
"email-sent": {
|
||||
"heading": "Solicitação de redefinição de senha feita com sucesso",
|
||||
"text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:"
|
||||
"heading": "Pedido de redefinição de senha feito com sucesso",
|
||||
"text": "Se existir uma conta com esse e-mail, você vai receber em breve as instruções pra redefinir sua senha."
|
||||
},
|
||||
"reset": {
|
||||
"confirm_password": "Confirmar senha",
|
||||
@@ -488,6 +488,8 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Ação copiada com sucesso",
|
||||
"action_copy_failed": "Falha ao copiar a ação",
|
||||
"action_created_successfully": "Ação criada com sucesso",
|
||||
"action_deleted_successfully": "Ação deletada com sucesso",
|
||||
"action_type": "Tipo de Ação",
|
||||
@@ -570,8 +572,10 @@
|
||||
"all_time": "Todo o tempo",
|
||||
"analysed_feedbacks": "Feedbacks Analisados",
|
||||
"category": "Categoria",
|
||||
"category_updated_successfully": "Categoria atualizada com sucesso!",
|
||||
"complaint": "Reclamação",
|
||||
"did_you_find_this_insight_helpful": "Você achou essa dica útil?",
|
||||
"failed_to_update_category": "Falha ao atualizar categoria",
|
||||
"feature_request": "Pedido de Recurso",
|
||||
"good_afternoon": "🌤️ Boa tarde",
|
||||
"good_evening": "🌙 Boa noite",
|
||||
@@ -1347,6 +1351,8 @@
|
||||
"does_not_contain": "não contém",
|
||||
"does_not_end_with": "Não termina com",
|
||||
"does_not_equal": "não é igual",
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_segment": "Editar Segmento",
|
||||
@@ -1935,34 +1941,34 @@
|
||||
"build_product_roadmap_question_2_placeholder": "Digite sua resposta aqui...",
|
||||
"card_abandonment_survey": "Pesquisa de Abandono de Carrinho",
|
||||
"card_abandonment_survey_description": "Entenda os motivos por trás do abandono de carrinho na sua loja online.",
|
||||
"card_abandonment_survey_question_1_button_label": "Claro!",
|
||||
"card_abandonment_survey_question_1_dismiss_button_label": "Não, valeu.",
|
||||
"card_abandonment_survey_question_1_headline": "Você tem 2 minutos para nos ajudar a melhorar?",
|
||||
"card_abandonment_survey_question_1_html": "Percebemos que você deixou alguns itens no seu carrinho. Adoraríamos entender o motivo.",
|
||||
"card_abandonment_survey_question_2_button_label": "Claro!",
|
||||
"card_abandonment_survey_question_2_dismiss_button_label": "Não, valeu.",
|
||||
"card_abandonment_survey_question_2_headline": "Qual foi o principal motivo para você não ter finalizado sua compra?",
|
||||
"card_abandonment_survey_question_3_choice_1": "Custos de frete altos",
|
||||
"card_abandonment_survey_question_3_choice_2": "Encontrei um preço melhor em outro lugar",
|
||||
"card_abandonment_survey_question_3_choice_3": "Só dando uma olhada",
|
||||
"card_abandonment_survey_question_3_choice_4": "Decidi não comprar",
|
||||
"card_abandonment_survey_question_3_choice_5": "Problemas com pagamento",
|
||||
"card_abandonment_survey_question_3_choice_6": "outro",
|
||||
"card_abandonment_survey_question_3_headline": "Qual foi o principal motivo pra você não ter finalizado a compra?",
|
||||
"card_abandonment_survey_question_3_subheader": "Por favor, escolha uma das opções a seguir:",
|
||||
"card_abandonment_survey_question_4_headline": "Por favor, explique o motivo de não ter concluído a compra:",
|
||||
"card_abandonment_survey_question_5_headline": "Como você avaliaria sua experiência geral de compra?",
|
||||
"card_abandonment_survey_question_5_lower_label": "Muito insatisfeito",
|
||||
"card_abandonment_survey_question_5_upper_label": "Muito satisfeito",
|
||||
"card_abandonment_survey_question_6_choice_1": "Reduzir os custos de envio",
|
||||
"card_abandonment_survey_question_6_choice_2": "Descontos ou promoções",
|
||||
"card_abandonment_survey_question_6_choice_3": "Mais opções de pagamento",
|
||||
"card_abandonment_survey_question_6_choice_4": "Melhores descrições de produtos",
|
||||
"card_abandonment_survey_question_6_choice_5": "Navegação do site melhorada",
|
||||
"card_abandonment_survey_question_6_choice_6": "outro",
|
||||
"card_abandonment_survey_question_6_headline": "O que te incentivaria a finalizar sua compra no futuro?",
|
||||
"card_abandonment_survey_question_6_subheader": "Por favor, selecione todas as opções que se aplicam:",
|
||||
"card_abandonment_survey_question_7_headline": "Você gostaria de receber um código de desconto por e-mail?",
|
||||
"card_abandonment_survey_question_7_label": "Sim, por favor entre em contato.",
|
||||
"card_abandonment_survey_question_8_headline": "Por favor, compartilha seu e-mail:",
|
||||
"card_abandonment_survey_question_9_headline": "Algum comentário ou sugestão a mais?",
|
||||
"card_abandonment_survey_question_2_choice_1": "Custos de frete altos",
|
||||
"card_abandonment_survey_question_2_choice_2": "Encontrei um preço melhor em outro lugar",
|
||||
"card_abandonment_survey_question_2_choice_3": "Só dando uma olhada",
|
||||
"card_abandonment_survey_question_2_choice_4": "Decidi não comprar",
|
||||
"card_abandonment_survey_question_2_choice_5": "Problemas com pagamento",
|
||||
"card_abandonment_survey_question_2_choice_6": "outro",
|
||||
"card_abandonment_survey_question_2_headline": "Qual foi o principal motivo pra você não ter finalizado a compra?",
|
||||
"card_abandonment_survey_question_2_subheader": "Por favor, escolha uma das opções a seguir:",
|
||||
"card_abandonment_survey_question_3_headline": "Por favor, explique o motivo de não ter concluído a compra:",
|
||||
"card_abandonment_survey_question_4_headline": "Como você avaliaria sua experiência geral de compra?",
|
||||
"card_abandonment_survey_question_4_lower_label": "Muito insatisfeito",
|
||||
"card_abandonment_survey_question_4_upper_label": "Muito satisfeito",
|
||||
"card_abandonment_survey_question_5_choice_1": "Reduzir os custos de envio",
|
||||
"card_abandonment_survey_question_5_choice_2": "Descontos ou promoções",
|
||||
"card_abandonment_survey_question_5_choice_3": "Mais opções de pagamento",
|
||||
"card_abandonment_survey_question_5_choice_4": "Melhores descrições de produtos",
|
||||
"card_abandonment_survey_question_5_choice_5": "Navegação do site melhorada",
|
||||
"card_abandonment_survey_question_5_choice_6": "outro",
|
||||
"card_abandonment_survey_question_5_headline": "O que te incentivaria a finalizar sua compra no futuro?",
|
||||
"card_abandonment_survey_question_5_subheader": "Por favor, selecione todas as opções que se aplicam:",
|
||||
"card_abandonment_survey_question_6_headline": "Você gostaria de receber um código de desconto por e-mail?",
|
||||
"card_abandonment_survey_question_6_label": "Sim, por favor entre em contato.",
|
||||
"card_abandonment_survey_question_7_headline": "Por favor, compartilha seu e-mail:",
|
||||
"card_abandonment_survey_question_8_headline": "Algum comentário ou sugestão a mais?",
|
||||
"career_development_survey_description": "Avalie a satisfação dos funcionários com o crescimento na carreira e oportunidades de desenvolvimento.",
|
||||
"career_development_survey_name": "Pesquisa de Desenvolvimento de Carreira",
|
||||
"career_development_survey_question_1_headline": "Estou satisfeito(a) com as oportunidades de desenvolvimento pessoal e profissional no {{productName}}.",
|
||||
|
||||
@@ -8,6 +8,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TPerson, TPersonWithAttributes } from "@formbricks/types/people";
|
||||
import { cache } from "../cache";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { displayCache } from "../display/cache";
|
||||
import { responseCache } from "../response/cache";
|
||||
import { surveyCache } from "../survey/cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { personCache } from "./cache";
|
||||
|
||||
@@ -224,7 +227,34 @@ export const deletePerson = async (personId: string): Promise<TPerson | null> =>
|
||||
validateInputs([personId, ZId]);
|
||||
|
||||
try {
|
||||
const person = await prisma.person.delete({
|
||||
const personRespondedSurveyIds = await prisma.response.findMany({
|
||||
where: {
|
||||
personId,
|
||||
},
|
||||
select: {
|
||||
surveyId: true,
|
||||
},
|
||||
distinct: ["surveyId"],
|
||||
});
|
||||
|
||||
const displaySurveyIds = await prisma.display.findMany({
|
||||
where: {
|
||||
personId,
|
||||
},
|
||||
select: {
|
||||
surveyId: true,
|
||||
},
|
||||
distinct: ["surveyId"],
|
||||
});
|
||||
|
||||
const uniqueSurveyIds = Array.from(
|
||||
new Set([
|
||||
...personRespondedSurveyIds.map(({ surveyId }) => surveyId),
|
||||
...displaySurveyIds.map(({ surveyId }) => surveyId),
|
||||
])
|
||||
);
|
||||
|
||||
const deletedPerson = await prisma.person.delete({
|
||||
where: {
|
||||
id: personId,
|
||||
},
|
||||
@@ -232,12 +262,30 @@ export const deletePerson = async (personId: string): Promise<TPerson | null> =>
|
||||
});
|
||||
|
||||
personCache.revalidate({
|
||||
id: person.id,
|
||||
userId: person.userId,
|
||||
environmentId: person.environmentId,
|
||||
id: deletedPerson.id,
|
||||
userId: deletedPerson.userId,
|
||||
environmentId: deletedPerson.environmentId,
|
||||
});
|
||||
|
||||
return person;
|
||||
surveyCache.revalidate({
|
||||
environmentId: deletedPerson.environmentId,
|
||||
});
|
||||
|
||||
responseCache.revalidate({
|
||||
personId: deletedPerson.id,
|
||||
environmentId: deletedPerson.environmentId,
|
||||
});
|
||||
|
||||
for (const surveyId of uniqueSurveyIds) {
|
||||
responseCache.revalidate({
|
||||
surveyId,
|
||||
});
|
||||
displayCache.revalidate({
|
||||
surveyId,
|
||||
});
|
||||
}
|
||||
|
||||
return deletedPerson;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// https://github.com/airbnb/javascript/#naming--uppercase
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getDefaultEndingCard, translate } from "../templates";
|
||||
import { translate } from "../templates";
|
||||
|
||||
export const COLOR_DEFAULTS = {
|
||||
brandColor: "#64748b",
|
||||
@@ -100,7 +100,16 @@ export const getPreviewSurvey = (locale: string) => {
|
||||
shuffleOption: "none",
|
||||
},
|
||||
],
|
||||
endings: [getDefaultEndingCard([], locale)],
|
||||
endings: [
|
||||
{
|
||||
id: "cltyqp5ng000108l9dmxw6nde",
|
||||
type: "endScreen",
|
||||
headline: { default: translate("default_ending_card_headline", locale) },
|
||||
subheader: { default: translate("default_ending_card_subheader", locale) },
|
||||
buttonLabel: { default: translate("default_ending_card_button_label", locale) },
|
||||
buttonLink: "https://formbricks.com",
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
|
||||
@@ -425,6 +425,18 @@ const evaluateSingleCondition = (
|
||||
Array.isArray(rightValue) &&
|
||||
rightValue.some((v) => leftValue.includes(v))
|
||||
);
|
||||
case "doesNotIncludeAllOf":
|
||||
return (
|
||||
Array.isArray(leftValue) &&
|
||||
Array.isArray(rightValue) &&
|
||||
rightValue.every((v) => !leftValue.includes(v))
|
||||
);
|
||||
case "doesNotIncludeOneOf":
|
||||
return (
|
||||
Array.isArray(leftValue) &&
|
||||
Array.isArray(rightValue) &&
|
||||
rightValue.some((v) => !leftValue.includes(v))
|
||||
);
|
||||
case "isAccepted":
|
||||
return leftValue === "accepted";
|
||||
case "isClicked":
|
||||
|
||||
@@ -107,19 +107,19 @@ const cartAbandonmentSurvey = (locale: string): TTemplate => {
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: translate("card_abandonment_survey_question_2_headline", locale) },
|
||||
headline: { default: translate("card_abandonment_survey_question_1_headline", locale) },
|
||||
required: false,
|
||||
buttonLabel: { default: translate("card_abandonment_survey_question_2_button_label", locale) },
|
||||
buttonLabel: { default: translate("card_abandonment_survey_question_1_button_label", locale) },
|
||||
buttonExternal: false,
|
||||
dismissButtonLabel: {
|
||||
default: translate("card_abandonment_survey_question_2_dismiss_button_label", locale),
|
||||
default: translate("card_abandonment_survey_question_1_dismiss_button_label", locale),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: translate("card_abandonment_survey_question_3_headline", locale) },
|
||||
subheader: { default: translate("card_abandonment_survey_question_3_subheader", locale) },
|
||||
headline: { default: translate("card_abandonment_survey_question_2_headline", locale) },
|
||||
subheader: { default: translate("card_abandonment_survey_question_2_subheader", locale) },
|
||||
buttonLabel: { default: translate("next", locale) },
|
||||
backButtonLabel: { default: translate("back", locale) },
|
||||
required: true,
|
||||
@@ -127,27 +127,27 @@ const cartAbandonmentSurvey = (locale: string): TTemplate => {
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_3_choice_1", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_2_choice_1", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_3_choice_2", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_2_choice_2", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_3_choice_3", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_2_choice_3", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_3_choice_4", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_2_choice_4", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_3_choice_5", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_2_choice_5", locale) },
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: { default: translate("card_abandonment_survey_question_3_choice_6", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_2_choice_6", locale) },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -155,7 +155,7 @@ const cartAbandonmentSurvey = (locale: string): TTemplate => {
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: translate("card_abandonment_survey_question_4_headline", locale),
|
||||
default: translate("card_abandonment_survey_question_3_headline", locale),
|
||||
},
|
||||
required: false,
|
||||
buttonLabel: { default: translate("next", locale) },
|
||||
@@ -165,12 +165,12 @@ const cartAbandonmentSurvey = (locale: string): TTemplate => {
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: translate("card_abandonment_survey_question_5_headline", locale) },
|
||||
headline: { default: translate("card_abandonment_survey_question_4_headline", locale) },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: translate("card_abandonment_survey_question_5_lower_label", locale) },
|
||||
upperLabel: { default: translate("card_abandonment_survey_question_5_upper_label", locale) },
|
||||
lowerLabel: { default: translate("card_abandonment_survey_question_4_lower_label", locale) },
|
||||
upperLabel: { default: translate("card_abandonment_survey_question_4_upper_label", locale) },
|
||||
buttonLabel: { default: translate("next", locale) },
|
||||
backButtonLabel: { default: translate("back", locale) },
|
||||
isColorCodingEnabled: false,
|
||||
@@ -179,36 +179,36 @@ const cartAbandonmentSurvey = (locale: string): TTemplate => {
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: {
|
||||
default: translate("card_abandonment_survey_question_6_headline", locale),
|
||||
default: translate("card_abandonment_survey_question_5_headline", locale),
|
||||
},
|
||||
subheader: { default: translate("card_abandonment_survey_question_6_subheader", locale) },
|
||||
subheader: { default: translate("card_abandonment_survey_question_5_subheader", locale) },
|
||||
buttonLabel: { default: translate("next", locale) },
|
||||
backButtonLabel: { default: translate("back", locale) },
|
||||
required: true,
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_6_choice_1", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_5_choice_1", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_6_choice_2", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_5_choice_2", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_6_choice_3", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_5_choice_3", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_6_choice_4", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_5_choice_4", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: translate("card_abandonment_survey_question_6_choice_5", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_5_choice_5", locale) },
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: { default: translate("card_abandonment_survey_question_6_choice_6", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_5_choice_6", locale) },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -241,16 +241,16 @@ const cartAbandonmentSurvey = (locale: string): TTemplate => {
|
||||
},
|
||||
],
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: translate("card_abandonment_survey_question_7_headline", locale) },
|
||||
headline: { default: translate("card_abandonment_survey_question_6_headline", locale) },
|
||||
required: false,
|
||||
label: { default: translate("card_abandonment_survey_question_7_label", locale) },
|
||||
label: { default: translate("card_abandonment_survey_question_6_label", locale) },
|
||||
buttonLabel: { default: translate("next", locale) },
|
||||
backButtonLabel: { default: translate("back", locale) },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: translate("card_abandonment_survey_question_8_headline", locale) },
|
||||
headline: { default: translate("card_abandonment_survey_question_7_headline", locale) },
|
||||
required: true,
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
@@ -261,7 +261,7 @@ const cartAbandonmentSurvey = (locale: string): TTemplate => {
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: translate("card_abandonment_survey_question_9_headline", locale) },
|
||||
headline: { default: translate("card_abandonment_survey_question_8_headline", locale) },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
buttonLabel: { default: translate("finish", locale) },
|
||||
|
||||
@@ -33,7 +33,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProductStyling | TS
|
||||
let cssVariables = ":root {\n";
|
||||
|
||||
// Helper function to append the variable if it's not undefined
|
||||
const appendCssVariable = (variableName: string, value: string | undefined) => {
|
||||
const appendCssVariable = (variableName: string, value?: string) => {
|
||||
if (value !== undefined) {
|
||||
cssVariables += `--fb-${variableName}: ${value};\n`;
|
||||
}
|
||||
|
||||
@@ -249,6 +249,8 @@ export const ZSurveyLogicConditionsOperator = z.enum([
|
||||
"equalsOneOf",
|
||||
"includesAllOf",
|
||||
"includesOneOf",
|
||||
"doesNotIncludeOneOf",
|
||||
"doesNotIncludeAllOf",
|
||||
"isClicked",
|
||||
"isAccepted",
|
||||
"isBefore",
|
||||
@@ -1212,9 +1214,16 @@ const isInvalidOperatorsForQuestionType = (
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
if (
|
||||
!["equals", "doesNotEqual", "includesAllOf", "includesOneOf", "isSubmitted", "isSkipped"].includes(
|
||||
operator
|
||||
)
|
||||
![
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"includesAllOf",
|
||||
"includesOneOf",
|
||||
"doesNotIncludeAllOf",
|
||||
"doesNotIncludeOneOf",
|
||||
"isSubmitted",
|
||||
"isSkipped",
|
||||
].includes(operator)
|
||||
) {
|
||||
isInvalidOperator = true;
|
||||
}
|
||||
@@ -1553,7 +1562,11 @@ const validateConditions = (
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (condition.operator === "includesAllOf" || condition.operator === "includesOneOf") {
|
||||
} else if (
|
||||
["includesAllOf", "includesOneOf", "doesNotIncludeAllOf", "doesNotIncludeOneOf"].includes(
|
||||
condition.operator
|
||||
)
|
||||
) {
|
||||
if (!Array.isArray(rightOperand.value)) {
|
||||
issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
||||
Reference in New Issue
Block a user