Compare commits

...

9 Commits

Author SHA1 Message Date
Matti Nannt
a8ab4aaf2e chore: prepare 2.7.1 release (#4302) 2024-11-13 15:37:36 +01:00
Piyush Gupta
78dca7a2bf fix: response cache invalidation on person delete (#4300)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-11-13 14:13:55 +00:00
Piyush Gupta
844ea40c3a feat: adds does not include options in conditions (#4296) 2024-11-13 13:01:03 +00:00
Piyush Gupta
7a6dedf452 fix: cache invalidation in sentiment and category update (#4295)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-11-13 12:58:03 +00:00
Dhruwang Jariwala
b641b37308 fix: rate limiting to forget password (#4297)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-11-13 12:27:21 +00:00
Dhruwang Jariwala
8c1f8bfb42 fix: email enumeration via forgot password page (#4299)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-11-13 12:05:44 +00:00
Dhruwang Jariwala
1f1563401d fix: Email Address Disclosure via URL in Registration Process (#4241)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-11-13 11:57:53 +00:00
Dhruwang Jariwala
9fd585ee07 fix: styling fixes (#4279)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-11-13 10:59:01 +00:00
Sai Suhas Sawant
609dcabf77 feat: promote dev-actions to prod (#4245)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-11-13 08:17:41 +00:00
51 changed files with 655 additions and 272 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,6 @@ export const Dashboard = ({
value={statsPeriod}
onValueChange={(value) => {
if (value) {
console.log("Stats period changed to:", value);
setStatsPeriod(value as TStatsPeriod);
}
}}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "2.7.0",
"version": "2.7.1",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [],

View File

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

View File

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

View File

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

View File

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