Compare commits

...

10 Commits

Author SHA1 Message Date
Jakob Schott
735c180eaf made minor changes to structure. Included inclusion criteria in setup flow 2025-07-04 16:30:44 +02:00
Johannes
342fe89f0c quota docs 2025-06-19 18:36:22 +02:00
Johannes
a0f334b300 chore: add rules (#6036) 2025-06-19 09:02:25 -07:00
Jakob Schott
a9f635b768 chore: Satisfy SonarQube ReadOnly props for all question types (#6021) 2025-06-19 06:10:11 +00:00
Jakob Schott
d385b4a0d6 fix: Set non-required as default value on questions (#6018) 2025-06-19 06:09:36 +00:00
Matti Nannt
5e825413d2 chore(infra): switch staging to internal lb (#6012) 2025-06-18 12:04:53 +00:00
Johannes
8c3e816ccd fix: remove Formbricks branding from Link Pages (#5989)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 16:18:25 +00:00
Anshuman Pandey
6ddc91ee85 fix: deletes local storage environment id on logout (#5957) 2025-06-16 14:01:16 +00:00
Saurav Jain
14023ca8a9 fix: keyboard accessibility issue (#3768) (#5941) 2025-06-16 15:45:52 +02:00
Dhruwang Jariwala
385e8a4262 fix: Airtable fix (#5976)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 12:37:05 +00:00
63 changed files with 1359 additions and 624 deletions

View File

@@ -0,0 +1,24 @@
---
description: Guideline for writing end-user facing documentation in the apps/docs folder
globs:
alwaysApply: false
---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
---
title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link"
---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
- In all headlines, only capitalize the current feature and nothing else, NO Camel Case
- Update the mint.json file and add the nav item at the correct position corresponding to where the MDX file is located
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>

View File

@@ -220,6 +220,9 @@ describe("MainNavigation", () => {
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
@@ -240,6 +243,9 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
// Verify localStorage.removeItem is called with the correct key
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
@@ -247,9 +253,13 @@ describe("MainNavigation", () => {
redirect: false,
callbackUrl: "/auth/login",
});
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});
// Clean up spy
removeItemSpy.mockRestore();
});
test("handles organization switching", async () => {

View File

@@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
@@ -390,6 +391,8 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",

View File

@@ -14,41 +14,64 @@ describe("ClientEnvironmentRedirect", () => {
cleanup();
});
test("should redirect to the provided environment ID when no last environment exists", () => {
test("should redirect to the first environment ID when no last environment exists", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
render(<ClientEnvironmentRedirect userEnvironments={["test-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
});
test("should redirect to the last environment ID when it exists in localStorage", () => {
test("should redirect to the last environment ID when it exists in localStorage and is valid", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with a last environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("last-env-id"),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
render(<ClientEnvironmentRedirect userEnvironments={["last-env-id", "other-env-id"]} />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
});
test("should clear invalid environment ID and redirect to default when stored ID is not in user environments", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with an invalid environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("invalid-env-id"),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect userEnvironments={["valid-env-1", "valid-env-2"]} />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(localStorageMock.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/valid-env-1");
});
test("should update redirect when environment ID prop changes", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
@@ -56,19 +79,20 @@ describe("ClientEnvironmentRedirect", () => {
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
const { rerender } = render(<ClientEnvironmentRedirect userEnvironments={["initial-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
// Clear mock calls
mockPush.mockClear();
// Rerender with new environment ID
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
rerender(<ClientEnvironmentRedirect userEnvironments={["new-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
});
});

View File

@@ -5,22 +5,23 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
interface ClientEnvironmentRedirectProps {
environmentId: string;
userEnvironments: string[];
}
const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => {
const ClientEnvironmentRedirect = ({ userEnvironments }: ClientEnvironmentRedirectProps) => {
const router = useRouter();
useEffect(() => {
const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS);
if (lastEnvironmentId) {
// Redirect to the last environment the user was in
if (lastEnvironmentId && userEnvironments.includes(lastEnvironmentId)) {
router.push(`/environments/${lastEnvironmentId}`);
} else {
router.push(`/environments/${environmentId}`);
// If the last environmentId is not valid, remove it from localStorage and redirect to the provided environmentId
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
router.push(`/environments/${userEnvironments[0]}`);
}
}, [environmentId, router]);
}, [userEnvironments, router]);
return null;
};

View File

@@ -1,13 +1,13 @@
import { checkForRequiredFields } from "./utils";
import { describe, test, expect } from "vitest";
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
import { describe, expect, test } from "vitest";
import { vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { checkForRequiredFields } from "./utils";
import { checkAuth } from "./utils";
// Create mock response objects
@@ -16,189 +16,197 @@ const mockNotAuthenticatedResponse = new Response("Not authenticated", { status:
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
authenticateRequest: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
hasPermission: vi.fn(),
hasPermission: vi.fn(),
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn(() => mockBadRequestResponse),
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
},
responses: {
badRequestResponse: vi.fn(() => mockBadRequestResponse),
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
},
}));
describe("checkForRequiredFields", () => {
test("should return undefined when all required fields are present", () => {
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
expect(result).toBeUndefined();
});
test("should return undefined when all required fields are present", () => {
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
expect(result).toBeUndefined();
});
test("should return bad request response when environmentId is missing", () => {
const result = checkForRequiredFields("", "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when environmentId is missing", () => {
const result = checkForRequiredFields("", "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is missing", () => {
const result = checkForRequiredFields("env-123", "", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is missing", () => {
const result = checkForRequiredFields("env-123", "", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is missing", () => {
const result = checkForRequiredFields("env-123", "image/png", "");
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is missing", () => {
const result = checkForRequiredFields("env-123", "image/png", "");
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when environmentId is undefined", () => {
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when environmentId is undefined", () => {
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is undefined", () => {
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is undefined", () => {
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is undefined", () => {
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is undefined", () => {
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
});
describe("checkAuth", () => {
const environmentId = "env-123";
const mockRequest = new NextRequest("http://localhost:3000/api/test");
const environmentId = "env-123";
const mockRequest = new NextRequest("http://localhost:3000/api/test");
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
const result = await checkAuth(null, environmentId, mockRequest);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-123",
permission: "read",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
},
};
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-123",
permission: "read",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(false);
const result = await checkAuth(null, environmentId, mockRequest);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
"POST"
);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when no session and authentication has POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-123",
permission: "write",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
},
};
test("returns undefined when no session and authentication has POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-123",
permission: "write",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(true);
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(true);
const result = await checkAuth(null, environmentId, mockRequest);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
expect(result).toBeUndefined();
});
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
"POST"
);
expect(result).toBeUndefined();
});
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
const result = await checkAuth(mockSession, environmentId, mockRequest);
const result = await checkAuth(mockSession, environmentId, mockRequest);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when session exists and user has environment access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
test("returns undefined when session exists and user has environment access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
const result = await checkAuth(mockSession, environmentId, mockRequest);
const result = await checkAuth(mockSession, environmentId, mockRequest);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(result).toBeUndefined();
});
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(result).toBeUndefined();
});
test("does not call authenticateRequest when session exists", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
test("does not call authenticateRequest when session exists", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
await checkAuth(mockSession, environmentId, mockRequest);
await checkAuth(mockSession, environmentId, mockRequest);
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
});
});
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
});
});

View File

@@ -1,38 +1,41 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { NextRequest } from "next/server";
import { Session } from "next-auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
export const checkForRequiredFields = (
environmentId: string,
fileType: string,
encodedFileName: string
): Response | undefined => {
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
export const checkForRequiredFields = (environmentId: string, fileType: string, encodedFileName: string): Response | undefined => {
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
};
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
if (!session) {
//check whether its using API key
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
if (!session) {
//check whether its using API key
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
};
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
}
};

View File

@@ -1,6 +1,7 @@
// headers -> "Content-Type" should be present and set to a valid MIME type
// body -> should be a valid file object (buffer)
// method -> PUT (to be the same as the signedUrl method)
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
@@ -10,7 +11,6 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) {

View File

@@ -1,3 +1,4 @@
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { validateFile } from "@/lib/fileValidation";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -5,8 +6,6 @@ import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
// api endpoint for uploading public files
// uploaded files will be public, anyone can access the file
@@ -14,7 +13,6 @@ import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/stora
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = async (request: NextRequest): Promise<Response> => {
let storageInput;
@@ -34,7 +32,6 @@ export const POST = async (request: NextRequest): Promise<Response> => {
const authResponse = await checkAuth(session, environmentId, request);
if (authResponse) return authResponse;
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {

View File

@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: true,
required: false,
});
expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined();
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
charLimit: {
enabled: false,
},
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
});
expect(question.id).toBeDefined();
});
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
headline: { default: "CTA Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
buttonExternal: false,
});
expect(question.id).toBeDefined();

View File

@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
shuffleOption: shuffleOption || "none",
required: required ?? true,
required: required ?? false,
logic,
};
};
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
longAnswer,
logic,
charLimit: {
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
range,
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
label: createI18nString(label, []),
logic,
};
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? true,
required: required ?? false,
buttonExternal,
buttonUrl,
logic,

View File

@@ -3006,12 +3006,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
t("templates.understand_low_engagement_question_1_choice_4"),
t("templates.understand_low_engagement_question_1_choice_5"),
],
choiceIds: [
reusableOptionIds[0],
reusableOptionIds[1],
reusableOptionIds[2],
reusableOptionIds[3],
],
choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2], reusableOptionIds[3]],
headline: t("templates.understand_low_engagement_question_1_headline"),
required: true,
containsOther: true,

View File

@@ -3,12 +3,12 @@ import { cleanup } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
// Mock dependencies
vi.mock("@/lib/environment/service", () => ({
getFirstEnvironmentIdByUserId: vi.fn(),
vi.mock("@/lib/project/service", () => ({
getProjectEnvironmentsByOrganizationIds: vi.fn(),
}));
vi.mock("@/lib/instance/service", () => ({
@@ -48,8 +48,11 @@ vi.mock("@/modules/ui/components/client-logout", () => ({
}));
vi.mock("@/app/ClientEnvironmentRedirect", () => ({
default: ({ environmentId }: { environmentId: string }) => (
<div data-testid="client-environment-redirect">Environment ID: {environmentId}</div>
default: ({ environmentId, userEnvironments }: { environmentId: string; userEnvironments?: string[] }) => (
<div data-testid="client-environment-redirect">
Environment ID: {environmentId}
{userEnvironments && ` | User Environments: ${userEnvironments.join(", ")}`}
</div>
),
}));
@@ -149,7 +152,7 @@ describe("Page", () => {
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation");
@@ -204,13 +207,23 @@ describe("Page", () => {
role: "owner",
};
const mockUserProjects = [
{
id: "test-project-id",
name: "Test Project",
environments: [],
},
];
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
@@ -228,8 +241,8 @@ describe("Page", () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation");
@@ -284,13 +297,23 @@ describe("Page", () => {
role: "member",
};
const mockUserProjects = [
{
id: "test-project-id",
name: "Test Project",
environments: [],
},
];
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
@@ -309,9 +332,9 @@ describe("Page", () => {
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { render } = await import("@testing-library/react");
const mockUser: TUser = {
@@ -364,7 +387,43 @@ describe("Page", () => {
role: "member",
};
const mockEnvironmentId = "test-env-id";
const mockUserProjects = [
{
id: "project-1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "test-org-id",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: "link" as const, industry: "saas" as const },
placement: "bottomRight" as const,
clickOutsideClose: false,
darkOverlay: false,
languages: [],
logo: null,
environments: [
{
id: "test-env-id",
type: "production" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
appSetupCompleted: true,
},
{
id: "test-env-dev",
type: "development" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
appSetupCompleted: true,
},
],
},
] as any;
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
@@ -372,8 +431,8 @@ describe("Page", () => {
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
isOwner: false,
@@ -385,7 +444,7 @@ describe("Page", () => {
const { container } = render(result);
expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent(
`Environment ID: ${mockEnvironmentId}`
`User Environments: test-env-id, test-env-dev`
);
});
});

View File

@@ -1,9 +1,9 @@
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
import { getFirstEnvironmentIdByUserId } from "@/lib/environment/service";
import { getIsFreshInstance } from "@/lib/instance/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -34,16 +34,34 @@ const Page = async () => {
return redirect("/setup/organization/create");
}
let environmentId: string | null = null;
environmentId = await getFirstEnvironmentIdByUserId(session.user.id);
const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id));
// Flatten all environments from all projects across all organizations
const allEnvironments = projectsByOrg.flatMap((project) => project.environments);
// Find first production environment and collect all other environment IDs in one pass
const { firstProductionEnvironmentId, otherEnvironmentIds } = allEnvironments.reduce(
(acc, env) => {
if (env.type === "production" && !acc.firstProductionEnvironmentId) {
acc.firstProductionEnvironmentId = env.id;
} else {
acc.otherEnvironmentIds.add(env.id);
}
return acc;
},
{ firstProductionEnvironmentId: null as string | null, otherEnvironmentIds: new Set<string>() }
);
const userEnvironments = [...otherEnvironmentIds];
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session.user.id,
userOrganizations[0].id
);
const { isManager, isOwner } = getAccessFlags(currentUserMembership?.role);
if (!environmentId) {
if (!firstProductionEnvironmentId) {
if (isOwner || isManager) {
return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`);
} else {
@@ -51,7 +69,10 @@ const Page = async () => {
}
}
return <ClientEnvironmentRedirect environmentId={environmentId} />;
// Put the first production environment at the front of the array
const sortedUserEnvironments = [firstProductionEnvironmentId, ...userEnvironments];
return <ClientEnvironmentRedirect userEnvironments={sortedUserEnvironments} />;
};
export default Page;

View File

@@ -1,3 +1,5 @@
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
export default LinkSurveyNotFound;
export default function NotFound() {
return <LinkSurveyNotFound />;
}

View File

@@ -13,7 +13,8 @@ import {
ZIntegrationAirtableTokenSchema,
} from "@formbricks/types/integration/airtable";
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service";
import { delay } from "../utils/promises";
import { truncateText } from "../utils/strings";
export const getBases = async (key: string) => {
@@ -99,7 +100,11 @@ export const getAirtableToken = async (environmentId: string) => {
});
if (!newToken) {
throw new Error("Failed to create new token");
logger.error("Failed to fetch new Airtable token", {
environmentId,
airtableIntegration,
});
throw new Error("Failed to fetch new Airtable token");
}
await createOrUpdateIntegration(environmentId, {
@@ -116,9 +121,11 @@ export const getAirtableToken = async (environmentId: string) => {
return access_token;
} catch (error) {
await deleteIntegration(environmentId);
throw new Error("invalid token");
logger.error("Failed to get Airtable token", {
environmentId,
error,
});
throw new Error("Failed to get Airtable token");
}
};
@@ -178,6 +185,18 @@ const addField = async (
return await req.json();
};
const getExistingFields = async (key: TIntegrationAirtableCredential, baseId: string, tableId: string) => {
const req = await tableFetcher(key, baseId);
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
const currentTable = tables.find((t) => t.id === tableId);
if (!currentTable) {
throw new Error(`Table with ID ${tableId} not found`);
}
return new Set(currentTable.fields.map((f) => f.name));
};
export const writeData = async (
key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData,
@@ -186,6 +205,7 @@ export const writeData = async (
const responses = values[0];
const questions = values[1];
// 1) Build the record payload
const data: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
data[questions[i]] =
@@ -194,34 +214,73 @@ export const writeData = async (
: responses[i];
}
const req = await tableFetcher(key, configData.baseId);
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
// 2) Figure out which fields need creating
const existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
const fieldsToCreate = questions.filter((q) => !existingFields.has(q));
const currentTable = tables.find((table) => table.id === configData.tableId);
if (currentTable) {
const currentFields = new Set(currentTable.fields.map((field) => field.name));
const fieldsToCreate = new Set<string>();
for (const field of questions) {
const hasField = currentFields.has(field);
if (!hasField) {
fieldsToCreate.add(field);
// 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit
if (fieldsToCreate.length > 0) {
// Sequential processing with delays
const DELAY_BETWEEN_REQUESTS = 250; // 250ms = 4 requests per second (staying under 5/sec limit)
for (let i = 0; i < fieldsToCreate.length; i++) {
const fieldName = fieldsToCreate[i];
const createRes = await addField(key, configData.baseId, configData.tableId, {
name: fieldName,
type: "singleLineText",
});
if (createRes?.error) {
throw new Error(`Failed to create field "${fieldName}": ${JSON.stringify(createRes)}`);
}
// Add delay between requests (except for the last one)
if (i < fieldsToCreate.length - 1) {
await delay(DELAY_BETWEEN_REQUESTS);
}
}
if (fieldsToCreate.size > 0) {
const createFieldPromise: Promise<any>[] = [];
fieldsToCreate.forEach((fieldName) => {
createFieldPromise.push(
addField(key, configData.baseId, configData.tableId, {
name: fieldName,
type: "singleLineText",
})
);
});
// 4) Wait for the new fields to show up
await waitForFieldsToExist(key, configData, fieldsToCreate);
}
await Promise.all(createFieldPromise);
// 5) Finally, add the records
await addRecords(key, configData.baseId, configData.tableId, data);
};
async function waitForFieldsToExist(
key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData,
fieldNames: string[],
maxRetries = 5,
intervalMs = 2000
) {
let existingFields: Set<string> = new Set(),
missingFields: string[] = [];
for (let attempt = 1; attempt <= maxRetries; attempt++) {
existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
missingFields = fieldNames.filter((f) => !existingFields.has(f));
if (missingFields.length === 0) {
return;
}
if (attempt < maxRetries) {
logger.error(
`Attempt ${attempt}/${maxRetries}: ${missingFields.length} field(s) still missing [${missingFields.join(
", "
)}], retrying in ${intervalMs / 1000}s…`
);
await new Promise((r) => setTimeout(r, intervalMs));
}
}
await addRecords(key, configData.baseId, configData.tableId, data);
};
throw new Error(
`Timed out waiting for ${missingFields.length} field(s) [${missingFields.join(
", "
)}] to become available. Available fields: [${Array.from(existingFields).join(", ")}]`
);
}

View File

@@ -1,10 +1,16 @@
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { OrganizationRole, Prisma, WidgetPlacement } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { getProject, getProjectByEnvironmentId, getProjects, getUserProjects } from "./service";
import {
getProject,
getProjectByEnvironmentId,
getProjectEnvironmentsByOrganizationIds,
getProjects,
getUserProjects,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -35,13 +41,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
};
vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
@@ -86,13 +99,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
};
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
@@ -144,13 +164,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
{
id: createId(),
@@ -162,23 +189,29 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "admin",
createdAt: new Date(),
updatedAt: new Date(),
role: OrganizationRole.owner,
accepted: true,
deprecatedRole: null,
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
@@ -210,23 +243,29 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
role: OrganizationRole.member,
accepted: true,
deprecatedRole: null,
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
@@ -278,23 +317,29 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "admin",
createdAt: new Date(),
updatedAt: new Date(),
role: OrganizationRole.owner,
accepted: true,
deprecatedRole: null,
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
@@ -326,13 +371,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
{
id: createId(),
@@ -344,13 +396,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
@@ -382,13 +441,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
@@ -418,4 +484,68 @@ describe("Project Service", () => {
await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError);
});
test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const mockProjects = [
{
environments: [],
},
{
environments: [],
},
];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: {
in: [organizationId1, organizationId2],
},
},
select: { environments: true },
});
});
test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
expect(result).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: {
in: [organizationId1, organizationId2],
},
},
select: { environments: true },
});
});
test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow(
DatabaseError
);
});
test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => {
await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError);
});
});

View File

@@ -170,3 +170,31 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
throw error;
}
});
export const getProjectEnvironmentsByOrganizationIds = reactCache(
async (organizationIds: string[]): Promise<Pick<TProject, "environments">[]> => {
validateInputs([organizationIds, ZId.array()]);
try {
if (organizationIds.length === 0) {
return [];
}
const projects = await prisma.project.findMany({
where: {
organizationId: {
in: organizationIds,
},
},
select: { environments: true },
});
return projects;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(err.message);
}
throw err;
}
}
);

View File

@@ -34,7 +34,8 @@
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
}
},
"reset_password": "Passwort zurücksetzen"
"reset_password": "Passwort zurücksetzen",
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
},
"invite": {
"create_account": "Konto erstellen",
@@ -79,7 +80,6 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Benutzer erfolgreich erstellt",
"user_successfully_created_description": "Dein neuer Benutzer wurde erfolgreich erstellt. Bitte klicke auf den untenstehenden Button und melde Dich in deinem Konto an.",
"user_successfully_created_info": "Wir haben nach einem Konto gesucht, das mit {email} verknüpft ist. Wenn keines existierte, haben wir eines für Dich erstellt. Wenn bereits ein Konto existierte, wurden keine Änderungen vorgenommen. Bitte melde Dich unten an, um fortzufahren."
},
"testimonial_1": "Als open-source Firma ist uns Datenschutz extrem wichtig! Formbricks bietet die perfekte Mischung aus modernster Technologie und solidem Datenschutz.",
@@ -96,7 +96,6 @@
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
"verification_email_resent_successfully": "Bestätigungs-E-Mail gesendet! Bitte überprüfe dein Postfach.",
"verification_email_successfully_sent_info": "Wenn ein Konto mit {email} verknüpft ist, haben wir einen Bestätigungslink an diese Adresse gesendet. Bitte überprüfe dein Postfach, um die Anmeldung abzuschließen.",
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
},
"verify": {
@@ -136,7 +135,6 @@
"app_survey": "App-Umfrage",
"apply_filters": "Filter anwenden",
"are_you_sure": "Bist Du sicher?",
"are_you_sure_this_action_cannot_be_undone": "Bist Du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
"attributes": "Attribute",
"avatar": "Avatar",
"back": "Zurück",
@@ -193,7 +191,6 @@
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"email": "E-Mail",
"embed": "Einbetten",
"enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -311,7 +308,6 @@
"project_not_found": "Projekt nicht gefunden",
"project_permission_not_found": "Projekt-Berechtigung nicht gefunden",
"projects": "Projekte",
"projects_limit_reached": "Projektlimit erreicht",
"question": "Frage",
"question_id": "Frage-ID",
"questions": "Fragen",
@@ -319,6 +315,7 @@
"remove": "Entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
@@ -414,7 +411,6 @@
"website_survey": "Website-Umfrage",
"weekly_summary": "Wöchentliche Zusammenfassung",
"welcome_card": "Willkommenskarte",
"yes": "Ja",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorised_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion auszuführen.",
@@ -599,6 +595,7 @@
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"first_name": "Vorname",
"last_name": "Nachname",
"no_responses_found": "Keine Antworten gefunden",
@@ -635,6 +632,7 @@
"airtable_integration": "Airtable Integration",
"airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert",
"airtable_logo": "Airtable-Logo",
"connect_with_airtable": "Mit Airtable verbinden",
"link_airtable_table": "Airtable Tabelle verknüpfen",
"link_new_table": "Neue Tabelle verknüpfen",
@@ -702,7 +700,6 @@
"select_a_database": "Datenbank auswählen",
"select_a_field_to_map": "Wähle ein Feld zum Zuordnen aus",
"select_a_survey_question": "Wähle eine Umfragefrage aus",
"sync_responses_with_a_notion_database": "Antworten mit einer Datenbank in Notion synchronisieren",
"update_connection": "Notion erneut verbinden",
"update_connection_tooltip": "Verbinde die Integration erneut, um neu hinzugefügte Datenbanken einzuschließen. Deine bestehenden Integrationen bleiben erhalten."
},
@@ -724,6 +721,7 @@
"slack_integration": "Slack Integration",
"slack_integration_description": "Sende Antworten direkt an Slack.",
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.",
"slack_logo": "Slack-Logo",
"slack_reconnect_button": "Erneut verbinden",
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
},
@@ -908,8 +906,7 @@
"tag_already_exists": "Tag existiert bereits",
"tag_deleted": "Tag gelöscht",
"tag_updated": "Tag aktualisiert",
"tags_merged": "Tags zusammengeführt",
"unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen"
"tags_merged": "Tags zusammengeführt"
},
"teams": {
"manage_teams": "Teams verwalten",
@@ -982,63 +979,53 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"10000_monthly_responses": "10,000 monatliche Antworten",
"1500_monthly_responses": "1,500 monatliche Antworten",
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer",
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
"1000_monthly_responses": "1,000 monatliche Antworten",
"1_project": "1 Projekt",
"2000_contacts": "2,000 Kontakte",
"3_projects": "3 Projekte",
"5000_monthly_responses": "5,000 monatliche Antworten",
"5_projects": "5 Projekte",
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
"advanced_targeting": "Erweitertes Targeting",
"7500_contacts": "7,500 Kontakte",
"all_integrations": "Alle Integrationen",
"all_surveying_features": "Alle Umfragefunktionen",
"annually": "Jährlich",
"api_webhooks": "API & Webhooks",
"app_surveys": "In-app Umfragen",
"contact_us": "Kontaktiere uns",
"attribute_based_targeting": "Attributbasiertes Targeting",
"current": "aktuell",
"current_plan": "Aktueller Plan",
"current_tier_limit": "Aktuelles Limit",
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit",
"custom": "Benutzerdefiniert & Skalierung",
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
"custom_project_limit": "Benutzerdefiniertes Projektlimit",
"customer_success_manager": "Customer Success Manager",
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
"email_support": "E-Mail-Support",
"enterprise": "Enterprise",
"email_follow_ups": "E-Mail Follow-ups",
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
"everything_in_free": "Alles in 'Free''",
"everything_in_scale": "Alles in 'Scale''",
"everything_in_startup": "Alles in 'Startup''",
"free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis",
"get_in_touch": "Kontaktiere uns",
"hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)",
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
"manage_card_details": "Karteninformationen verwalten",
"manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"multi_language_surveys": "Mehrsprachige Umfragen",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"priority_support": "Priorisierter Support",
"remove_branding": "Branding entfernen",
"say_hi": "Sag Hi!",
"scale": "Scale",
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder",
"technical_onboarding": "Technische Einführung",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
"unlimited_miu": "Unbegrenzte MIU",
"unlimited_projects": "Unbegrenzte Projekte",
"unlimited_responses": "Unbegrenzte Antworten",
@@ -1077,6 +1064,7 @@
"create_new_organization": "Neue Organisation erstellen",
"create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.",
"customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan",
"delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.",
"delete_organization": "Organisation löschen",
"delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen",
"delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
@@ -1233,8 +1221,9 @@
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
@@ -1307,7 +1296,6 @@
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
"card_border_color": "Farbe des Kartenrandes",
"card_shadow_color": "Farbton des Kartenschattens",
"card_styling": "Kartenstil",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
@@ -1332,7 +1320,6 @@
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"change_the_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.",
"changes_saved": "Änderungen gespeichert.",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
@@ -1789,6 +1776,7 @@
"setup_instructions": "Einrichtung",
"setup_integrations": "Integrationen einrichten",
"share_results": "Ergebnisse teilen",
"share_survey": "Umfrage teilen",
"share_the_link": "Teile den Link",
"share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",

View File

@@ -34,7 +34,8 @@
"text": "You can now log in with your new password"
}
},
"reset_password": "Reset password"
"reset_password": "Reset password",
"reset_password_description": "You will be logged out to reset your password."
},
"invite": {
"create_account": "Create an account",
@@ -79,7 +80,6 @@
},
"signup_without_verification_success": {
"user_successfully_created": "User successfully created",
"user_successfully_created_description": "Your new user has been created successfully. Please click the button below and sign in to your account.",
"user_successfully_created_info": "Weve checked for an account associated with {email}. If none existed, weve created one for you. If an account already existed, no changes were made. Please log in below to continue."
},
"testimonial_1": "We measure the clarity of our docs and learn from churn all on one platform. Great product, very responsive team!",
@@ -96,7 +96,6 @@
"resend_verification_email": "Resend verification email",
"verification_email_resent_successfully": "Verification email sent! Please check your inbox.",
"verification_email_successfully_sent_info": "If theres an account associated with {email}, weve sent a verification link to that address. Please check your inbox to complete the sign-up.",
"we_sent_an_email_to": "We sent an email to {email}. ",
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
},
"verify": {
@@ -136,7 +135,6 @@
"app_survey": "App Survey",
"apply_filters": "Apply filters",
"are_you_sure": "Are you sure?",
"are_you_sure_this_action_cannot_be_undone": "Are you sure? This action cannot be undone.",
"attributes": "Attributes",
"avatar": "Avatar",
"back": "Back",
@@ -193,7 +191,6 @@
"e_commerce": "E-Commerce",
"edit": "Edit",
"email": "Email",
"embed": "Embed",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -311,7 +308,6 @@
"project_not_found": "Project not found",
"project_permission_not_found": "Project permission not found",
"projects": "Projects",
"projects_limit_reached": "Projects limit reached",
"question": "Question",
"question_id": "Question ID",
"questions": "Questions",
@@ -319,6 +315,7 @@
"remove": "Remove",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"response": "Response",
@@ -414,7 +411,6 @@
"website_survey": "Website Survey",
"weekly_summary": "Weekly summary",
"welcome_card": "Welcome card",
"yes": "Yes",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorised_to_perform_this_action": "You are not authorised to perform this action.",
@@ -599,6 +595,7 @@
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"first_name": "First Name",
"last_name": "Last Name",
"no_responses_found": "No responses found",
@@ -635,6 +632,7 @@
"airtable_integration": "Airtable Integration",
"airtable_integration_description": "Sync responses directly with Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration is not configured",
"airtable_logo": "Airtable logo",
"connect_with_airtable": "Connect with Airtable",
"link_airtable_table": "Link Airtable Table",
"link_new_table": "Link new table",
@@ -702,7 +700,6 @@
"select_a_database": "Select Database",
"select_a_field_to_map": "Select a field to map",
"select_a_survey_question": "Select a survey question",
"sync_responses_with_a_notion_database": "Sync responses with a Notion Database",
"update_connection": "Reconnect Notion",
"update_connection_tooltip": "Reconnect the integration to include newly added databases. Your existing integrations will remain intact."
},
@@ -724,6 +721,7 @@
"slack_integration": "Slack Integration",
"slack_integration_description": "Send responses directly to Slack.",
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.",
"slack_logo": "Slack logo",
"slack_reconnect_button": "Reconnect",
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
},
@@ -908,8 +906,7 @@
"tag_already_exists": "Tag already exists",
"tag_deleted": "Tag deleted",
"tag_updated": "Tag updated",
"tags_merged": "Tags merged",
"unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields"
"tags_merged": "Tags merged"
},
"teams": {
"manage_teams": "Manage teams",
@@ -982,63 +979,53 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"10000_monthly_responses": "10000 Monthly Responses",
"1500_monthly_responses": "1500 Monthly Responses",
"2000_monthly_identified_users": "2000 Monthly Identified Users",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"1000_monthly_responses": "Monthly 1,000 Responses",
"1_project": "1 Project",
"2000_contacts": "2,000 Contacts",
"3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses",
"5_projects": "5 Projects",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
"7500_contacts": "7,500 Contacts",
"all_integrations": "All Integrations",
"all_surveying_features": "All surveying features",
"annually": "Annually",
"api_webhooks": "API & Webhooks",
"app_surveys": "App Surveys",
"contact_us": "Contact Us",
"attribute_based_targeting": "Attribute-based Targeting",
"current": "Current",
"current_plan": "Current Plan",
"current_tier_limit": "Current Tier Limit",
"custom_miu_limit": "Custom MIU limit",
"custom": "Custom & Scale",
"custom_contacts_limit": "Custom Contacts Limit",
"custom_project_limit": "Custom Project Limit",
"customer_success_manager": "Customer Success Manager",
"custom_response_limit": "Custom Response Limit",
"email_embedded_surveys": "Email Embedded Surveys",
"email_support": "Email Support",
"enterprise": "Enterprise",
"email_follow_ups": "Email Follow-ups",
"enterprise_description": "Premium support and custom limits.",
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
"everything_in_free": "Everything in Free",
"everything_in_scale": "Everything in Scale",
"everything_in_startup": "Everything in Startup",
"free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free",
"get_in_touch": "Get in touch",
"hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)",
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
"manage_card_details": "Manage Card Details",
"manage_subscription": "Manage Subscription",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"multi_language_surveys": "Multi-Language Surveys",
"per_month": "per month",
"per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"priority_support": "Priority Support",
"remove_branding": "Remove Branding",
"say_hi": "Say Hi!",
"scale": "Scale",
"scale_description": "Advanced features for scaling your business.",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles",
"technical_onboarding": "Technical Onboarding",
"unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_apps_websites": "Unlimited Apps & Websites",
"unlimited_miu": "Unlimited MIU",
"unlimited_projects": "Unlimited Projects",
"unlimited_responses": "Unlimited Responses",
@@ -1077,6 +1064,7 @@
"create_new_organization": "Create new organization",
"create_new_organization_description": "Create a new organization to handle a different set of projects.",
"customize_email_with_a_higher_plan": "Customize email with a higher plan",
"delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.",
"delete_organization": "Delete Organization",
"delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes",
"delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:",
@@ -1233,8 +1221,9 @@
"copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully!",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
@@ -1307,7 +1296,6 @@
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color",
"card_border_color": "Card border color",
"card_shadow_color": "Card shadow color",
"card_styling": "Card Styling",
"casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit",
@@ -1332,7 +1320,6 @@
"change_the_brand_color_of_the_survey": "Change the brand color of the survey.",
"change_the_placement_of_this_survey": "Change the placement of this survey.",
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
"change_the_shadow_color_of_the_card": "Change the shadow color of the card.",
"changes_saved": "Changes saved.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
@@ -1789,6 +1776,7 @@
"setup_instructions": "Setup instructions",
"setup_integrations": "Setup integrations",
"share_results": "Share results",
"share_survey": "Share survey",
"share_the_link": "Share the link",
"share_the_link_to_get_responses": "Share the link to get responses",
"show_all_responses_that_match": "Show all responses that match",
@@ -1894,12 +1882,12 @@
},
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you don't see the email in your inbox.",
"completed": "This free & open-source survey has been closed.",
"create_your_own": "Create your own",
"completed": "This survey is closed.",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This free & open-source survey is temporarily paused.",
"paused": "This survey is temporarily paused.",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",

View File

@@ -34,7 +34,8 @@
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
}
},
"reset_password": "Réinitialiser le mot de passe"
"reset_password": "Réinitialiser le mot de passe",
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
},
"invite": {
"create_account": "Créer un compte",
@@ -79,7 +80,6 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Utilisateur créé avec succès",
"user_successfully_created_description": "Votre nouvel utilisateur a été créé avec succès. Veuillez cliquer sur le bouton ci-dessous et vous connecter à votre compte.",
"user_successfully_created_info": "Nous avons vérifié s'il existait un compte associé à {email}. Si aucun n'existait, nous en avons créé un pour vous. Si un compte existait déjà, aucune modification n'a été apportée. Veuillez vous connecter ci-dessous pour continuer."
},
"testimonial_1": "Nous mesurons la clarté de nos documents et apprenons des abandons, le tout sur une seule plateforme. Excellent produit, équipe très réactive !",
@@ -96,7 +96,6 @@
"resend_verification_email": "Renvoyer l'email de vérification",
"verification_email_resent_successfully": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
"verification_email_successfully_sent_info": "Si un compte est associé à {email}, nous avons envoyé un lien de vérification à cette adresse. Veuillez vérifier votre boîte de réception pour terminer l'inscription.",
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
},
"verify": {
@@ -136,7 +135,6 @@
"app_survey": "Sondage d'application",
"apply_filters": "Appliquer des filtres",
"are_you_sure": "Es-tu sûr ?",
"are_you_sure_this_action_cannot_be_undone": "Êtes-vous sûr ? Cette action ne peut pas être annulée.",
"attributes": "Attributs",
"avatar": "Avatar",
"back": "Retour",
@@ -193,7 +191,6 @@
"e_commerce": "E-commerce",
"edit": "Modifier",
"email": "Email",
"embed": "Intégrer",
"enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -311,7 +308,6 @@
"project_not_found": "Projet non trouvé",
"project_permission_not_found": "Autorisation de projet non trouvée",
"projects": "Projets",
"projects_limit_reached": "Limite de projets atteinte",
"question": "Question",
"question_id": "ID de la question",
"questions": "Questions",
@@ -319,6 +315,7 @@
"remove": "Retirer",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Demander la tarification",
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"response": "Réponse",
@@ -414,7 +411,6 @@
"website_survey": "Sondage de site web",
"weekly_summary": "Résumé hebdomadaire",
"welcome_card": "Carte de bienvenue",
"yes": "Oui",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorised_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
@@ -599,6 +595,7 @@
"contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"first_name": "Prénom",
"last_name": "Nom de famille",
"no_responses_found": "Aucune réponse trouvée",
@@ -635,6 +632,7 @@
"airtable_integration": "Intégration Airtable",
"airtable_integration_description": "Synchronisez les réponses directement avec Airtable.",
"airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée",
"airtable_logo": "Logo Airtable",
"connect_with_airtable": "Se connecter à Airtable",
"link_airtable_table": "Lier la table Airtable",
"link_new_table": "Lier nouvelle table",
@@ -702,7 +700,6 @@
"select_a_database": "Sélectionner la base de données",
"select_a_field_to_map": "Sélectionnez un champ à mapper",
"select_a_survey_question": "Sélectionnez une question d'enquête",
"sync_responses_with_a_notion_database": "Synchroniser les réponses avec une base de données Notion",
"update_connection": "Reconnecter Notion",
"update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes."
},
@@ -724,6 +721,7 @@
"slack_integration": "Intégration Slack",
"slack_integration_description": "Envoyez les réponses directement sur Slack.",
"slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.",
"slack_logo": "logo Slack",
"slack_reconnect_button": "Reconnecter",
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
},
@@ -908,8 +906,7 @@
"tag_already_exists": "Le tag existe déjà",
"tag_deleted": "Tag supprimé",
"tag_updated": "Étiquette mise à jour",
"tags_merged": "Étiquettes fusionnées",
"unique_constraint_failed_on_the_fields": "Échec de la contrainte unique sur les champs"
"tags_merged": "Étiquettes fusionnées"
},
"teams": {
"manage_teams": "Gérer les équipes",
@@ -982,63 +979,53 @@
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Réponses Mensuelles",
"1500_monthly_responses": "1500 Réponses Mensuelles",
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"1000_monthly_responses": "1000 Réponses Mensuelles",
"1_project": "1 Projet",
"2000_contacts": "2 000 Contacts",
"3_projects": "3 Projets",
"5000_monthly_responses": "5,000 Réponses Mensuelles",
"5_projects": "5 Projets",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
"7500_contacts": "7 500 Contacts",
"all_integrations": "Toutes les intégrations",
"all_surveying_features": "Tous les outils d'arpentage",
"annually": "Annuellement",
"api_webhooks": "API et Webhooks",
"app_surveys": "Sondages d'application",
"contact_us": "Contactez-nous",
"attribute_based_targeting": "Ciblage basé sur les attributs",
"current": "Actuel",
"current_plan": "Plan actuel",
"current_tier_limit": "Limite de niveau actuel",
"custom_miu_limit": "Limite MIU personnalisé",
"custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Limite de contacts personnalisé",
"custom_project_limit": "Limite de projet personnalisé",
"customer_success_manager": "Responsable de la réussite client",
"custom_response_limit": "Limite de réponse personnalisé",
"email_embedded_surveys": "Sondages intégrés par e-mail",
"email_support": "Support par e-mail",
"enterprise": "Entreprise",
"email_follow_ups": "Relances par e-mail",
"enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Tout est gratuit",
"everything_in_scale": "Tout à l'échelle",
"everything_in_startup": "Tout dans le Startup",
"free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits",
"get_in_touch": "Prenez contact",
"hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"link_surveys": "Sondages par lien (partageables)",
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte",
"manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs Identifiés Mensuels",
"multi_language_surveys": "Sondages multilingues",
"per_month": "par mois",
"per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Soutien premium avec SLA",
"priority_support": "Soutien Prioritaire",
"remove_branding": "Supprimer la marque",
"say_hi": "Dis bonjour !",
"scale": "Échelle",
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
"startup": "Startup",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Rôles d'accès d'équipe",
"technical_onboarding": "Intégration technique",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_apps_websites": "Applications et sites Web illimités",
"unlimited_miu": "MIU Illimité",
"unlimited_projects": "Projets illimités",
"unlimited_responses": "Réponses illimitées",
@@ -1077,6 +1064,7 @@
"create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
"customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
@@ -1233,8 +1221,9 @@
"copy_survey_description": "Copier cette enquête dans un autre environnement",
"copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
@@ -1307,7 +1296,6 @@
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
"card_background_color": "Couleur de fond de la carte",
"card_border_color": "Couleur de la bordure de la carte",
"card_shadow_color": "Couleur de l'ombre de la carte",
"card_styling": "Style de carte",
"casual": "Décontracté",
"caution_edit_duplicate": "Dupliquer et modifier",
@@ -1332,7 +1320,6 @@
"change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.",
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"change_the_shadow_color_of_the_card": "Changez la couleur de l'ombre de la carte.",
"changes_saved": "Modifications enregistrées.",
"character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères",
@@ -1789,6 +1776,7 @@
"setup_instructions": "Instructions d'installation",
"setup_integrations": "Configurer les intégrations",
"share_results": "Partager les résultats",
"share_survey": "Partager l'enquête",
"share_the_link": "Partager le lien",
"share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",

View File

@@ -34,7 +34,8 @@
"text": "Agora você pode fazer login com sua nova senha"
}
},
"reset_password": "Redefinir senha"
"reset_password": "Redefinir senha",
"reset_password_description": "Você será desconectado para redefinir sua senha."
},
"invite": {
"create_account": "Cria uma conta",
@@ -79,7 +80,6 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Usuário criado com sucesso",
"user_successfully_created_description": "Seu novo usuário foi criado com sucesso. Por favor, clique no botão abaixo e faça login na sua conta.",
"user_successfully_created_info": "Verificamos se há uma conta associada a {email}. Se não existia, criamos uma para você. Se uma conta já existia, nenhuma alteração foi feita. Por favor, faça login abaixo para continuar."
},
"testimonial_1": "Mediamos a clareza dos nossos documentos e aprendemos com a rotatividade tudo em uma única plataforma. Ótimo produto, equipe muito atenciosa!",
@@ -96,7 +96,6 @@
"resend_verification_email": "Reenviar e-mail de verificação",
"verification_email_resent_successfully": "E-mail de verificação enviado! Por favor, verifique sua caixa de entrada.",
"verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviamos um link de verificação para esse endereço. Por favor, verifique sua caixa de entrada para completar o cadastro.",
"we_sent_an_email_to": "Enviamos um email para {email}",
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
},
"verify": {
@@ -136,7 +135,6 @@
"app_survey": "Pesquisa de App",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Certeza?",
"are_you_sure_this_action_cannot_be_undone": "Tem certeza? Essa ação não pode ser desfeita.",
"attributes": "atributos",
"avatar": "Avatar",
"back": "Voltar",
@@ -193,7 +191,6 @@
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"email": "Email",
"embed": "incorporar",
"enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -311,7 +308,6 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"projects_limit_reached": "Limites de projetos atingidos",
"question": "Pergunta",
"question_id": "ID da Pergunta",
"questions": "Perguntas",
@@ -319,6 +315,7 @@
"remove": "remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"response": "Resposta",
@@ -359,7 +356,7 @@
"start_free_trial": "Iniciar Teste Grátis",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"styling": "estilização",
"styling": "Estilização",
"submit": "Enviar",
"summary": "Resumo",
"survey": "Pesquisa",
@@ -371,7 +368,7 @@
"survey_paused": "Pesquisa pausada.",
"survey_scheduled": "Pesquisa agendada.",
"survey_type": "Tipo de Pesquisa",
"surveys": "pesquisas",
"surveys": "Pesquisas",
"switch_organization": "Mudar organização",
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso",
@@ -414,7 +411,6 @@
"website_survey": "Pesquisa de Site",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorised_to_perform_this_action": "Você não tem autorização para fazer isso.",
@@ -599,6 +595,7 @@
"contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Sobrenome",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -635,6 +632,7 @@
"airtable_integration": "Integração com Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "Logo do Airtable",
"connect_with_airtable": "Conectar com o Airtable",
"link_airtable_table": "Vincular Tabela do Airtable",
"link_new_table": "Vincular nova tabela",
@@ -702,7 +700,6 @@
"select_a_database": "Selecionar Banco de Dados",
"select_a_field_to_map": "Selecione um campo para mapear",
"select_a_survey_question": "Escolha uma pergunta da pesquisa",
"sync_responses_with_a_notion_database": "Sincronizar respostas com um banco de dados do Notion",
"update_connection": "Reconectar Notion",
"update_connection_tooltip": "Reconecte a integração para incluir os novos bancos de dados adicionados. Suas integrações existentes permanecerão intactas."
},
@@ -724,6 +721,7 @@
"slack_integration": "Integração com o Slack",
"slack_integration_description": "Manda as respostas direto pro Slack.",
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logotipo do Slack",
"slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
},
@@ -908,8 +906,7 @@
"tag_already_exists": "Tag já existe",
"tag_deleted": "Tag apagada",
"tag_updated": "Tag atualizada",
"tags_merged": "Tags mescladas",
"unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos"
"tags_merged": "Tags mescladas"
},
"teams": {
"manage_teams": "Gerenciar Equipes",
@@ -982,63 +979,53 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"1000_monthly_responses": "1000 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2.000 Contatos",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
"7500_contacts": "7.500 Contatos",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todos os recursos de levantamento",
"annually": "anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Pesquisas de App",
"contact_us": "Fale Conosco",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual de Nível",
"custom_miu_limit": "Limite MIU personalizado",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contatos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gerente de Sucesso do Cliente",
"custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"email_follow_ups": "Acompanhamentos por Email",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
"everything_in_free": "Tudo de graça",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis",
"get_in_touch": "Entre em contato",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
"manage_card_details": "Gerenciar Detalhes do Cartão",
"manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"multi_language_surveys": "Pesquisas Multilíngues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diz oi!",
"scale": "escala",
"scale_description": "Recursos avançados pra escalar seu negócio.",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_apps_websites": "Apps e Sites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",
@@ -1077,6 +1064,7 @@
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
"delete_member_confirmation": "Membros apagados perderão acesso a todos os projetos e pesquisas da sua organização.",
"delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
@@ -1233,8 +1221,9 @@
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
"copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1307,7 +1296,6 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_shadow_color": "cor da sombra do cartão",
"card_styling": "Estilização de Cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
@@ -1332,7 +1320,6 @@
"change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.",
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"change_the_shadow_color_of_the_card": "Muda a cor da sombra do cartão.",
"changes_saved": "Mudanças salvas.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1789,6 +1776,7 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Compartilhar resultados",
"share_survey": "Compartilhar pesquisa",
"share_the_link": "Compartilha o link",
"share_the_link_to_get_responses": "Compartilha o link pra receber respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",

View File

@@ -34,7 +34,8 @@
"text": "Pode agora iniciar sessão com a sua nova palavra-passe"
}
},
"reset_password": "Redefinir palavra-passe"
"reset_password": "Redefinir palavra-passe",
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
},
"invite": {
"create_account": "Criar uma conta",
@@ -79,7 +80,6 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Utilizador criado com sucesso",
"user_successfully_created_description": "O seu novo utilizador foi criado com sucesso. Por favor, clique no botão abaixo e inicie sessão na sua conta.",
"user_successfully_created_info": "Verificámos a existência de uma conta associada a {email}. Se não existia, criámos uma para si. Se já existia uma conta, não foram feitas alterações. Por favor, inicie sessão abaixo para continuar."
},
"testimonial_1": "Medimos a clareza dos nossos documentos e aprendemos com a rotatividade, tudo numa só plataforma. Ótimo produto, equipa muito responsiva!",
@@ -96,7 +96,6 @@
"resend_verification_email": "Reenviar email de verificação",
"verification_email_resent_successfully": "Email de verificação enviado! Por favor, verifique a sua caixa de entrada.",
"verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviámos um link de verificação para esse endereço. Por favor, verifique a sua caixa de entrada para completar o registo.",
"we_sent_an_email_to": "Enviámos um email para {email}. ",
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
},
"verify": {
@@ -136,7 +135,6 @@
"app_survey": "Inquérito da Aplicação",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Tem a certeza?",
"are_you_sure_this_action_cannot_be_undone": "Tem a certeza? Esta ação não pode ser desfeita.",
"attributes": "Atributos",
"avatar": "Avatar",
"back": "Voltar",
@@ -193,7 +191,6 @@
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"email": "Email",
"embed": "Incorporar",
"enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
@@ -311,7 +308,6 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"projects_limit_reached": "Limite de projetos atingido",
"question": "Pergunta",
"question_id": "ID da pergunta",
"questions": "Perguntas",
@@ -319,6 +315,7 @@
"remove": "Remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"response": "Resposta",
@@ -414,7 +411,6 @@
"website_survey": "Inquérito do Website",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorised_to_perform_this_action": "Não está autorizado para realizar esta ação.",
@@ -599,6 +595,7 @@
"contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Apelido",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -635,6 +632,7 @@
"airtable_integration": "Integração com o Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "logotipo Airtable",
"connect_with_airtable": "Ligar ao Airtable",
"link_airtable_table": "Ligar Tabela Airtable",
"link_new_table": "Ligar nova tabela",
@@ -702,7 +700,6 @@
"select_a_database": "Selecionar Base de Dados",
"select_a_field_to_map": "Selecione um campo para mapear",
"select_a_survey_question": "Selecione uma pergunta do inquérito",
"sync_responses_with_a_notion_database": "Sincronizar respostas com uma Base de Dados do Notion",
"update_connection": "Reconectar Notion",
"update_connection_tooltip": "Restabeleça a integração para incluir as bases de dados recentemente adicionadas. As suas integrações existentes permanecerão intactas."
},
@@ -724,6 +721,7 @@
"slack_integration": "Integração com Slack",
"slack_integration_description": "Enviar respostas diretamente para o Slack.",
"slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logótipo Slack",
"slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack."
},
@@ -908,8 +906,7 @@
"tag_already_exists": "A etiqueta já existe",
"tag_deleted": "Etiqueta eliminada",
"tag_updated": "Etiqueta atualizada",
"tags_merged": "Etiquetas fundidas",
"unique_constraint_failed_on_the_fields": "A restrição de unicidade falhou nos campos"
"tags_merged": "Etiquetas fundidas"
},
"teams": {
"manage_teams": "Gerir equipas",
@@ -982,63 +979,53 @@
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"1000_monthly_responses": "1000 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2,000 Contactos",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
"7500_contacts": "7,500 Contactos",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todas as funcionalidades de inquérito",
"annually": "Anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos da Aplicação",
"contact_us": "Contacte-nos",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "Atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível",
"custom_miu_limit": "Limite MIU Personalizado",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contactos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gestor de Sucesso do Cliente",
"custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Inquéritos Incorporados no Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"email_follow_ups": "Acompanhamentos por Email",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo em Gratuito",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis",
"get_in_touch": "Entre em contacto",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Ligar Inquéritos (Partilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
"manage_card_details": "Gerir Detalhes do Cartão",
"manage_subscription": "Gerir Subscrição",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"multi_language_surveys": "Inquéritos Multilingues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diga Olá!",
"scale": "Escala",
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
"startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",
@@ -1077,6 +1064,7 @@
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior",
"delete_member_confirmation": "Membros eliminados perderão acesso a todos os projetos e inquéritos da sua organização.",
"delete_organization": "Eliminar Organização",
"delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:",
@@ -1233,8 +1221,9 @@
"copy_survey_description": "Copiar este questionário para outro ambiente",
"copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1307,7 +1296,6 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_shadow_color": "Cor da sombra do cartão",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
@@ -1332,7 +1320,6 @@
"change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito",
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"change_the_shadow_color_of_the_card": "Alterar a cor da sombra do cartão.",
"changes_saved": "Alterações guardadas.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1789,6 +1776,7 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Partilhar resultados",
"share_survey": "Partilhar inquérito",
"share_the_link": "Partilhar o link",
"share_the_link_to_get_responses": "Partilhe o link para obter respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
@@ -1894,12 +1882,12 @@
},
"s": {
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
"completed": "Este inquérito gratuito e de código aberto foi encerrado.",
"create_your_own": "Crie o seu próprio",
"completed": "Este inquérito está encerrado.",
"create_your_own": "Crie o seu próprio inquérito de código aberto",
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo",
"just_curious": "Só por curiosidade?",
"link_invalid": "Este inquérito só pode ser respondido por convite.",
"paused": "Este inquérito gratuito e de código aberto está temporariamente pausado.",
"paused": "Este inquérito está temporariamente suspenso.",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",

View File

@@ -34,7 +34,8 @@
"text": "您現在可以使用新密碼登入"
}
},
"reset_password": "重設密碼"
"reset_password": "重設密碼",
"reset_password_description": "您將被登出以重設您的密碼。"
},
"invite": {
"create_account": "建立帳戶",
@@ -79,7 +80,6 @@
},
"signup_without_verification_success": {
"user_successfully_created": "使用者建立成功",
"user_successfully_created_description": "您的新使用者已成功建立。請點擊下方按鈕並登入您的帳戶。",
"user_successfully_created_info": "我們已檢查與 {email} 相關聯的帳戶。如果不存在,我們已為您建立一個。如果帳戶已存在,則未進行任何更改。請在下方登入以繼續。"
},
"testimonial_1": "我們在同一個平台上測量文件的清晰度,並從客戶流失中學習。很棒的產品,團隊反應非常迅速!",
@@ -96,7 +96,6 @@
"resend_verification_email": "重新發送驗證電子郵件",
"verification_email_resent_successfully": "驗證電子郵件已發送!請檢查您的收件箱。",
"verification_email_successfully_sent_info": "如果有一個帳戶與 {email} 相關聯,我們已發送驗證連結至該地址。請檢查您的收件箱以完成註冊。",
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
},
"verify": {
@@ -136,7 +135,6 @@
"app_survey": "應用程式問卷",
"apply_filters": "套用篩選器",
"are_you_sure": "您確定嗎?",
"are_you_sure_this_action_cannot_be_undone": "您確定嗎?此操作無法復原。",
"attributes": "屬性",
"avatar": "頭像",
"back": "返回",
@@ -193,7 +191,6 @@
"e_commerce": "電子商務",
"edit": "編輯",
"email": "電子郵件",
"embed": "嵌入",
"enterprise_license": "企業授權",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -311,7 +308,6 @@
"project_not_found": "找不到專案",
"project_permission_not_found": "找不到專案權限",
"projects": "專案",
"projects_limit_reached": "已達到專案上限",
"question": "問題",
"question_id": "問題 ID",
"questions": "問題",
@@ -319,6 +315,7 @@
"remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
@@ -414,7 +411,6 @@
"website_survey": "網站問卷",
"weekly_summary": "每週摘要",
"welcome_card": "歡迎卡片",
"yes": "是",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorised_to_perform_this_action": "您未獲授權執行此操作。",
@@ -599,6 +595,7 @@
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字",
"last_name": "姓氏",
"no_responses_found": "找不到回應",
@@ -635,6 +632,7 @@
"airtable_integration": "Airtable 整合",
"airtable_integration_description": "直接與 Airtable 同步回應。",
"airtable_integration_is_not_configured": "尚未設定 Airtable 整合",
"airtable_logo": "Airtable 標誌",
"connect_with_airtable": "連線 Airtable",
"link_airtable_table": "連結 Airtable 表格",
"link_new_table": "連結新表格",
@@ -702,7 +700,6 @@
"select_a_database": "選取資料庫",
"select_a_field_to_map": "選取要對應的欄位",
"select_a_survey_question": "選取問卷問題",
"sync_responses_with_a_notion_database": "與 Notion 資料庫同步回應",
"update_connection": "重新連線 Notion",
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
},
@@ -724,6 +721,7 @@
"slack_integration": "Slack 整合",
"slack_integration_description": "直接將回應傳送至 Slack。",
"slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。",
"slack_logo": "Slack 標誌",
"slack_reconnect_button": "重新連線",
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
},
@@ -908,8 +906,7 @@
"tag_already_exists": "標籤已存在",
"tag_deleted": "標籤已刪除",
"tag_updated": "標籤已更新",
"tags_merged": "標籤已合併",
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
"tags_merged": "標籤已合併"
},
"teams": {
"manage_teams": "管理團隊",
@@ -982,63 +979,53 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"10000_monthly_responses": "10000 個每月回應",
"1500_monthly_responses": "1500 個每月回應",
"2000_monthly_identified_users": "2000 個每月識別使用者",
"30000_monthly_identified_users": "30000 個每月識別使用者",
"1000_monthly_responses": "1000 個每月回應",
"1_project": "1 個專案",
"2000_contacts": "2000 個聯絡人",
"3_projects": "3 個專案",
"5000_monthly_responses": "5000 個每月回應",
"5_projects": "5 個專案",
"7500_monthly_identified_users": "7500 個每月識別使用者",
"advanced_targeting": "進階目標設定",
"7500_contacts": "7500 個聯絡人",
"all_integrations": "所有整合",
"all_surveying_features": "所有調查功能",
"annually": "每年",
"api_webhooks": "API 和 Webhook",
"app_surveys": "應用程式問卷",
"contact_us": "聯絡我們",
"attribute_based_targeting": "基於屬性的定位",
"current": "目前",
"current_plan": "目前方案",
"current_tier_limit": "目前層級限制",
"custom_miu_limit": "自訂 MIU 上限",
"custom": "自訂 & 規模",
"custom_contacts_limit": "自訂聯絡人上限",
"custom_project_limit": "自訂專案上限",
"customer_success_manager": "客戶成功經理",
"custom_response_limit": "自訂回應上限",
"email_embedded_surveys": "電子郵件嵌入式問卷",
"email_support": "電子郵件支援",
"enterprise": "企業版",
"email_follow_ups": "電子郵件後續追蹤",
"enterprise_description": "頂級支援和自訂限制。",
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
"everything_in_free": "免費方案中的所有功能",
"everything_in_scale": "進階方案中的所有功能",
"everything_in_startup": "啟動方案中的所有功能",
"free": "免費",
"free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月",
"get_in_touch": "取得聯繫",
"hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)",
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
"manage_card_details": "管理卡片詳細資料",
"manage_subscription": "管理訂閱",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"multi_language_surveys": "多語言問卷",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"priority_support": "優先支援",
"remove_branding": "移除品牌",
"say_hi": "打個招呼!",
"scale": "進階版",
"scale_description": "用於擴展業務的進階功能。",
"startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色",
"technical_onboarding": "技術新手上路",
"unable_to_upgrade_plan": "無法升級方案",
"unlimited_apps_websites": "無限應用程式和網站",
"unlimited_miu": "無限 MIU",
"unlimited_projects": "無限專案",
"unlimited_responses": "無限回應",
@@ -1077,6 +1064,7 @@
"create_new_organization": "建立新組織",
"create_new_organization_description": "建立新組織以處理一組不同的專案。",
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
"delete_organization": "刪除組織",
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性",
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
@@ -1233,8 +1221,9 @@
"copy_survey_description": "將此問卷複製到另一個環境",
"copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
@@ -1307,7 +1296,6 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色",
"card_shadow_color": "卡片陰影顏色",
"card_styling": "卡片樣式設定",
"casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯",
@@ -1332,7 +1320,6 @@
"change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。",
"change_the_placement_of_this_survey": "變更此問卷的位置。",
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。",
"changes_saved": "已儲存變更。",
"character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制",
@@ -1789,6 +1776,7 @@
"setup_instructions": "設定說明",
"setup_integrations": "設定整合",
"share_results": "分享結果",
"share_survey": "分享問卷",
"share_the_link": "分享連結",
"share_the_link_to_get_responses": "分享連結以取得回應",
"show_all_responses_that_match": "顯示所有相符的回應",

View File

@@ -1,3 +1,4 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
@@ -78,6 +79,11 @@ describe("DeleteAccountModal", () => {
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
Object.defineProperty(window, "localStorage", {
writable: true,
value: { removeItem: vi.fn() },
});
// Mock window.location.replace
Object.defineProperty(window, "location", {
writable: true,
@@ -94,6 +100,8 @@ describe("DeleteAccountModal", () => {
/>
);
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
@@ -106,6 +114,7 @@ describe("DeleteAccountModal", () => {
reason: "account_deletion",
redirect: false, // Updated to match new implementation
});
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
@@ -116,6 +125,11 @@ describe("DeleteAccountModal", () => {
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
Object.defineProperty(window, "localStorage", {
writable: true,
value: { removeItem: vi.fn() },
});
Object.defineProperty(window, "location", {
writable: true,
value: { replace: vi.fn() },
@@ -137,12 +151,15 @@ describe("DeleteAccountModal", () => {
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion",
redirect: false, // Updated to match new implementation
});
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith(
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
);

View File

@@ -1,5 +1,6 @@
"use client";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
@@ -38,6 +39,8 @@ export const DeleteAccountModal = ({
setDeleting(true);
await deleteUserAction();
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",

View File

@@ -336,7 +336,7 @@ export const getCXQuestionNameMap = (t: TFnType) =>
) as Record<TSurveyQuestionTypeEnum, string>;
export const universalQuestionPresets = {
required: true,
required: false,
};
export const getQuestionDefaults = (id: string, project: any, t: TFnType) => {

View File

@@ -118,7 +118,7 @@ export const LinkSurvey = ({
}, [survey.isVerifyEmailEnabled, verifiedEmail]);
if (hasFinishedSingleUseResponse) {
return <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
}
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified") {

View File

@@ -126,4 +126,19 @@ describe("SurveyInactive", () => {
expect(screen.getByTestId("button")).toBeInTheDocument();
expect(screen.getByTestId("mock-image")).toBeInTheDocument();
});
test("shows branding when linkSurveyBranding is true", async () => {
const Component = await SurveyInactive({ status: "paused", project: { linkSurveyBranding: true } });
render(Component);
expect(screen.getByTestId("mock-image")).toBeInTheDocument();
expect(screen.getByTestId("footer-link")).toBeInTheDocument();
expect(screen.getByTestId("footer-link")).toHaveAttribute("href", "https://formbricks.com");
});
test("hides branding when linkSurveyBranding is false", async () => {
const Component = await SurveyInactive({ status: "paused", project: { linkSurveyBranding: false } });
render(Component);
expect(screen.queryByTestId("mock-image")).not.toBeInTheDocument();
expect(screen.queryByTestId("footer-link")).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import { Button } from "@/modules/ui/components/button";
import { getTranslate } from "@/tolgee/server";
import { Project } from "@prisma/client";
import { CheckCircle2Icon, HelpCircleIcon, PauseCircleIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -9,9 +10,11 @@ import footerLogo from "../lib/footerlogo.svg";
export const SurveyInactive = async ({
status,
surveyClosedMessage,
project,
}: {
status: "paused" | "completed" | "link invalid" | "scheduled" | "response submitted";
surveyClosedMessage?: TSurveyClosedMessage | null;
project?: Pick<Project, "linkSurveyBranding">;
}) => {
const t = await getTranslate();
const icons = {
@@ -28,10 +31,15 @@ export const SurveyInactive = async ({
"response submitted": t("s.response_submitted"),
};
const showCTA =
status !== "link invalid" &&
status !== "response submitted" &&
((status !== "paused" && status !== "completed") || project?.linkSurveyBranding || !project) &&
!(status === "completed" && surveyClosedMessage);
return (
<div className="flex h-full flex-col items-center justify-between bg-gradient-to-br from-slate-200 to-slate-50 px-4 py-8 text-center">
<div></div>
<div className="flex flex-col items-center space-y-3 text-slate-300">
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">
{icons[status]}
<h1 className="text-4xl font-bold text-slate-800">
{status === "completed" && surveyClosedMessage
@@ -43,19 +51,19 @@ export const SurveyInactive = async ({
? surveyClosedMessage.subheading
: descriptions[status]}
</p>
{!(status === "completed" && surveyClosedMessage) &&
status !== "link invalid" &&
status !== "response submitted" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
</div>
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
{showCTA && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
</div>
{(!project || project.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
</div>
);
};

View File

@@ -13,13 +13,17 @@ vi.mock("next/link", () => ({
default: vi.fn(({ children, href }) => <a href={href}>{children}</a>),
}));
const mockProject = {
linkSurveyBranding: true,
};
describe("SurveyLinkUsed", () => {
afterEach(() => {
cleanup();
});
test("renders with default values when singleUseMessage is null", () => {
render(<SurveyLinkUsed singleUseMessage={null} />);
render(<SurveyLinkUsed singleUseMessage={null} project={mockProject} />);
expect(screen.getByText("s.survey_already_answered_heading")).toBeInTheDocument();
expect(screen.getByText("s.survey_already_answered_subheading")).toBeInTheDocument();
@@ -31,17 +35,28 @@ describe("SurveyLinkUsed", () => {
subheading: "Custom Subheading",
} as any;
render(<SurveyLinkUsed singleUseMessage={singleUseMessage} />);
render(<SurveyLinkUsed singleUseMessage={singleUseMessage} project={mockProject} />);
expect(screen.getByText("Custom Heading")).toBeInTheDocument();
expect(screen.getByText("Custom Subheading")).toBeInTheDocument();
});
test("renders footer with link to Formbricks", () => {
render(<SurveyLinkUsed singleUseMessage={null} />);
test("renders footer with link to Formbricks when branding is enabled", () => {
render(<SurveyLinkUsed singleUseMessage={null} project={mockProject} />);
const link = document.querySelector('a[href="https://formbricks.com"]');
expect(link).toBeInTheDocument();
expect(vi.mocked(Image)).toHaveBeenCalled();
});
test("does not render footer when branding is disabled", () => {
const projectWithoutBranding = {
linkSurveyBranding: false,
};
render(<SurveyLinkUsed singleUseMessage={null} project={projectWithoutBranding} />);
const link = document.querySelector('a[href="https://formbricks.com"]');
expect(link).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { CheckCircle2Icon } from "lucide-react";
import Image from "next/image";
@@ -9,27 +10,29 @@ import footerLogo from "../lib/footerlogo.svg";
interface SurveyLinkUsedProps {
singleUseMessage: TSurveySingleUse | null;
project?: Pick<Project, "linkSurveyBranding">;
}
export const SurveyLinkUsed = ({ singleUseMessage }: SurveyLinkUsedProps) => {
export const SurveyLinkUsed = ({ singleUseMessage, project }: SurveyLinkUsedProps) => {
const { t } = useTranslate();
const defaultHeading = t("s.survey_already_answered_heading");
const defaultSubheading = t("s.survey_already_answered_subheading");
return (
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
<div></div>
<div className="flex flex-col items-center space-y-3 text-slate-300">
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">
<CheckCircle2Icon className="h-20 w-20" />
<h1 className="text-4xl font-bold text-slate-800">{singleUseMessage?.heading ?? defaultHeading}</h1>
<p className="text-lg leading-10 text-slate-500">
{singleUseMessage?.subheading ?? defaultSubheading}
</p>
</div>
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
{(!project || project.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
</div>
);
};

View File

@@ -251,4 +251,33 @@ describe("renderSurvey", () => {
})
).rejects.toThrow("Project not found");
});
describe("Survey Inactive States", () => {
test("renders SurveyInactive with project when survey is paused", async () => {
const survey: TSurvey = { ...mockSurvey, status: "paused" };
const project = { id: "project-123", linkSurveyBranding: true };
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(project as any);
const result = await renderSurvey({
survey: survey,
searchParams: {},
isPreview: false,
});
expect(result).toBeDefined();
});
test("renders SurveyInactive without project when project is not found", async () => {
const survey: TSurvey = { ...mockSurvey, status: "paused" };
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
const result = await renderSurvey({
survey: survey,
searchParams: {},
isPreview: false,
});
expect(result).toBeDefined();
});
});
});

View File

@@ -60,10 +60,12 @@ export const renderSurvey = async ({
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress" && !isPreview) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
project={project || undefined}
/>
);
}

View File

@@ -4,6 +4,7 @@ import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -50,12 +51,19 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
// When token is invalid, we don't have survey data to get project branding settings
// So we show SurveyInactive without project data (shows branding by default for backward compatibility)
return <SurveyInactive status="link invalid" />;
}
const { surveyId, contactId } = result.data;
const existingResponse = await getExistingContactResponse(surveyId, contactId)();
if (existingResponse) {
const survey = await getSurvey(surveyId);
if (survey) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="response submitted" project={project || undefined} />;
}
return <SurveyInactive status="response submitted" />;
}

View File

@@ -47,9 +47,5 @@ describe("LinkSurveyNotFound", () => {
// Check the basic elements that are visible in the rendered output
expect(screen.getByText("Survey not found.")).toBeInTheDocument();
expect(screen.getByText("There is no survey with this ID.")).toBeInTheDocument();
expect(screen.getByTestId("mock-help-circle-icon")).toBeInTheDocument();
// Check the button exists
expect(screen.getByTestId("mock-button")).toBeInTheDocument();
});
});

View File

@@ -1,26 +1,12 @@
import { Button } from "@/modules/ui/components/button";
import { HelpCircleIcon } from "lucide-react";
import { StaticImport } from "next/dist/shared/lib/get-img-props";
import Image from "next/image";
import Link from "next/link";
import footerLogo from "./lib/footerlogo.svg";
export const LinkSurveyNotFound = () => {
return (
<div className="flex h-full flex-col items-center justify-between bg-gradient-to-br from-slate-200 to-slate-50 py-8 text-center">
<div></div>
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-slate-200 to-slate-50 py-8 text-center">
<div className="flex flex-col items-center space-y-3 text-slate-300">
<HelpCircleIcon className="h-20 w-20" />,
<HelpCircleIcon className="h-20 w-20" />
<h1 className="text-4xl font-bold text-slate-800">Survey not found.</h1>
<p className="text-lg leading-10 text-slate-500">There is no survey with this ID.</p>
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">Create your own</Link>
</Button>
</div>
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as StaticImport} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
</div>
);

View File

@@ -1,14 +1,15 @@
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId } from "@/modules/survey/link/lib/data";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { cleanup, render } from "@testing-library/react";
import { notFound } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TResponseData } from "@formbricks/types/responses";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { LinkSurveyPage, generateMetadata } from "./page";
@@ -26,22 +27,32 @@ vi.mock("@/modules/survey/lib/survey", () => ({
}));
vi.mock("@/modules/survey/link/components/survey-inactive", () => ({
SurveyInactive: vi.fn(() => <div data-testid="survey-inactive" />),
SurveyInactive: vi.fn(() => <div>Survey Inactive</div>),
}));
vi.mock("@/modules/survey/link/components/survey-renderer", () => ({
renderSurvey: vi.fn(() => <div data-testid="survey-renderer" />),
renderSurvey: vi.fn(() => <div>Render Survey</div>),
}));
vi.mock("@/modules/survey/link/lib/data", () => ({
getResponseBySingleUseId: vi.fn(() => vi.fn()),
getResponseBySingleUseId: vi.fn(),
getSurveyWithMetadata: vi.fn(),
}));
vi.mock("@/modules/survey/link/lib/project", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/link/metadata", () => ({
getMetadataForLinkSurvey: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("LinkSurveyPage", () => {
afterEach(() => {
cleanup();
@@ -232,4 +243,169 @@ describe("LinkSurveyPage", () => {
})
);
});
test("should show 'link invalid' for single-use survey without suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ id: "proj-123" } as any);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({}),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(SurveyInactive).mock.calls[0][0]).toEqual({
status: "link invalid",
project: { id: "proj-123" },
});
});
test("should show 'link invalid' for encrypted single-use survey with invalid suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(validateSurveySingleUseId).mockReturnValue(undefined);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ id: "proj-123" } as any);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({ suId: "invalid-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(SurveyInactive).mock.calls[0][0]).toEqual({
status: "link invalid",
project: { id: "proj-123" },
});
});
test("should render survey for encrypted single-use survey with valid suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(validateSurveySingleUseId).mockReturnValue("valid-suid");
const mockResponseFn = vi.fn().mockResolvedValue({ id: "res-1" });
vi.mocked(getResponseBySingleUseId).mockReturnValue(mockResponseFn);
const props = {
params: Promise.resolve({ surveyId: "survey-123" }),
searchParams: Promise.resolve({ suId: "encrypted-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(renderSurvey).mock.calls[0][0]).toEqual(
expect.objectContaining({
singleUseId: "valid-suid",
singleUseResponse: { id: "res-1" },
})
);
});
test("should render survey for non-encrypted single-use survey", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
const mockResponseFn = vi.fn().mockResolvedValue({ id: "res-1" });
vi.mocked(getResponseBySingleUseId).mockReturnValue(mockResponseFn);
const props = {
params: Promise.resolve({ surveyId: "survey-123" }),
searchParams: Promise.resolve({ suId: "plain-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(renderSurvey).mock.calls[0][0]).toEqual(
expect.objectContaining({
singleUseId: "plain-suid",
singleUseResponse: { id: "res-1" },
})
);
});
test("should render survey with undefined response when getResponseBySingleUseId fails", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
const mockResponseFn = vi.fn().mockRejectedValue(new Error("DB error"));
vi.mocked(getResponseBySingleUseId).mockReturnValue(mockResponseFn);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({ suId: "plain-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(logger.error).toHaveBeenCalled();
expect(vi.mocked(renderSurvey).mock.calls[0][0]).toEqual(
expect.objectContaining({
singleUseId: "plain-suid",
singleUseResponse: undefined,
})
);
});
test("should handle missing project for single-use survey without suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({}),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(SurveyInactive).mock.calls[0][0]).toEqual({
status: "link invalid",
project: undefined,
});
});
test("LinkSurveyPage calls notFound when getSurveyWithMetadata throws an error", async () => {
const databaseError = new Error("Database connection failed");
vi.mocked(getSurveyWithMetadata).mockRejectedValue(databaseError);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({}),
};
await LinkSurveyPage(props);
expect(getSurveyWithMetadata).toHaveBeenCalledWith("survey123");
expect(logger.error).toHaveBeenCalledWith(databaseError, "Error fetching survey");
expect(notFound).toHaveBeenCalled();
});
});
describe("generateMetadata", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should call notFound for invalid surveyId", async () => {
const props = {
params: Promise.resolve({ surveyId: "invalid-id" }),
searchParams: Promise.resolve({}),
};
await generateMetadata(props);
expect(notFound).toHaveBeenCalled();
});
test("should call getMetadataForLinkSurvey for valid surveyId", async () => {
vi.mocked(getMetadataForLinkSurvey).mockResolvedValue({ title: "Test Survey" });
const props = {
params: Promise.resolve({ surveyId: "survey-123" }),
searchParams: Promise.resolve({}),
};
const metadata = await generateMetadata(props);
expect(getMetadataForLinkSurvey).toHaveBeenCalledWith("survey-123");
expect(metadata).toEqual({ title: "Test Survey" });
});
});

View File

@@ -2,11 +2,13 @@ import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys/types";
interface LinkSurveyPageProps {
params: Promise<{
@@ -42,7 +44,14 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
const isPreview = searchParams.preview === "true";
// Use optimized survey data fetcher (includes all necessary data)
const survey = await getSurveyWithMetadata(params.surveyId);
let survey: TSurvey | null = null;
try {
survey = await getSurveyWithMetadata(params.surveyId);
} catch (error) {
logger.error(error, "Error fetching survey");
return notFound();
}
const suId = searchParams.suId;
const isSingleUseSurvey = survey?.singleUse?.enabled;
@@ -53,7 +62,8 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (isSingleUseSurvey) {
// check if the single use id is present for single use surveys
if (!suId) {
return <SurveyInactive status="link invalid" />;
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
// if encryption is enabled, validate the single use id
@@ -61,7 +71,8 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (isSingleUseSurveyEncrypted) {
validatedSingleUseId = validateSurveySingleUseId(suId);
if (!validatedSingleUseId) {
return <SurveyInactive status="link invalid" />;
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
}
// if encryption is disabled, use the suId as is

View File

@@ -1,17 +1,61 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { render } from "@testing-library/react";
import { signOut } from "next-auth/react";
import { describe, expect, test, vi } from "vitest";
import { type MockedFunction, beforeEach, describe, expect, test, vi } from "vitest";
import { ClientLogout } from "./index";
// Mock the localStorage
const mockRemoveItem = vi.fn();
Object.defineProperty(window, "localStorage", {
value: {
removeItem: mockRemoveItem,
},
});
// Mock next-auth/react
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: vi.fn(),
}));
const mockUseSignOut = useSignOut as MockedFunction<typeof useSignOut>;
describe("ClientLogout", () => {
test("calls signOut on render", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseSignOut.mockReturnValue({
signOut: mockSignOut,
});
});
test("calls signOut with correct parameters on render", () => {
render(<ClientLogout />);
expect(signOut).toHaveBeenCalled();
expect(mockUseSignOut).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({
reason: "forced_logout",
redirectUrl: "/auth/login",
redirect: false,
callbackUrl: "/auth/login",
});
});
test("handles missing userId and userEmail", () => {
render(<ClientLogout />);
expect(mockUseSignOut).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({
reason: "forced_logout",
redirectUrl: "/auth/login",
redirect: false,
callbackUrl: "/auth/login",
});
});
test("removes environment ID from localStorage", () => {
render(<ClientLogout />);
expect(mockRemoveItem).toHaveBeenCalledWith("formbricks-environment-id");
});
test("renders null", () => {

View File

@@ -1,11 +1,20 @@
"use client";
import { signOut } from "next-auth/react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { useEffect } from "react";
export const ClientLogout = () => {
const { signOut: signOutWithAudit } = useSignOut();
useEffect(() => {
signOut();
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
signOutWithAudit({
reason: "forced_logout",
redirectUrl: "/auth/login",
redirect: false,
callbackUrl: "/auth/login",
});
});
return null;
};

View File

@@ -103,7 +103,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await page.locator("path").nth(3).click();
@@ -115,7 +115,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(
page.locator("#questionCard-4").getByText(surveys.createAndSubmit.npsQuestion.highLabel)
).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < 11; i++) {
@@ -135,7 +135,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel)).toBeVisible();
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
await page.getByLabel(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
await page.locator("#questionCard-6").getByRole("button", { name: "Next" }).click();
// Picture Select Question
@@ -760,7 +760,7 @@ test.describe("Testing Survey with advanced logic", async () => {
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
@@ -772,7 +772,7 @@ test.describe("Testing Survey with advanced logic", async () => {
await expect(
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel)
).toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < 11; i++) {
@@ -831,7 +831,7 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
await page.getByLabel(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// File Upload Question

View File

@@ -418,7 +418,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
await page.getByLabel("Required").click();
// Multi Select Question
await page
@@ -463,8 +462,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
},
]);
await page.getByLabel("Required").click();
// Rating Question
await page
.locator("div")
@@ -510,7 +507,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(params.ranking.choices[4]);
await page.getByLabel("Required").click();
// Matrix Question
await page
@@ -549,7 +545,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
await page.getByLabel("Required").click();
// Consent Question
await page
@@ -578,7 +573,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.click();
await page.getByRole("button", { name: "Date" }).click();
await page.getByLabel("Question*").fill(params.date.question);
await page.getByLabel("Required").click();
// Cal Question
await page
@@ -588,7 +582,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.click();
await page.getByRole("button", { name: "Schedule a meeting" }).click();
await page.getByLabel("Question*").fill(params.cal.question);
await page.getByLabel("Required").click();
// Fill Address Question
await page
@@ -633,8 +626,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Assign =" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("This ");
await page.locator("#action-2-value-input").click();
await page.locator("#action-2-value-input").fill("1");
// Single Select Question
await page.getByRole("heading", { name: params.singleSelectQuestion.question }).click();

View File

@@ -64,6 +64,7 @@
"pages": [
"xm-and-surveys/surveys/link-surveys/data-prefilling",
"xm-and-surveys/surveys/link-surveys/embed-surveys",
"xm-and-surveys/surveys/link-surveys/quota-management",
"xm-and-surveys/surveys/link-surveys/market-research-panel",
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
"xm-and-surveys/surveys/link-surveys/single-use-links",

View File

@@ -0,0 +1,125 @@
---
title: "Quota Management"
description: "Control response collection by setting limits on specific segments to ensure balanced and representative survey datasets."
icon: "chart-pie"
---
## Overview
Quota Management allows you to set limits on the number of responses collected for specific segments or criteria in your survey. This feature helps ensure you collect a balanced and representative dataset while preventing oversaturation of certain response types.
<Note>
Quota Management is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
### Key benefits
- **Balanced Data Collection**: Ensure your survey responses are evenly distributed across different segments
- **Cost Control**: Prevent collecting more responses than needed from specific groups
- **Quality Assurance**: Maintain data quality by avoiding homogeneous response patterns
- **Automated Management**: Automatically stop collecting responses when quotas are met
### How Quota Management works
When you set up quotas for your survey, Formbricks automatically tracks responses against your defined limits. Once a quota is reached, the system can:
- Prevent new responses from that segment
- Skip respondents to the end of the survey
- Redirect respondents to a custom end screen
## Setting up Quotas
In the first step, you need to define the criteria for the quota:
<Steps>
<Step title="Name the quota">
Create a Quota and label it e.g. "Mobile Phone Users in Europe"
</Step>
<Step title="Set quota limit">
Set numerical limits for each hidden field value combination e.g. 500
</Step>
<Step title="Define inclusion criteria">
Choose a distinct set of answers to survey questions, variable values or hidden fields. Responses who match this set will be included in the quota.
</Step>
<Step title="Configure actions">
Choose what happens when this Quota is met (e.g. skip to specific end screen)
</Step>
</Steps>
## Quota actions
Configure what happens when a quota reaches its limit:
<Tabs>
<Tab title="Skip to End">
Jump respondents directly to the survey completion page
</Tab>
<Tab title="Custom Redirect (soon)">
Redirect respondents to a custom thank you page or alternative survey
</Tab>
</Tabs>
## Counting against Quotas
### 1. Count by Hidden Field value
Determine if a response falls in or out of a Quota based on hidden field values passed through URL parameters:
```
https://your-survey-url.com/s/abc123?product=credit-card&region=europe
```
### 2. Quota by survey responses
Create quotas based on specific answers to survey questions:
<Tabs>
<Tab title="Single Question Quota">
Set quotas for individual answer options:
- Question: "What is your gender?"
- Quota: 500 responses for "Male", 500 responses for "Female"
</Tab>
<Tab title="Multi-Question Quota">
Combine multiple question responses:
- Criteria: Age group "25-34" AND Location "Urban"
- Quota: 200 responses matching both criteria
</Tab>
</Tabs>
### 3. Multi-criteria quotas
Create complex quotas using multiple conditions:
<CodeGroup>
```example "Hidden Field + Response Combination"
Hidden Field: product = "mobile"
AND
Question Response: satisfaction = "very satisfied"
```
```example "Multiple Response Criteria"
Question 1: age_group = "18-25"
AND
Question 2: location = "urban"
AND
Question 3: income = "high"
Quota Limit: 50 responses
```
</CodeGroup>
### Partial vs. complete responses
<Info>
By default, Quota Management includes partial responses in quota counts. You can change this behavior by configuring the quota to only count complete responses.
</Info>
This means if a respondent starts but doesn't complete the survey, they may still count toward your quota if they've answered the qualifying questions.
## Quota monitoring
<Card title="Live Quota Status" icon="chart-line">
Monitor your quotas in real-time through the dashboard in the survey summary:
- **Current Count**: See how many responses each quota has collected
- **Progress Bars**: Visual representation of quota completion
- **Status Indicators**: Active, completed, or paused quota status
</Card>

View File

@@ -132,10 +132,9 @@ externalSecret:
ingress:
annotations:
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
alb.ingress.kubernetes.io/group.name: formbricks-stage
alb.ingress.kubernetes.io/group.name: internal
alb.ingress.kubernetes.io/healthcheck-path: /health
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-Res-2021-06
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/target-type: ip

View File

@@ -43,7 +43,7 @@ export function AddressQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: AddressQuestionProps) {
}: Readonly<AddressQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);

View File

@@ -40,7 +40,7 @@ export function CalQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: CalQuestionProps) {
}: Readonly<CalQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const [errorMessage, setErrorMessage] = useState("");

View File

@@ -40,7 +40,7 @@ export function ConsentQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: ConsentQuestionProps) {
}: Readonly<ConsentQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;

View File

@@ -43,7 +43,7 @@ export function ContactInfoQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: ContactInfoQuestionProps) {
}: Readonly<ContactInfoQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);

View File

@@ -41,7 +41,7 @@ export function CTAQuestion({
currentQuestionId,
isBackButtonHidden,
onOpenExternalURL,
}: CTAQuestionProps) {
}: Readonly<CTAQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;

View File

@@ -94,7 +94,7 @@ export function DateQuestion({
ttc,
currentQuestionId,
isBackButtonHidden,
}: DateQuestionProps) {
}: Readonly<DateQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = question.imageUrl || question.videoUrl;

View File

@@ -14,21 +14,21 @@ import { FileInput } from "../general/file-input";
import { Subheader } from "../general/subheader";
interface FileUploadQuestionProps {
readonly question: TSurveyFileUploadQuestion;
readonly value: string[];
readonly onChange: (responseData: TResponseData) => void;
readonly onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
readonly onBack: () => void;
readonly onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
readonly isFirstQuestion: boolean;
readonly isLastQuestion: boolean;
readonly surveyId: string;
readonly languageCode: string;
readonly ttc: TResponseTtc;
readonly setTtc: (ttc: TResponseTtc) => void;
readonly autoFocusEnabled: boolean;
readonly currentQuestionId: TSurveyQuestionId;
readonly isBackButtonHidden: boolean;
question: TSurveyFileUploadQuestion;
value: string[];
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
onBack: () => void;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
isFirstQuestion: boolean;
isLastQuestion: boolean;
surveyId: string;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
}
export function FileUploadQuestion({
@@ -46,7 +46,7 @@ export function FileUploadQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: FileUploadQuestionProps) {
}: Readonly<FileUploadQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);

View File

@@ -40,7 +40,7 @@ export function MatrixQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: MatrixQuestionProps) {
}: Readonly<MatrixQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);

View File

@@ -41,7 +41,7 @@ export function MultipleChoiceMultiQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: MultipleChoiceMultiProps) {
}: Readonly<MultipleChoiceMultiProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);

View File

@@ -41,7 +41,7 @@ export function MultipleChoiceSingleQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: MultipleChoiceSingleProps) {
}: Readonly<MultipleChoiceSingleProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [otherSelected, setOtherSelected] = useState(false);
const otherSpecify = useRef<HTMLInputElement | null>(null);

View File

@@ -40,7 +40,7 @@ export function NPSQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: NPSQuestionProps) {
}: Readonly<NPSQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = question.imageUrl || question.videoUrl;

View File

@@ -43,7 +43,7 @@ export function OpenTextQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: OpenTextQuestionProps) {
}: Readonly<OpenTextQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [currentLength, setCurrentLength] = useState(value.length || 0);
const isMediaAvailable = question.imageUrl || question.videoUrl;

View File

@@ -41,7 +41,7 @@ export function PictureSelectionQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: PictureSelectionProps) {
}: Readonly<PictureSelectionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;

View File

@@ -46,7 +46,7 @@ export function RankingQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: RankingQuestionProps) {
}: Readonly<RankingQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isCurrent = question.id === currentQuestionId;
const shuffledChoicesIds = useMemo(() => {
@@ -189,6 +189,7 @@ export function RankingQuestion({
handleItemClick(item);
}}
type="button"
aria-label={`Select ${getLocalizedValue(item.label, languageCode)} for ranking`}
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group text-left focus:outline-none">
<span
className={cn(
@@ -206,12 +207,13 @@ export function RankingQuestion({
{isSorted ? (
<div className="fb-flex fb-flex-col fb-h-full fb-grow-0 fb-border-l fb-border-border">
<button
tabIndex={-1}
tabIndex={isFirst ? -1 : 0}
type="button"
onClick={(e) => {
e.preventDefault();
handleMove(item.id, "up");
}}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} up`}
className={cn(
"fb-px-2 fb-flex fb-flex-1 fb-items-center fb-justify-center",
isFirst
@@ -234,7 +236,7 @@ export function RankingQuestion({
</svg>
</button>
<button
tabIndex={-1}
tabIndex={isLast ? -1 : 0}
type="button"
onClick={(e) => {
e.preventDefault();
@@ -246,6 +248,7 @@ export function RankingQuestion({
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-br-custom fb-transition-colors"
)}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} down`}
disabled={isLast}>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -6,7 +6,7 @@ interface ScrollableContainerProps {
children: JSX.Element;
}
export function ScrollableContainer({ children }: ScrollableContainerProps) {
export function ScrollableContainer({ children }: Readonly<ScrollableContainerProps>) {
const [isAtBottom, setIsAtBottom] = useState(false);
const [isAtTop, setIsAtTop] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

View File

@@ -30,7 +30,7 @@ export function StackedCardsContainer({
setQuestionId,
shouldResetQuestionId = true,
fullSizeCards = false,
}: StackedCardsContainerProps) {
}: Readonly<StackedCardsContainerProps>) {
const [hovered, setHovered] = useState(false);
const highlightBorderColor = survey.styling?.overwriteThemeStyling
? survey.styling?.highlightBorderColor?.light

View File

@@ -20,7 +20,7 @@ export function SurveyContainer({
onClose,
clickOutside,
isOpen = true,
}: SurveyContainerProps) {
}: Readonly<SurveyContainerProps>) {
const modalRef = useRef<HTMLDivElement>(null);
const isCenter = placement === "center";
const isModal = mode === "modal";