Compare commits

...

7 Commits

Author SHA1 Message Date
Dhruwang Jariwala 8d47ab9709 fix: rtl tweaks (#7136) 2026-01-21 07:08:22 +00:00
Matti Nannt 8f6d27c1ef fix: upgrade next.js and preact to fix high-severity vulnerabilities (#7134) 2026-01-20 11:22:01 +00:00
Dhruwang Jariwala a37815b831 fix: breaking email embed preview for single select question (#7133) 2026-01-20 06:42:15 +00:00
Dhruwang Jariwala 2b526a87ca fix: email locale in invite accepted email (#7124) 2026-01-19 13:32:01 +00:00
Dhruwang Jariwala 047750967c fix: console warnings in survey ui package (#7130) 2026-01-19 07:19:13 +00:00
Johannes a54356c3b0 docs: add CSAT and update Survey Cooldown (#7128)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-19 07:06:16 +00:00
Matti Nannt 38ea5ed6ae perf: remove redundant database indexes (#7104) 2026-01-16 10:17:05 +00:00
63 changed files with 764 additions and 247 deletions
@@ -58,7 +58,7 @@ async function handleEmailUpdate({
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
await sendVerificationNewEmail(ctx.user.id, inputEmail, ctx.user.locale);
}
return payload;
}
@@ -58,6 +58,7 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
ctx.user.email,
emailHtml,
survey.environmentId,
ctx.user.locale,
organizationLogoUrl || ""
);
});
@@ -215,7 +215,14 @@ export const POST = async (request: Request) => {
}
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
sendResponseFinishedEmail(
user.email,
user.locale,
environmentId,
survey,
response,
responseCount
).catch((error) => {
logger.error(
{ error, url: request.url, userEmail: user.email },
`Failed to send email to ${user.email}`
+17 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "./server";
@@ -11,6 +11,10 @@ vi.mock("@/lingodotdev/shared", () => ({
}));
describe("lingodotdev server", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should get translate", async () => {
vi.mocked(getLocale).mockResolvedValue("en-US");
const translate = await getTranslate();
@@ -22,4 +26,16 @@ describe("lingodotdev server", () => {
const translate = await getTranslate();
expect(translate).toBeDefined();
});
test("should use provided locale instead of calling getLocale", async () => {
const translate = await getTranslate("de-DE");
expect(getLocale).not.toHaveBeenCalled();
expect(translate).toBeDefined();
});
test("should call getLocale when locale is not provided", async () => {
vi.mocked(getLocale).mockResolvedValue("fr-FR");
await getTranslate();
expect(getLocale).toHaveBeenCalled();
});
});
+5 -4
View File
@@ -3,6 +3,7 @@ import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { TUserLocale } from "@formbricks/types/user";
import { getLocale } from "@/lingodotdev/language";
const initI18next = async (lng: string) => {
@@ -21,9 +22,9 @@ const initI18next = async (lng: string) => {
return i18nInstance;
};
export async function getTranslate() {
const locale = await getLocale();
export async function getTranslate(locale?: TUserLocale) {
const resolvedLocale = locale ?? (await getLocale());
const i18nextInstance = await initI18next(locale);
return i18nextInstance.getFixedT(locale);
const i18nextInstance = await initI18next(resolvedLocale);
return i18nextInstance.getFixedT(resolvedLocale);
}
@@ -33,7 +33,7 @@ export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).act
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = updatedUser;
await sendPasswordResetNotifyEmail(updatedUser);
await sendPasswordResetNotifyEmail({ email: updatedUser.email, locale: updatedUser.locale });
return { success: true };
}
)
@@ -69,6 +69,7 @@ describe("invite", () => {
creator: {
name: "Test User",
email: "test@example.com",
locale: "en-US",
},
};
@@ -89,6 +90,7 @@ describe("invite", () => {
select: {
name: true,
email: true,
locale: true,
},
},
},
@@ -46,6 +46,7 @@ export const getInvite = reactCache(async (inviteId: string): Promise<InviteWith
select: {
name: true,
email: true,
locale: true,
},
},
},
+6 -1
View File
@@ -102,7 +102,12 @@ export const InvitePage = async (props: InvitePageProps) => {
);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(invite.creator.name ?? "", user?.name ?? "", invite.creator.email);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user?.name ?? "",
invite.creator.email,
invite.creator.locale
);
await updateUser(session.user.id, {
notificationSettings: {
...user.notificationSettings,
@@ -1,10 +1,12 @@
import { Invite } from "@prisma/client";
import { TUserLocale } from "@formbricks/types/user";
export interface InviteWithCreator
extends Pick<Invite, "id" | "expiresAt" | "organizationId" | "role" | "teamIds"> {
creator: {
name: string | null;
email: string;
locale: TUserLocale;
};
}
+7 -2
View File
@@ -127,7 +127,12 @@ async function handleInviteAcceptance(
},
});
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user.name,
invite.creator.email,
invite.creator.locale
);
await deleteInvite(invite.id);
}
@@ -168,7 +173,7 @@ async function handlePostUserCreation(
}
if (!emailVerificationDisabled) {
await sendVerificationEmail(user);
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
}
}
@@ -48,8 +48,7 @@ describe("resendVerificationEmailAction", () => {
const mockUser = {
id: "user123",
email: "test@example.com",
emailVerified: null, // Not verified
name: "Test User",
locale: "en-US",
};
const mockVerifiedUser = {
@@ -32,7 +32,7 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica
};
}
ctx.auditLoggingCtx.userId = user.id;
await sendVerificationEmail(user);
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
return {
success: true,
};
@@ -121,6 +121,7 @@ export const sendTestEmailAction = authenticatedActionClient
await sendEmailCustomizationPreviewEmail(
ctx.user.email,
ctx.user.name,
ctx.user.locale,
organization?.whitelabel?.logoUrl || ""
);
@@ -213,8 +213,8 @@ export async function PreviewEmailTemplate({
{ "rounded-l-lg border-l": i === 0 },
{ "rounded-r-lg": i === firstQuestion.range - 1 },
firstQuestion.isColorCodingEnabled &&
firstQuestion.scale === "number" &&
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
firstQuestion.scale === "number" &&
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
firstQuestion.scale === "star" && "border-transparent"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}
@@ -288,7 +288,7 @@ export async function PreviewEmailTemplate({
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Link
className="border-input-border-color bg-input-color text-question-color rounded-custom mt-2 block border border-solid p-4 hover:bg-slate-100"
className="border-input-border-color bg-input-color text-question-color rounded-custom mt-2 block border border-solid p-4"
href={`${urlWithPrefilling}${firstQuestion.id}=${getLocalizedValue(choice.label, defaultLanguageCode)}`}
key={choice.id}>
{getLocalizedValue(choice.label, defaultLanguageCode)}
+36 -23
View File
@@ -71,12 +71,12 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean>
secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports
...(SMTP_AUTHENTICATED
? {
auth: {
type: "LOGIN",
user: SMTP_USER,
pass: SMTP_PASSWORD,
},
}
auth: {
type: "LOGIN",
user: SMTP_USER,
pass: SMTP_PASSWORD,
},
}
: {}),
tls: {
rejectUnauthorized: SMTP_REJECT_UNAUTHORIZED_TLS,
@@ -97,9 +97,13 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean>
}
};
export const sendVerificationNewEmail = async (id: string, email: string): Promise<boolean> => {
export const sendVerificationNewEmail = async (
id: string,
email: string,
locale: TUserLocale
): Promise<boolean> => {
try {
const t = await getTranslate();
const t = await getTranslate(locale);
const token = createEmailChangeToken(id, email);
const verifyLink = `${WEBAPP_URL}/verify-email-change?token=${encodeURIComponent(token)}`;
@@ -119,12 +123,14 @@ export const sendVerificationNewEmail = async (id: string, email: string): Promi
export const sendVerificationEmail = async ({
id,
email,
locale,
}: {
id: string;
email: TUserEmail;
locale: TUserLocale;
}): Promise<boolean> => {
try {
const t = await getTranslate();
const t = await getTranslate(locale);
const token = createToken(id, {
expiresIn: "1d",
});
@@ -154,7 +160,7 @@ export const sendForgotPasswordEmail = async (user: {
email: TUserEmail;
locale: TUserLocale;
}): Promise<boolean> => {
const t = await getTranslate();
const t = await getTranslate(user.locale);
const token = createToken(user.id, {
expiresIn: "1d",
});
@@ -167,8 +173,11 @@ export const sendForgotPasswordEmail = async (user: {
});
};
export const sendPasswordResetNotifyEmail = async (user: { email: string }): Promise<boolean> => {
const t = await getTranslate();
export const sendPasswordResetNotifyEmail = async (user: {
email: string;
locale: TUserLocale;
}): Promise<boolean> => {
const t = await getTranslate(user.locale);
const html = await renderPasswordResetNotifyEmail({ t, ...legalProps });
return await sendEmail({
to: user.email,
@@ -201,9 +210,10 @@ export const sendInviteMemberEmail = async (
export const sendInviteAcceptedEmail = async (
inviterName: string,
inviteeName: string,
email: string
email: string,
inviterLocale?: TUserLocale
): Promise<void> => {
const t = await getTranslate();
const t = await getTranslate(inviterLocale);
const html = await renderInviteAcceptedEmail({ inviteeName, inviterName, t, ...legalProps });
await sendEmail({
to: email,
@@ -214,12 +224,13 @@ export const sendInviteAcceptedEmail = async (
export const sendResponseFinishedEmail = async (
email: string,
locale: TUserLocale,
environmentId: string,
survey: TSurvey,
response: TResponse,
responseCount: number
): Promise<void> => {
const t = await getTranslate();
const t = await getTranslate(locale);
const personEmail = response.contactAttributes?.email;
const organization = await getOrganizationByEnvironmentId(environmentId);
@@ -246,12 +257,12 @@ export const sendResponseFinishedEmail = async (
to: email,
subject: personEmail
? t("emails.response_finished_email_subject_with_email", {
personEmail,
surveyName: survey.name,
})
personEmail,
surveyName: survey.name,
})
: t("emails.response_finished_email_subject", {
surveyName: survey.name,
}),
surveyName: survey.name,
}),
replyTo: personEmail?.toString() ?? MAIL_FROM,
html,
});
@@ -261,9 +272,10 @@ export const sendEmbedSurveyPreviewEmail = async (
to: string,
innerHtml: string,
environmentId: string,
locale: TUserLocale,
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const t = await getTranslate(locale);
const html = await renderEmbedSurveyPreviewEmail({
html: innerHtml,
environmentId,
@@ -281,9 +293,10 @@ export const sendEmbedSurveyPreviewEmail = async (
export const sendEmailCustomizationPreviewEmail = async (
to: string,
userName: string,
locale: TUserLocale,
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const t = await getTranslate(locale);
const emailHtmlBody = await renderEmailCustomizationPreviewEmail({
userName,
logoUrl,
@@ -305,7 +318,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
const singleUseId = data.suId;
const logoUrl = data.logoUrl || "";
const token = createTokenForLinkSurvey(surveyId, email);
const t = await getTranslate();
const t = await getTranslate(data.locale);
const getSurveyLink = (): string => {
if (singleUseId) {
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
@@ -24,6 +24,7 @@ interface LinkSurveyWrapperProps {
IS_FORMBRICKS_CLOUD: boolean;
publicDomain: string;
isBrandingEnabled: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export const LinkSurveyWrapper = ({
@@ -41,6 +42,7 @@ export const LinkSurveyWrapper = ({
IS_FORMBRICKS_CLOUD,
publicDomain,
isBrandingEnabled,
dir = "auto",
}: LinkSurveyWrapperProps) => {
//for embedded survey strip away all surrounding css
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
@@ -80,11 +82,11 @@ export const LinkSurveyWrapper = ({
onBackgroundLoaded={handleBackgroundLoaded}>
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} dir={dir} />
)}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
Survey Preview 👀
<ResetProgressButton onClick={handleResetSurvey} />
@@ -10,6 +10,7 @@ import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
@@ -116,6 +117,11 @@ export const SurveyClientWrapper = ({
}
setResponseData({});
};
// Determine text direction based on language code for logo positioning only
// which checks both language code and survey content. This is only for logo UI positioning.
const logoDir = useMemo(() => {
return isRTLLanguage(survey, languageCode) ? "rtl" : "auto";
}, [languageCode, survey]);
return (
<>
@@ -140,7 +146,8 @@ export const SurveyClientWrapper = ({
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
isBrandingEnabled={project.linkSurveyBranding}
dir={logoDir}>
<SurveyInline
appUrl={publicDomain}
environmentId={survey.environmentId}
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, MailIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Toaster, toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -10,11 +10,13 @@ import { z } from "zod";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { isSurveyResponsePresentAction, sendLinkSurveyEmailAction } from "@/modules/survey/link/actions";
import { getWebAppLocale } from "@/modules/survey/link/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
@@ -26,7 +28,7 @@ interface VerifyEmailProps {
singleUseId?: string;
languageCode: string;
styling: TProjectStyling;
locale: string;
locale: TUserLocale;
}
const ZVerifyEmailInput = z.object({
@@ -42,7 +44,18 @@ export const VerifyEmail = ({
styling,
locale,
}: VerifyEmailProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Set i18n language based on survey language
useEffect(() => {
const webAppLocale = getWebAppLocale(languageCode, survey);
if (i18n.language !== webAppLocale) {
i18n.changeLanguage(webAppLocale).catch(() => {
// If changeLanguage fails, fallback to default locale
i18n.changeLanguage("en-US");
});
}
}, [languageCode, survey, i18n]);
const form = useForm<TVerifyEmailInput>({
defaultValues: {
email: "",
@@ -175,7 +188,7 @@ export const VerifyEmail = ({
{!emailSent && showPreviewQuestions && (
<div>
<p className="text-2xl font-bold">{t("s.question_preview")}</p>
<div className="mt-4 flex max-h-[50vh] w-full flex-col overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 bg-opacity-20 p-4 text-slate-700">
<div className="bg-opacity-20 mt-4 flex max-h-[50vh] w-full flex-col overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4 text-slate-700">
{questions.map((question, index) => (
<p
key={index}
@@ -0,0 +1,188 @@
import { describe, expect, test } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
({
id: "survey-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test",
type: "link",
environmentId: "env-1",
createdBy: null,
status: "draft",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
timeToFinish: false,
showResponseCount: false,
},
questions: [],
blocks: [],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
styling: null,
segment: null,
languages,
displayPercentage: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
singleUse: null,
pin: null,
projectOverwrites: null,
surveyClosedMessage: null,
followUps: [],
delay: 0,
autoComplete: null,
showLanguageSwitch: null,
recaptcha: null,
isBackButtonHidden: false,
isCaptureIpEnabled: false,
slug: null,
metadata: {},
}) as TSurvey;
describe("getWebAppLocale", () => {
test("maps language codes and handles defaults", () => {
expect(getWebAppLocale("en", createMockSurvey())).toBe("en-US");
expect(getWebAppLocale("de", createMockSurvey())).toBe("de-DE");
const surveyWithLang = createMockSurvey([
{
language: {
id: "l1",
code: "de",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
]);
expect(getWebAppLocale("default", surveyWithLang)).toBe("de-DE");
expect(getWebAppLocale("xx", createMockSurvey())).toBe("en-US");
});
test("returns en-US when default requested but no default language", () => {
const surveyNoDefault = createMockSurvey([
{
language: { id: "l1", code: "de", alias: null, createdAt: new Date(), updatedAt: new Date(), projectId: "p1" },
default: false,
enabled: true,
},
]);
expect(getWebAppLocale("default", surveyNoDefault)).toBe("en-US");
});
test("matches base language code for variants", () => {
expect(getWebAppLocale("pt-PT", createMockSurvey())).toBe("pt-PT");
expect(getWebAppLocale("es-MX", createMockSurvey())).toBe("es-ES");
});
});
describe("isRTL", () => {
test("detects RTL characters", () => {
expect(isRTL("مرحبا")).toBe(true);
expect(isRTL("שלום")).toBe(true);
expect(isRTL("Hello")).toBe(false);
});
});
describe("isRTLLanguage", () => {
const createJsSurvey = (
languages: TJsEnvironmentStateSurvey["languages"] = [],
blocks: TSurveyBlock[] = []
): TJsEnvironmentStateSurvey =>
({
id: "s1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test",
type: "link",
environmentId: "env-1",
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
timeToFinish: false,
showResponseCount: false,
},
blocks,
languages,
}) as unknown as TJsEnvironmentStateSurvey;
test("checks language codes when multi-language enabled", () => {
const survey = createJsSurvey([
{
language: {
id: "l1",
code: "ar",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
]);
expect(isRTLLanguage(survey, "ar")).toBe(true);
expect(isRTLLanguage(survey, "en")).toBe(false);
});
test("checks content when no languages configured", () => {
const element = {
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "مرحبا" },
required: false,
} as unknown as TSurveyElement;
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(true);
});
test("checks welcomeCard headline when enabled and no languages", () => {
const survey = {
...createJsSurvey([], []),
welcomeCard: { enabled: true, headline: { default: "مرحبا" } },
} as unknown as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "default")).toBe(true);
});
test("returns false when no languages and no headlines found", () => {
const element = { id: "q1", type: TSurveyElementTypeEnum.OpenText, headline: {}, required: false };
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(false);
});
});
describe("getElementsFromSurveyBlocks", () => {
test("extracts elements from blocks", () => {
const el1 = {
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
} as unknown as TSurveyElement;
const el2 = {
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
} as unknown as TSurveyElement;
const block = { id: "b1", name: "Block", elements: [el1, el2] } as TSurveyBlock;
const result = getElementsFromSurveyBlocks([block]);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("q1");
});
});
+109 -2
View File
@@ -1,2 +1,109 @@
// Prefilling logic has been moved to @/modules/survey/link/lib/prefill
// This file is kept for any future utility functions
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
export function isRTL(text: string): boolean {
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlCharRegex.test(text);
}
/**
* List of RTL language codes
*/
const RTL_LANGUAGES = ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "he", "fa", "ur"];
/**
* Returns true if the language code represents an RTL language.
* @param survey The survey to test
* @param languageCode The language code to test (e.g., "ar", "ar-SA", "he")
*/
export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: string): boolean {
if (survey.languages.length === 0) {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
}
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
} else {
const code =
languageCode === "default"
? survey.languages.find((language) => language.default)?.language.code
: languageCode;
const baseCode = code?.split("-")[0].toLowerCase() ?? "en";
return RTL_LANGUAGES.some((rtl) => rtl.toLowerCase().startsWith(baseCode));
}
}
/**
* Derives a flat array of elements from the survey's blocks structure.
* @param blocks The blocks array
* @returns An array of TSurveyElement (pure elements without block-level properties)
*/
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
/**
* Maps survey language codes to web app locale codes.
* Falls back to "en-US" if the language is not available in web app locales.
*/
export const getWebAppLocale = (languageCode: string, survey: TSurvey): string => {
// Map of common 2-letter language codes to web app locale codes
const languageToLocaleMap: Record<string, string> = {
en: "en-US",
de: "de-DE",
pt: "pt-BR", // Default to Brazilian Portuguese
"pt-BR": "pt-BR",
"pt-PT": "pt-PT",
fr: "fr-FR",
nl: "nl-NL",
zh: "zh-Hans-CN", // Default to Simplified Chinese
"zh-Hans": "zh-Hans-CN",
"zh-Hans-CN": "zh-Hans-CN",
"zh-Hant": "zh-Hant-TW",
"zh-Hant-TW": "zh-Hant-TW",
ro: "ro-RO",
ja: "ja-JP",
es: "es-ES",
sv: "sv-SE",
ru: "ru-RU",
};
let codeToMap = languageCode;
// If languageCode is "default", get the default language from survey
if (languageCode === "default") {
const defaultLanguage = survey.languages?.find((lang) => lang.default);
if (defaultLanguage) {
codeToMap = defaultLanguage.language.code;
} else {
return "en-US";
}
}
// Check if it's already a web app locale code
if (languageToLocaleMap[codeToMap]) {
return languageToLocaleMap[codeToMap];
}
// Try to find a match by base language code (e.g., "pt-BR" -> "pt")
const baseCode = codeToMap.split("-")[0].toLowerCase();
if (languageToLocaleMap[baseCode]) {
return languageToLocaleMap[baseCode];
}
// Fallback to English if language is not supported
return "en-US";
};
@@ -13,6 +13,7 @@ interface ClientLogoProps {
projectLogo: Project["logo"] | null;
surveyLogo?: TLogo | null;
previewSurvey?: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export const ClientLogo = ({
@@ -20,13 +21,23 @@ export const ClientLogo = ({
projectLogo,
surveyLogo,
previewSurvey = false,
dir = "auto",
}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
let positionClasses = "";
if (!previewSurvey) {
if (dir === "rtl") {
positionClasses = "top-3 right-3 md:top-7 md:right-7";
} else {
positionClasses = "top-3 left-3 md:top-7 md:left-7";
}
}
return (
<div
className={cn(previewSurvey ? "" : "top-3 left-3 md:top-7 md:left-7", "group absolute z-0 rounded-lg")}
className={cn(positionClasses, "group absolute z-0 rounded-lg")}
style={{ backgroundColor: logoToUse?.bgColor }}>
{previewSurvey && environmentId && (
<Link
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -101,7 +101,7 @@
"lucide-react": "0.507.0",
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
"next": "16.1.1",
"next": "16.1.3",
"next-auth": "4.24.12",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",
@@ -9,7 +9,7 @@ icon: "map-pin"
src="https://app.formbricks.com/s/m8w91e8wi52pdao8un1f4twu"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -9,7 +9,7 @@ icon: "check"
src="https://app.formbricks.com/s/orxp15pca6x2nfr3v8pttpwm"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -9,7 +9,7 @@ icon: "address-book"
src="https://app.formbricks.com/s/z2zjoonfeythx5n6z5qijbsg"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -9,7 +9,7 @@ icon: "calendar"
src="https://app.formbricks.com/s/rk844spc8ffls25vzkxzzhse"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -15,7 +15,7 @@ icon: "upload"
src="https://app.formbricks.com/s/oo4e6vva48w0trn01ht8krwo"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -12,7 +12,7 @@ Free text questions allow respondents to enter a custom answer. Displays a title
src="https://app.formbricks.com/s/cm2b2eftv000012b0l3htbu0a"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -11,7 +11,7 @@ The values range from 0 to a user-defined maximum (e.g., 0 to X). The selection
src="https://app.formbricks.com/s/obqeey0574jig4lo2gqyv51e"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -10,7 +10,7 @@ icon: "presentation-screen"
src="https://app.formbricks.com/s/vqmpasmnt5qcpsa4enheips0"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -9,7 +9,7 @@ icon: "ranking-star"
src="https://app.formbricks.com/s/z6s84x9wbyk0yqqtfaz238px"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -11,7 +11,7 @@ Rating questions allow respondents to rate questions on a scale. Displays a titl
src="https://app.formbricks.com/s/cx7u4n6hwvc3nztuk4vdezl9"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -38,8 +38,35 @@ Select the icon to be used for the rating scale. The options include: stars, num
### Range
Select the range of the rating scale. the options include: 3, 4, 5, 7 or 10. The default is 5.
Select the range of the rating scale. the options include: 3, 4, 5, 6, 7 or 10. The default is 5.
### Labels
Add labels for the lower and upper bounds of the rating scale. The default is "Not good" and "Very good".
## CSAT Summary
After collecting responses, rating questions display a CSAT (Customer Satisfaction) score with a visual traffic light indicator to help you quickly assess satisfaction levels:
- 🟢 **Green** (> 80%): High satisfaction - your users are very satisfied
- 🟠 **Orange** (55-80%): Moderate satisfaction - there's room for improvement
- 🔴 **Red** (< 55%): Low satisfaction - immediate attention needed
<Note>The traffic light indicator appears automatically in the survey summary view, giving you instant feedback on user satisfaction without needing to dig into the data.</Note>
### How CSAT is Calculated
The CSAT percentage represents the proportion of respondents who gave a "satisfied" rating. What counts as "satisfied" depends on your selected range:
| Range | Satisfied Ratings | Examples |
|-------|------------------|----------|
| 3 | Highest rating only | ⭐⭐⭐ |
| 4 | Top 2 ratings | ⭐⭐⭐ or ⭐⭐⭐⭐ |
| 5 | Top 2 ratings | ⭐⭐⭐⭐ or ⭐⭐⭐⭐⭐ |
| 6 | Top 2 ratings | 5 or 6 |
| 7 | Top 2 ratings | 6 or 7 |
| 10 | Top 3 ratings | 8, 9, or 10 |
<Note>
**Pro Tip:** For most use cases, a 5-point scale with star or smiley icons provides the best balance between granularity and user experience. Users find it easy to understand and quick to complete.
</Note>
@@ -9,7 +9,7 @@ icon: "calendar-check"
src="https://app.formbricks.com/s/hx08x27c2aghywh57rroe6fi"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -12,7 +12,7 @@ Multi select questions allow respondents to select several answers from a list.
src="https://app.formbricks.com/s/jhyo6lwzf6eh3fyplhlp7h5f"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -17,7 +17,7 @@ Picture selection questions allow respondents to select one or more images from
src="https://app.formbricks.com/s/xtgmwxlk7jxxr4oi6ym7odki"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -12,7 +12,7 @@ Single select questions allow respondents to select one answer from a list. Disp
src="https://app.formbricks.com/s/wybd3v3cxpdfve4472fu3lhi"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -11,7 +11,7 @@ It consists of a title (can be Question or Short Note) and a description, which
src="https://app.formbricks.com/s/k3p7r7riyy504u4zziqat8zj"
style={{
position: "relative",
height: "90vh",
height: "600px",
maxHeight: "100vh",
width: "100%",
border: 0,
@@ -14,7 +14,7 @@ Recontact options are the last layer of the logic that determines if a survey is
3. **Recontact Options:** Should the survey be shown (again) to this user? That's dependent on:
- Did the user see any survey recently (meaning, has Global Waiting Time passed)?
- Did the user see any survey recently (meaning, has Survey Cooldown passed)?
- Did the user see this specific survey already?
@@ -50,13 +50,13 @@ Available Recontact Options include:
![Choose Recontanct Options for the Survey](/images/xm-and-surveys/surveys/website-app-surveys/recontact/survey-recontact.webp)
## Project-wide Global Waiting Time
## Project-wide Survey Cooldown
The Global Waiting Time is a universal blocker to make sure that no user sees too many surveys. This is particularly helpful when several teams of large organisations use Formbricks at the same time.
The Survey Cooldown is a universal blocker to make sure that no user sees too many surveys. This is particularly helpful when several teams of large organisations use Formbricks at the same time.
<Note>The default Global Waiting Time is set to 7 days.</Note>
<Note>The default Survey Cooldown is set to 7 days.</Note>
To adjust the Global Waiting Time:
To adjust the Survey Cooldown:
1. Visit Formbricks Settings
@@ -68,9 +68,9 @@ To adjust the Global Waiting Time:
![Formbricks Project-Wide Wait Time](/images/xm-and-surveys/surveys/website-app-surveys/recontact/global-wait-time.webp)
## Overriding Global Waiting Time for a Specific Survey
## Overriding Survey Cooldown for a Specific Survey
For specific surveys, you may need to override the default waiting time. Below is how you can do that:
For specific surveys, you may need to override the default cooldown. Below is how you can do that:
1. In the Survey Editor, access the Settings Tab.
@@ -80,11 +80,11 @@ For specific surveys, you may need to override the default waiting time. Below i
4. Set a custom recontact period:
- **Always Show Survey**: Displays the survey whenever triggered, ignoring the waiting time.
- **Always Show Survey**: Displays the survey whenever triggered, ignoring the cooldown.
- **Wait `X` days before showing this survey again**: Sets a specific interval before the survey can be shown again.
![Ignore Global Waiting Time for a Specific Survey](/images/xm-and-surveys/surveys/website-app-surveys/recontact/ignore-wait-time.webp)
![Ignore Survey Cooldown for a Specific Survey](/images/xm-and-surveys/surveys/website-app-surveys/recontact/ignore-wait-time.webp)
---
+1 -1
View File
@@ -46,7 +46,7 @@
"dependencies": {
"react": "19.2.3",
"react-dom": "19.2.3",
"next": "16.1.1"
"next": "16.1.3"
},
"devDependencies": {
"@azure/identity": "4.13.0",
@@ -0,0 +1,32 @@
-- DropIndex
DROP INDEX "public"."Membership_userId_idx";
-- DropIndex
DROP INDEX "public"."Project_organizationId_idx";
-- DropIndex
DROP INDEX "public"."Response_surveyId_idx";
-- DropIndex
DROP INDEX "public"."Segment_environmentId_idx";
-- DropIndex
DROP INDEX "public"."SurveyAttributeFilter_surveyId_idx";
-- DropIndex
DROP INDEX "public"."SurveyLanguage_languageId_idx";
-- DropIndex
DROP INDEX "public"."SurveyQuota_surveyId_idx";
-- DropIndex
DROP INDEX "public"."SurveyTrigger_surveyId_idx";
-- DropIndex
DROP INDEX "public"."Tag_environmentId_idx";
-- DropIndex
DROP INDEX "public"."TagsOnResponses_responseId_idx";
-- DropIndex
DROP INDEX "public"."User_email_idx";
-12
View File
@@ -173,7 +173,6 @@ model Response {
@@index([createdAt])
@@index([surveyId, createdAt]) // to determine monthly response count
@@index([contactId, createdAt]) // to determine monthly identified users (persons)
@@index([surveyId])
}
/// Represents a label that can be applied to survey responses.
@@ -193,7 +192,6 @@ model Tag {
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
@@unique([environmentId, name])
@@index([environmentId])
}
/// Junction table linking tags to responses.
@@ -208,7 +206,6 @@ model TagsOnResponses {
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([responseId, tagId])
@@index([responseId])
}
enum SurveyStatus {
@@ -259,7 +256,6 @@ model SurveyTrigger {
actionClassId String
@@unique([surveyId, actionClassId])
@@index([surveyId])
}
enum SurveyAttributeFilterCondition {
@@ -297,7 +293,6 @@ model SurveyAttributeFilter {
value String
@@unique([surveyId, attributeKeyId])
@@index([surveyId])
@@index([attributeKeyId])
}
@@ -432,7 +427,6 @@ model SurveyQuota {
countPartialSubmissions Boolean @default(false)
@@unique([surveyId, name])
@@index([surveyId])
}
/// Junction table linking responses to quotas.
@@ -635,7 +629,6 @@ model Project {
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
@@unique([organizationId, name])
@@index([organizationId])
}
/// Represents the top-level organizational hierarchy in Formbricks.
@@ -690,7 +683,6 @@ model Membership {
role OrganizationRole @default(member)
@@id([userId, organizationId])
@@index([userId])
@@index([organizationId])
}
@@ -858,8 +850,6 @@ model User {
teamUsers TeamUser[]
lastLoginAt DateTime?
isActive Boolean @default(true)
@@index([email])
}
/// Defines a segment of contacts based on attributes.
@@ -884,7 +874,6 @@ model Segment {
surveys Survey[]
@@unique([environmentId, title])
@@index([environmentId])
}
/// Represents a supported language in the system.
@@ -924,7 +913,6 @@ model SurveyLanguage {
@@id([languageId, surveyId])
@@index([surveyId])
@@index([languageId])
}
/// Represents a team within an organization.
@@ -304,7 +304,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3", optionLabelClassName)}
className={cn("mx-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -336,7 +336,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("mx-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>
@@ -385,7 +385,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3", optionLabelClassName)}
className={cn("mx-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -60,26 +60,6 @@ interface RankingItemProps {
dir?: TextDirection;
}
function getTopButtonRadiusClass(isFirst: boolean, dir?: TextDirection): string {
if (isFirst) {
return "cursor-not-allowed opacity-30";
}
if (dir === "rtl") {
return "rounded-tl-md";
}
return "rounded-tr-md";
}
function getBottomButtonRadiusClass(isLast: boolean, dir?: TextDirection): string {
if (isLast) {
return "cursor-not-allowed opacity-30";
}
if (dir === "rtl") {
return "rounded-bl-md";
}
return "rounded-br-md";
}
function RankingItem({
item,
rankedIds,
@@ -94,21 +74,11 @@ function RankingItem({
const isLast = isRanked && rankIndex === rankedIds.length - 1;
const displayNumber = isRanked ? rankIndex + 1 : undefined;
// RTL-aware padding class
const paddingClass = dir === "rtl" ? "pr-3" : "pl-3";
// RTL-aware border class for control buttons
const borderClass = dir === "rtl" ? "border-r" : "border-l";
// RTL-aware border radius classes for control buttons
const topButtonRadiusClass = getTopButtonRadiusClass(isFirst, dir);
const bottomButtonRadiusClass = getBottomButtonRadiusClass(isLast, dir);
return (
<div
dir={dir}
className={cn(
"rounded-option flex h-12 cursor-pointer items-center border transition-all",
paddingClass,
"rounded-option flex h-12 cursor-pointer items-center border px-3 transition-all",
"bg-option-bg border-option-border",
"hover:bg-option-hover-bg focus-within:border-brand focus-within:bg-option-selected-bg focus-within:shadow-sm",
isRanked && "bg-option-selected-bg border-brand",
@@ -138,16 +108,14 @@ function RankingItem({
)}>
{displayNumber}
</span>
<span
className="font-option text-option font-option-weight text-option-label shrink grow text-start"
dir={dir}>
<span className="font-option text-option font-option-weight text-option-label shrink grow text-start">
{item.label}
</span>
</button>
{/* Up/Down buttons for ranked items */}
{isRanked ? (
<div className={cn("border-option-border flex h-full grow-0 flex-col", borderClass)}>
<div className={cn("border-option-border -mx-3 flex h-full grow-0 flex-col")} dir={dir}>
<button
type="button"
tabIndex={isFirst ? -1 : 0}
@@ -157,10 +125,7 @@ function RankingItem({
}}
disabled={isFirst || disabled}
aria-label={`Move ${item.label} up`}
className={cn(
"flex flex-1 items-center justify-center px-2 transition-colors",
topButtonRadiusClass
)}>
className={cn("flex flex-1 items-center justify-center px-2 transition-colors")}>
<ChevronUp className="h-5 w-5" />
</button>
<button
@@ -173,8 +138,7 @@ function RankingItem({
disabled={isLast || disabled}
aria-label={`Move ${item.label} down`}
className={cn(
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors",
bottomButtonRadiusClass
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors"
)}>
<ChevronDown className="h-5 w-5" />
</button>
@@ -261,7 +225,6 @@ function Ranking({
{/* Ranking Options */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Ranking options</legend>
<div className="space-y-2" ref={parent as React.Ref<HTMLDivElement>}>
@@ -244,6 +244,7 @@ function SingleSelect({
return (
<label
key={option.id}
dir={dir}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
@@ -254,7 +255,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("mx-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -265,6 +266,7 @@ function SingleSelect({
{hasOtherOption && otherOptionId ? (
<label
htmlFor={`${inputId}-${otherOptionId}`}
dir={dir}
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
@@ -304,6 +306,7 @@ function SingleSelect({
<label
key={option.id}
htmlFor={optionId}
dir={dir}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
@@ -313,7 +316,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("mx-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -188,7 +188,6 @@ function Calendar({
...classNames,
}}
components={{
// @ts-expect-error - React types version mismatch - the project uses React 19 types, but some Radix UI packages (react-day-picker) bundle their own older React types, creating incompatible Ref type definitions
Root: CalendarRoot,
Chevron: CalendarChevron,
DayButton: CalendarDayButton,
@@ -9,7 +9,6 @@ export interface ProgressProps extends Omit<React.ComponentProps<"div">, "childr
function Progress({ className, value, ...props }: Readonly<ProgressProps>): React.JSX.Element {
const progressValue: number = typeof value === "number" ? value : 0;
return (
// @ts-expect-error - React types version mismatch - the project uses React 19 types, but some Radix UI packages (@radix-ui/react-progress) bundle their own older React types, creating incompatible Ref type definitions
<ProgressPrimitive.Root
data-slot="progress"
value={progressValue}
+1 -1
View File
@@ -44,7 +44,7 @@
"i18next": "25.5.2",
"i18next-icu": "2.4.0",
"isomorphic-dompurify": "2.33.0",
"preact": "10.26.10",
"preact": "10.28.2",
"react-i18next": "15.7.3"
},
"devDependencies": {
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { DateElement as SurveyUIDateElement } from "@formbricks/survey-ui";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyDateElement } from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
@@ -16,6 +17,8 @@ interface DateElementProps {
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentElementId: string;
surveyLanguages: TSurveyLanguage[];
dir?: "ltr" | "rtl" | "auto";
}
export function DateElement({
@@ -26,6 +29,8 @@ export function DateElement({
ttc,
setTtc,
currentElementId,
surveyLanguages,
dir = "auto",
}: Readonly<DateElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
@@ -78,7 +83,12 @@ export function DateElement({
required={element.required}
requiredLabel={t("common.required")}
errorMessage={errorMessage}
locale={languageCode}
locale={
languageCode === "default"
? surveyLanguages.find((language) => language.default)?.language.code
: languageCode
}
dir={dir}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
@@ -15,6 +15,7 @@ interface MatrixElementProps {
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
}
export function MatrixElement({
@@ -25,6 +26,7 @@ export function MatrixElement({
ttc,
setTtc,
currentElementId,
dir = "auto",
}: Readonly<MatrixElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
@@ -142,6 +144,7 @@ export function MatrixElement({
return (
<form key={element.id} onSubmit={handleSubmit} className="w-full">
<Matrix
dir={dir}
elementId={element.id}
inputId={element.id}
headline={getLocalizedValue(element.headline, languageCode)}
@@ -16,6 +16,7 @@ interface RankingElementProps {
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
}
export function RankingElement({
@@ -26,6 +27,7 @@ export function RankingElement({
ttc,
setTtc,
currentElementId,
dir = "auto",
}: Readonly<RankingElementProps>) {
const { t } = useTranslation();
const [startTime, setStartTime] = useState(performance.now());
@@ -128,6 +130,7 @@ export function RankingElement({
return (
<form onSubmit={handleSubmit} className="w-full">
<Ranking
dir={dir}
elementId={element.id}
inputId={element.id}
headline={getLocalizedValue(element.headline, languageCode)}
@@ -9,6 +9,7 @@ import {
type TSurveyMatrixElement,
type TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ElementConditional } from "@/components/general/element-conditional";
@@ -36,6 +37,7 @@ interface BlockConditionalProps {
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
fullSizeCards: boolean;
surveyLanguages: TSurveyLanguage[];
}
export function BlockConditional({
@@ -58,7 +60,8 @@ export function BlockConditional({
onOpenExternalURL,
dir,
fullSizeCards,
}: BlockConditionalProps) {
surveyLanguages,
}: Readonly<BlockConditionalProps>) {
// Track the current element being filled (for TTC tracking)
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
@@ -290,6 +293,7 @@ export function BlockConditional({
return (
<div key={element.id}>
<ElementConditional
surveyLanguages={surveyLanguages}
element={element}
value={value[element.id]}
onChange={(responseData) => handleElementChange(element.id, responseData)}
@@ -4,6 +4,7 @@ import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "
import { type TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { AddressElement } from "@/components/elements/address-element";
import { CalElement } from "@/components/elements/cal-element";
import { ConsentElement } from "@/components/elements/consent-element";
@@ -36,6 +37,7 @@ interface ElementConditionalProps {
dir?: "ltr" | "rtl" | "auto";
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
surveyLanguages: TSurveyLanguage[];
}
export function ElementConditional({
@@ -53,7 +55,8 @@ export function ElementConditional({
dir,
formRef,
onTtcCollect,
}: ElementConditionalProps) {
surveyLanguages,
}: Readonly<ElementConditionalProps>) {
// Ref to the container div, used to find and expose the form element inside
const containerRef = useRef<HTMLDivElement>(null);
@@ -224,6 +227,8 @@ export function ElementConditional({
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
surveyLanguages={surveyLanguages}
dir={dir}
/>
);
case TSurveyElementTypeEnum.PictureSelection:
@@ -280,6 +285,7 @@ export function ElementConditional({
ttc={ttc}
setTtc={wrappedSetTtc}
currentElementId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.Address:
@@ -299,6 +305,7 @@ export function ElementConditional({
case TSurveyElementTypeEnum.Ranking:
return (
<RankingElement
dir={dir}
element={element}
value={Array.isArray(value) ? getResponseValueForRankingElement(value, element.choices) : []}
onChange={onChange}
@@ -8,7 +8,7 @@ import { mixColor } from "@/lib/color";
import { getI18nLanguage } from "@/lib/i18n-utils";
import i18n from "@/lib/i18n.config";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { checkIfSurveyIsRTL, cn } from "@/lib/utils";
import { cn, isRTLLanguage } from "@/lib/utils";
interface LanguageSwitchProps {
survey: TJsEnvironmentStateSurvey;
@@ -59,7 +59,7 @@ export function LanguageSwitch({
handleI18nLanguage(calculatedLanguageCode);
if (setDir) {
const calculateDir = checkIfSurveyIsRTL(survey, calculatedLanguageCode) ? "rtl" : "auto";
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
setDir?.(calculateDir);
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { checkIfSurveyIsRTL } from "@/lib/utils";
import { isRTLLanguage } from "@/lib/utils";
import { SurveyContainer } from "../wrappers/survey-container";
import { Survey } from "./survey";
@@ -8,12 +8,11 @@ export function RenderSurvey(props: SurveyContainerProps) {
const [isOpen, setIsOpen] = useState(true);
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode);
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
useEffect(() => {
const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode);
const isRTL = isRTLLanguage(props.survey, props.languageCode);
setDir(isRTL ? "rtl" : "auto");
}, [props.languageCode, props.survey]);
@@ -801,6 +801,7 @@ export function Survey({
return (
Boolean(block) && (
<BlockConditional
surveyLanguages={localSurvey.languages}
key={block.id}
surveyId={localSurvey.id}
block={{
@@ -30,7 +30,7 @@ interface WelcomeCardProps {
function TimerIcon() {
return (
<div className="mr-1">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -177,18 +177,16 @@ export function WelcomeCard({
</div>
{timeToFinish && !showResponseCount ? (
<div
className="text-subheading my-4 flex items-center"
className="text-subheading my-4 flex items-center space-x-1"
data-testid="fb__surveys__welcome-card__time-display">
<TimerIcon />
<p className="pt-1 text-xs">
<span>
{calculateTimeToComplete()}{" "}
</span>
<span>{calculateTimeToComplete()} </span>
</p>
</div>
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="text-subheading my-4 flex items-center">
<div className="text-subheading my-4 flex items-center space-x-1">
<UsersIcon />
<p className="pt-1 text-xs">
<span data-testid="fb__surveys__welcome-card__response-count">
@@ -198,12 +196,10 @@ export function WelcomeCard({
</div>
) : null}
{timeToFinish && showResponseCount ? (
<div className="text-subheading my-4 flex items-center">
<div className="text-subheading my-4 flex items-center space-x-1">
<TimerIcon />
<p className="pt-1 text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
<span>
{calculateTimeToComplete()}{" "}
</span>
<span>{calculateTimeToComplete()} </span>
<span data-testid="fb__surveys__welcome-card__response-count">
{responseCount && responseCount > 3
? `${t("common.people_responded", { count: responseCount })}`
@@ -96,7 +96,9 @@ export const StackedCard = ({
return (
<div
ref={(el) => (cardRefs.current[dynamicQuestionIndex] = el)}
ref={(el) => {
cardRefs.current[dynamicQuestionIndex] = el;
}}
id={`questionCard-${dynamicQuestionIndex}`}
data-testid={`questionCard-${dynamicQuestionIndex}`}
key={dynamicQuestionIndex}
+82
View File
@@ -10,6 +10,8 @@ import {
getMimeType,
getShuffledChoicesIds,
getShuffledRowIndices,
isRTL,
isRTLLanguage,
} from "./utils";
// Mock crypto.getRandomValues for deterministic shuffle tests
@@ -327,3 +329,83 @@ describe("findBlockByElementId", () => {
expect(block).toBeUndefined();
});
});
describe("isRTL", () => {
test("returns true for RTL text", () => {
expect(isRTL("مرحبا")).toBe(true);
expect(isRTL("שלום")).toBe(true);
});
test("returns false for LTR text", () => {
expect(isRTL("Hello")).toBe(false);
expect(isRTL("")).toBe(false);
});
});
describe("isRTLLanguage", () => {
test("returns true for RTL language codes when multi-language enabled", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [
{
language: {
id: "l1",
code: "ar",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "ar")).toBe(true);
expect(isRTLLanguage(survey, "he")).toBe(true);
});
test("returns false for LTR language codes", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [
{
language: {
id: "l1",
code: "en",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "en")).toBe(false);
});
test("checks survey content when no languages configured", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "مرحبا" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "default")).toBe(true);
});
});
+32 -15
View File
@@ -222,26 +222,43 @@ export function isRTL(text: string): boolean {
return rtlCharRegex.test(text);
}
export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCode: string): boolean => {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
/**
* List of RTL language codes
*/
const RTL_LANGUAGES = ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "he", "fa", "ur"];
/**
* Returns true if the language code represents an RTL language.
* @param languageCode The language code to test (e.g., "ar", "ar-SA", "he")
*/
export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: string): boolean {
if (survey.languages.length === 0) {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
}
}
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
} else {
const code =
languageCode === "default"
? survey.languages.find((language) => language.default)?.language.code
: languageCode;
const baseCode = code?.split("-")[0].toLowerCase() ?? "en";
return RTL_LANGUAGES.some((rtl) => rtl.toLowerCase().startsWith(baseCode));
}
return false;
};
}
/**
* Derives a flat array of elements from the survey's blocks structure.
+2 -1
View File
@@ -1,11 +1,12 @@
import { z } from "zod";
import { ZUserLocale } from "./user";
export const ZLinkSurveyEmailData = z.object({
surveyId: z.string(),
email: z.string(),
suId: z.string().optional(),
surveyName: z.string(),
locale: z.string(),
locale: ZUserLocale,
logoUrl: z.string().optional(),
});
+70 -70
View File
@@ -21,8 +21,8 @@ importers:
.:
dependencies:
next:
specifier: 16.1.1
version: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: 16.1.3
version: 16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
@@ -279,7 +279,7 @@ importers:
version: 1.2.6(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@sentry/nextjs':
specifier: 10.5.0
version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.11))
version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.11))
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)
@@ -368,14 +368,14 @@ importers:
specifier: 3.0.1
version: 3.0.1
next:
specifier: 16.1.1
version: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: 16.1.3
version: 16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-auth:
specifier: 4.24.12
version: 4.24.12(patch_hash=43pqaaqjvqhdw6jmcjbeq3fjse)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 4.24.12(patch_hash=43pqaaqjvqhdw6jmcjbeq3fjse)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-safe-action:
specifier: 7.10.8
version: 7.10.8(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4)
version: 7.10.8(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -982,8 +982,8 @@ importers:
specifier: 2.33.0
version: 2.33.0
preact:
specifier: 10.26.10
version: 10.26.10
specifier: 10.28.2
version: 10.28.2
react-i18next:
specifier: 15.7.3
version: 15.7.3(i18next@25.5.2(typescript@5.8.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.8.3)
@@ -1002,13 +1002,13 @@ importers:
version: link:../types
'@preact/preset-vite':
specifier: 2.10.1
version: 2.10.1(@babel/core@7.28.5)(preact@10.26.10)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
version: 2.10.1(@babel/core@7.28.5)(preact@10.28.2)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
'@tailwindcss/postcss':
specifier: 4.1.17
version: 4.1.17
'@testing-library/preact':
specifier: 3.2.4
version: 3.2.4(preact@10.26.10)
version: 3.2.4(preact@10.28.2)
'@types/react':
specifier: 19.1.4
version: 19.1.4
@@ -2789,8 +2789,8 @@ packages:
'@next/env@16.0.9':
resolution: {integrity: sha512-6284pl8c8n9PQidN63qjPVEu1uXXKjnmbmaLebOzIfTrSXdGiAPsIMRi4pk/+v/ezqweE1/B8bFqiAAfC6lMXg==}
'@next/env@16.1.1':
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
'@next/env@16.1.3':
resolution: {integrity: sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA==}
'@next/eslint-plugin-next@15.3.2':
resolution: {integrity: sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==}
@@ -2801,8 +2801,8 @@ packages:
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-arm64@16.1.1':
resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==}
'@next/swc-darwin-arm64@16.1.3':
resolution: {integrity: sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@@ -2813,8 +2813,8 @@ packages:
cpu: [x64]
os: [darwin]
'@next/swc-darwin-x64@16.1.1':
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
'@next/swc-darwin-x64@16.1.3':
resolution: {integrity: sha512-aF4us2JXh0zn3hNxvL1Bx3BOuh8Lcw3p3Xnurlvca/iptrDH1BrpObwkw9WZra7L7/0qB9kjlREq3hN/4x4x+Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@@ -2825,8 +2825,8 @@ packages:
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-gnu@16.1.1':
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
'@next/swc-linux-arm64-gnu@16.1.3':
resolution: {integrity: sha512-8VRkcpcfBtYvhGgXAF7U3MBx6+G1lACM1XCo1JyaUr4KmAkTNP8Dv2wdMq7BI+jqRBw3zQE7c57+lmp7jCFfKA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -2837,8 +2837,8 @@ packages:
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
'@next/swc-linux-arm64-musl@16.1.3':
resolution: {integrity: sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -2849,8 +2849,8 @@ packages:
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
'@next/swc-linux-x64-gnu@16.1.3':
resolution: {integrity: sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -2861,8 +2861,8 @@ packages:
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
'@next/swc-linux-x64-musl@16.1.3':
resolution: {integrity: sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -2873,8 +2873,8 @@ packages:
cpu: [arm64]
os: [win32]
'@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
'@next/swc-win32-arm64-msvc@16.1.3':
resolution: {integrity: sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@@ -2885,8 +2885,8 @@ packages:
cpu: [x64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.1':
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
'@next/swc-win32-x64-msvc@16.1.3':
resolution: {integrity: sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -8703,8 +8703,8 @@ packages:
sass:
optional: true
next@16.1.1:
resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==}
next@16.1.3:
resolution: {integrity: sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -9190,12 +9190,12 @@ packages:
peerDependencies:
preact: '>=10'
preact@10.26.10:
resolution: {integrity: sha512-sqdfdSa8AZeJ+wfMYjFImIRTnhfyPSLCH+LEb1+BoRUDKLnE6AnvZeClx3Bkj2Q9nn44GFAefOKIx5oc54q93A==}
preact@10.26.6:
resolution: {integrity: sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==}
preact@10.28.2:
resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -13791,7 +13791,7 @@ snapshots:
'@next/env@16.0.9': {}
'@next/env@16.1.1': {}
'@next/env@16.1.3': {}
'@next/eslint-plugin-next@15.3.2':
dependencies:
@@ -13800,49 +13800,49 @@ snapshots:
'@next/swc-darwin-arm64@16.0.9':
optional: true
'@next/swc-darwin-arm64@16.1.1':
'@next/swc-darwin-arm64@16.1.3':
optional: true
'@next/swc-darwin-x64@16.0.9':
optional: true
'@next/swc-darwin-x64@16.1.1':
'@next/swc-darwin-x64@16.1.3':
optional: true
'@next/swc-linux-arm64-gnu@16.0.9':
optional: true
'@next/swc-linux-arm64-gnu@16.1.1':
'@next/swc-linux-arm64-gnu@16.1.3':
optional: true
'@next/swc-linux-arm64-musl@16.0.9':
optional: true
'@next/swc-linux-arm64-musl@16.1.1':
'@next/swc-linux-arm64-musl@16.1.3':
optional: true
'@next/swc-linux-x64-gnu@16.0.9':
optional: true
'@next/swc-linux-x64-gnu@16.1.1':
'@next/swc-linux-x64-gnu@16.1.3':
optional: true
'@next/swc-linux-x64-musl@16.0.9':
optional: true
'@next/swc-linux-x64-musl@16.1.1':
'@next/swc-linux-x64-musl@16.1.3':
optional: true
'@next/swc-win32-arm64-msvc@16.0.9':
optional: true
'@next/swc-win32-arm64-msvc@16.1.1':
'@next/swc-win32-arm64-msvc@16.1.3':
optional: true
'@next/swc-win32-x64-msvc@16.0.9':
optional: true
'@next/swc-win32-x64-msvc@16.1.1':
'@next/swc-win32-x64-msvc@16.1.3':
optional: true
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
@@ -14312,12 +14312,12 @@ snapshots:
dependencies:
playwright: 1.56.1
'@preact/preset-vite@2.10.1(@babel/core@7.28.5)(preact@10.26.10)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))':
'@preact/preset-vite@2.10.1(@babel/core@7.28.5)(preact@10.28.2)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.5
'@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5)
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.5)
'@prefresh/vite': 2.4.11(preact@10.26.10)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
'@prefresh/vite': 2.4.11(preact@10.28.2)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
'@rollup/pluginutils': 4.2.1
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.5)
debug: 4.4.3
@@ -14332,20 +14332,20 @@ snapshots:
'@prefresh/babel-plugin@0.5.2': {}
'@prefresh/core@1.5.9(preact@10.26.10)':
'@prefresh/core@1.5.9(preact@10.28.2)':
dependencies:
preact: 10.26.10
preact: 10.28.2
'@prefresh/utils@1.2.1': {}
'@prefresh/vite@2.4.11(preact@10.26.10)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))':
'@prefresh/vite@2.4.11(preact@10.28.2)(vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.5
'@prefresh/babel-plugin': 0.5.2
'@prefresh/core': 1.5.9(preact@10.26.10)
'@prefresh/core': 1.5.9(preact@10.28.2)
'@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1
preact: 10.26.10
preact: 10.28.2
vite: 6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -15700,7 +15700,7 @@ snapshots:
'@sentry/core@10.5.0': {}
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.11))':
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.11))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.38.0
@@ -15713,7 +15713,7 @@ snapshots:
'@sentry/vercel-edge': 10.5.0
'@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.11))
chalk: 3.0.0
next: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
resolve: 1.22.8
rollup: 4.54.0
stacktrace-parser: 0.1.11
@@ -16561,10 +16561,10 @@ snapshots:
lodash: 4.17.21
redent: 3.0.0
'@testing-library/preact@3.2.4(preact@10.26.10)':
'@testing-library/preact@3.2.4(preact@10.28.2)':
dependencies:
'@testing-library/dom': 8.20.1
preact: 10.26.10
preact: 10.28.2
'@testing-library/react@16.3.0(@testing-library/dom@8.20.1)(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
@@ -20620,13 +20620,13 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.12(patch_hash=43pqaaqjvqhdw6jmcjbeq3fjse)(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next-auth@4.24.12(patch_hash=43pqaaqjvqhdw6jmcjbeq3fjse)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.26.6
@@ -20637,9 +20637,9 @@ snapshots:
optionalDependencies:
nodemailer: 7.0.11
next-safe-action@7.10.8(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4):
next-safe-action@7.10.8(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4):
dependencies:
next: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
@@ -20670,9 +20670,9 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.1
'@next/env': 16.1.3
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.11
caniuse-lite: 1.0.30001762
@@ -20681,14 +20681,14 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.1
'@next/swc-darwin-x64': 16.1.1
'@next/swc-linux-arm64-gnu': 16.1.1
'@next/swc-linux-arm64-musl': 16.1.1
'@next/swc-linux-x64-gnu': 16.1.1
'@next/swc-linux-x64-musl': 16.1.1
'@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.1.1
'@next/swc-darwin-arm64': 16.1.3
'@next/swc-darwin-x64': 16.1.3
'@next/swc-linux-arm64-gnu': 16.1.3
'@next/swc-linux-arm64-musl': 16.1.3
'@next/swc-linux-x64-gnu': 16.1.3
'@next/swc-linux-x64-musl': 16.1.3
'@next/swc-win32-arm64-msvc': 16.1.3
'@next/swc-win32-x64-msvc': 16.1.3
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.56.1
sharp: 0.34.5
@@ -21197,10 +21197,10 @@ snapshots:
preact: 10.26.6
pretty-format: 3.8.0
preact@10.26.10: {}
preact@10.26.6: {}
preact@10.28.2: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2