Compare commits

..

30 Commits

Author SHA1 Message Date
Cursor Agent
5b17dd6292 Update profile update toast message for name and locale changes
Co-authored-by: mail <mail@matti.sh>
2025-07-10 12:35:20 +00:00
Dhruwang Jariwala
599e847686 chore: removed integrity hash chain from audit logging (#6202) 2025-07-10 10:43:57 +00:00
Victor Hugo dos Santos
4e52556f7e feat: add single contact using the API V2 (#6168) 2025-07-10 10:34:18 +00:00
Kshitij Sharma
492a59e7de fix: show multi-choice question first in styling preview (#6150)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 01:41:02 -07:00
Jakob Schott
e0be53805e fix: Spelling mistake for Nodemailer in docs (#5988) 2025-07-10 00:29:50 -07:00
Johannes
5c2860d1a4 docs: Personal Link docs (#6034)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 00:13:29 -07:00
Piyush Gupta
18ba5bbd8a fix: types in audit log wrapper (#6200) 2025-07-10 03:55:28 +00:00
Johannes
572b613034 docs: update prefilling docs (#6062) 2025-07-09 08:52:53 -07:00
Abhi-Bohora
a9c7140ba6 fix: Edit Recall button flicker when user types into the edit field (#6121)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-09 08:51:42 -07:00
Abhishek Sharma
7fa95cd74a fix: recall fallback input to be displayed on top of other contai… (#6124)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-09 08:51:27 -07:00
Nathanaël
8c7f36d496 chore: Update docker-compose.yml, fix syntax (#6158) 2025-07-09 17:39:58 +02:00
Jakob Schott
42dcbd3e7e chore: changed date format on license alert to MMM dd, YYYY (#6182) 2025-07-09 14:57:04 +00:00
Piyush Gupta
1c1cd99510 fix: unsaved survey dialog (#6201) 2025-07-09 08:14:32 +00:00
Dhruwang Jariwala
b0a7e212dd fix: suid copy issue on safari (#6174) 2025-07-08 10:50:02 +00:00
Dhruwang Jariwala
0c1f6f3c3a fix: translations (#6186) 2025-07-08 08:52:36 +00:00
Matti Nannt
9399b526b8 fix: run PR checks on every pull requests (#6185) 2025-07-08 11:07:03 +02:00
Dhruwang Jariwala
cd60032bc9 fix: row/column deletion in matrix question (#6184) 2025-07-08 07:12:16 +00:00
Dhruwang Jariwala
a941f994ea fix: removed userId from contact endpoint response (#6175)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-08 06:36:56 +00:00
Jakob Schott
75d170bce5 chore: removed unnecessary text bullet point from dialog (#6180) 2025-07-07 15:29:44 +00:00
Piyush Gupta
16caae6dd6 chore: upgrade to storybook 9 (#6141) 2025-07-07 09:55:22 +00:00
Kshitij Sharma
a490600479 fix: ensure date question respects question color styling (#6155)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-07 00:43:21 -07:00
Suraj
be28641722 fix: changing project name doesn't update in the sidebar and project selector (#6130)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-07 05:36:17 +00:00
Dhruwang Jariwala
4fdea3221b feat: Personal links (#6138)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-04 14:17:40 +00:00
Jakob Schott
fef30c54b2 feat: replace deprecated modals with new one (5824) (#5903)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
2025-07-04 11:44:36 +00:00
Johannes
75362eac7a chore: updating contribution docs (#6157) 2025-07-04 04:56:14 -07:00
Dhruwang Jariwala
6e3b224944 chore: sunset card shadow color (#6152) 2025-07-04 10:44:32 +00:00
Aditya
ef1be219b4 fix: Show Specific Error for Duplicate Tag Names (#6057)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-04 08:47:49 +00:00
Piyush Gupta
ba9b01a969 fix: survey list refresh (#6104)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-04 08:16:27 +00:00
Harsh Bhat
e810e38333 chore: change pricing (#5850)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-03 13:40:19 +00:00
victorvhs017
dab8ad00d5 feat: Add Sentry source maps (#6047) 2025-07-03 13:03:59 +00:00
265 changed files with 12924 additions and 8145 deletions

View File

@@ -210,6 +210,8 @@ UNKEY_ROOT_KEY=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
# SENTRY_ENVIRONMENT=
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
@@ -217,7 +219,7 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# Audit logs options. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

View File

@@ -0,0 +1,121 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
required: true
sentry_auth_token:
description: 'Sentry authentication token'
required: true
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
shell: bash
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
exit 1
fi
echo "✅ Sentry auth token validated successfully"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
# Capture the current exit code to preserve it
local original_exit_code=$?
echo "🧹 Cleaning up Docker container..."
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
# Extract .next directory containing sourcemaps
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
# Verify sourcemaps exist
if [ ! -d "./extracted-next/static/chunks" ]; then
echo "❌ Error: .next/static/chunks directory not found in Docker image"
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
exit 1
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
exit 1
fi
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: production
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
- name: Clean up extracted files
shell: bash
if: always()
run: |
set -euo pipefail
# Clean up extracted files
rm -rf ./extracted-next
echo "🧹 Cleaned up extracted files"

View File

@@ -32,3 +32,25 @@ jobs:
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -10,8 +10,6 @@ permissions:
on:
pull_request:
branches:
- main
merge_group:
workflow_dispatch:

View File

@@ -0,0 +1,46 @@
name: Upload Sentry Sourcemaps (Manual)
on:
workflow_dispatch:
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
type: string
release_version:
description: "Release version (e.g., v1.2.3)"
required: true
type: string
tag_version:
description: "Docker image tag (leave empty to use release_version)"
required: false
type: string
permissions:
contents: read
jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests
Ready to dive into the code and make a real impact? Here's your path:
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
## 🚀 Aspiring Features

View File

@@ -192,7 +192,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
- Note: For the time being, we can only facilitate code contributions as an exception.
## All Thanks To Our Contributors

View File

@@ -14,10 +14,9 @@ const config: StorybookConfig = {
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-docs"),
],
framework: {
name: getAbsolutePath("@storybook/react-vite"),

View File

@@ -1,5 +1,21 @@
import type { Preview } from "@storybook/react";
import type { Preview } from "@storybook/react-vite";
import { TolgeeProvider } from "@tolgee/react";
import React from "react";
import "../../web/modules/ui/globals.css";
import { TolgeeBase } from "../../web/tolgee/shared";
// Create a Storybook-specific Tolgee decorator
const withTolgee = (Story: any) => {
const tolgee = TolgeeBase().init({
tagNewKeys: [], // No branch tagging in Storybook
});
return React.createElement(
TolgeeProvider,
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
React.createElement(Story)
);
};
const preview: Preview = {
parameters: {
@@ -10,6 +26,7 @@ const preview: Preview = {
},
},
},
decorators: [withTolgee],
};
export default preview;

View File

@@ -14,23 +14,19 @@
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "0.12.0",
"eslint-plugin-storybook": "9.0.15",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"vite": "6.3.5"
"storybook": "9.0.15",
"vite": "6.3.5",
"@storybook/addon-docs": "9.0.15"
}
}

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/blocks";
import { Meta } from "@storybook/addon-docs/blocks";
import Accessibility from "./assets/accessibility.png";
import AddonLibrary from "./assets/addon-library.png";

View File

@@ -1,5 +1,5 @@
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { cleanup, render } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock child components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
<div data-testid="modal-with-tabs">
<span data-testid="modal-label">{label}</span>
<span data-testid="modal-description">{description}</span>
<span data-testid="modal-open">{open.toString()}</span>
<button onClick={() => setOpen(false)}>Close</button>
{icon}
{tabs.map((tab) => (
<div key={tab.title}>
<h2>{tab.title}</h2>
{tab.children}
</div>
))}
</div>
)),
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
vi.mock("./ActionActivityTab", () => ({
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
},
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
"common.no_code": "No Code",
"common.action": "Action",
"common.code": "Code",
};
return translations[key] || key;
},
}),
}));
const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn();
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
vi.clearAllMocks(); // Clear mocks after each test
});
test("renders ModalWithTabs with correct props", () => {
test("renders correctly when open", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
expect(mockedModalWithTabs).toHaveBeenCalled();
const props = mockedModalWithTabs.mock.calls[0][0];
test("does not render when open is false", () => {
render(<ActionDetailModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
// Check basic props
expect(props.open).toBe(true);
expect(props.setOpen).toBe(mockSetOpen);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
test("switches tabs correctly", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// Check icon data-testid based on the mock for the default 'code' type
expect(props.icon).toBeDefined();
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Initially shows activity tab (first tab is active)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
// Check tabs structure
expect(props.tabs).toHaveLength(2);
expect(props.tabs[0].title).toBe("common.activity");
expect(props.tabs[1].title).toBe("common.settings");
// Click settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Check if the correct mocked components are used as children
// Access the mocked functions directly
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
// Now shows settings tab content
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
// Click activity tab again
const activityTab = screen.getByText("Activity");
await user.click(activityTab);
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
// Back to activity tab content
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
// Check props passed to child components
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
test("resets to first tab when modal is reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.actionClass).toBe(mockActionClass);
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
expect(settingsTabProps.isReadOnly).toBe(false);
// Switch to settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
// Close modal
rerender(<ActionDetailModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<ActionDetailModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
test("renders correct icon based on action type", () => {
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
});
// Expect the 'nocode-icon' based on the updated mock and action type
expect(props.icon).toBeDefined();
test("handles action without description", () => {
const actionWithoutDescription = { ...mockActionClass, description: "" };
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
});
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
test("passes correct props to ActionActivityTab", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
{
otherEnvActionClasses: mockOtherEnvActionClasses,
otherEnvironment: mockOtherEnvironment,
isReadOnly: false,
environment: mockEnvironment,
actionClass: mockActionClass,
environmentId: mockEnvironmentId,
},
undefined
);
});
test("passes correct props to ActionSettingsTab when tab is active", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// ActionSettingsTab should not be called initially since first tab is active
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
// Click the settings tab to activate ActionSettingsTab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now ActionSettingsTab should be called with correct props
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
{
actionClass: mockActionClass,
actionClasses: mockActionClasses,
setOpen: mockSetOpen,
isReadOnly: false,
},
undefined
);
});
test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.isReadOnly).toBe(true);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
expect.objectContaining({
isReadOnly: true,
}),
undefined
);
});
});

View File

@@ -59,6 +59,16 @@ export const ActionDetailModal = ({
},
];
const typeDescription = () => {
if (actionClass.description) return actionClass.description;
else
return (
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
" " +
t("common.action").toLowerCase()
);
};
return (
<>
<ModalWithTabs
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={actionClass.description || ""}
description={typeDescription()}
/>
</>
);

View File

@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
)}
</div>
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
{!isReadOnly ? (
<Button
type="button"
variant="destructive"
onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger">
<TrashIcon />
{t("common.delete")}

View File

@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, ...props }: any) =>
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal" {...props}>
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
}));
vi.mock("@tolgee/react", () => ({
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
);
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("opens the modal when the 'Add Action' button is clicked", async () => {
test("opens the dialog when the 'Add Action' button is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect(
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
expect(props.setActionClasses).toBeInstanceOf(Function);
});
test("closes the modal when the close button (simulated) is clicked", async () => {
test("closes the dialog when the close button (simulated) is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button
const closeModalButton = screen.getByText("Close Modal");
await userEvent.click(closeModalButton);
// Simulate closing via the mocked Dialog's close button
const closeDialogButton = screen.getByText("Close Dialog");
await userEvent.click(closeDialogButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
});

View File

@@ -2,7 +2,14 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
{t("common.add_action")}
<PlusIcon />
</Button>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<MousePointerClickIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.actions.track_new_user_action")}
</div>
<div className="text-sm text-slate-500">
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</div>
</Modal>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick>
<DialogHeader>
<MousePointerClickIcon />
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
<DialogDescription>
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -101,6 +101,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isPendingDowngrade={isPendingDowngrade ?? false}
active={active}
environmentId={environment.id}
locale={user.locale}
/>
<div className="flex h-full">

View File

@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }) =>
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal">
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>,

View File

@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
@@ -19,11 +27,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { TFnType, useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
);
};
const renderQuestionSelection = ({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: {
t: TFnType;
selectedSurvey: TSurvey;
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (value: boolean) => void;
setIncludeMetadata: (value: boolean) => void;
includeCreatedAt: boolean;
setIncludeCreatedAt: (value: boolean) => void;
}) => {
return (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
);
};
export const AddIntegrationModal = ({
open,
setOpenWithStates,
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
};
return (
<Modal open={open} setOpen={handleClose} noPadding>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent className="overflow-visible md:overflow-visible">
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.airtable.link_airtable_table")}
</div>
<div className="text-sm text-slate-500">
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.airtable.sync_responses_with_airtable")}
</div>
</DialogDescription>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="flex rounded-lg p-6">
<div className="flex w-full flex-col gap-y-4 pt-5">
{airtableArray.length ? (
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
<DialogBody className="overflow-visible">
<div className="flex w-full flex-col gap-y-4">
{airtableArray.length ? (
<BaseSelectDropdown
control={control}
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
</div>
</div>
) : (
<NoBaseFoundError />
)}
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label>
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="survey"
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/>
</div>
</div>
) : null}
{!surveys.length ? (
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</p>
) : null}
{survey && selectedSurvey && (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
))}
</div>
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="survey"
render={({ field }) => (
<Select
required
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
)}
<div className="flex justify-end gap-x-2">
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</p>
)}
<Button type="submit">{t("common.save")}</Button>
{survey &&
selectedSurvey &&
renderQuestionSelection({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
})}
</div>
</div>
</div>
</form>
</Modal>
</DialogBody>
<DialogFooter>
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")

View File

@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useEffect, useState } from "react";
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.google_sheets.link_google_sheet")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>,
TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -334,7 +362,7 @@ describe("AddIntegrationModal (Notion)", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -359,7 +387,7 @@ describe("AddIntegrationModal (Notion)", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
});
expect(screen.getByText("Delete")).toBeInTheDocument();
@@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(xButton);
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
await userEvent.click(trashButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
});

View File

@@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, XIcon } from "lucide-react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center">
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
<div className="w-[340px] max-w-full">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems}
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="w-[340px] max-w-full">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
/>
</div>
</div>
<button
type="button"
className={`rounded-md p-1 hover:bg-slate-300 ${
idx === mapping.length - 1 ? "visible" : "invisible"
}`}
onClick={addRow}>
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
</button>
<button
type="button"
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
</button>
<div className="flex space-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
<PlusIcon />
</Button>
</div>
</div>
</div>
);
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.notion.link_notion_database")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.notion.notion_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label>
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto">
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
)}
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.notion.link_database")}
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -121,6 +136,8 @@ vi.mock("@tolgee/react", async () => {
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "environments.integrations.slack.slack_integration_description")
return "Send responses directly to Slack.";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
@@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();

View File

@@ -7,9 +7,17 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { CircleHelpIcon } from "lucide-react";
import Image from "next/image";
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
);
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.slack.link_slack_channel")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={SlackLogo}
alt={t("environments.integrations.slack.slack_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{t("common.delete")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

@@ -169,7 +169,7 @@ export const resetPasswordAction = authenticatedActionClient.action(
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);

View File

@@ -88,6 +88,11 @@ export const EditProfileDetailsForm = ({
const updatedUserResult = await updateUserAction(data);
if (updatedUserResult?.data) {
// Show success toast for name/locale changes when email also changes
if (nameChanged || localeChanged) {
toast.success(t("environments.settings.profile.personal_information_updated"));
}
if (!emailVerificationDisabled) {
toast.success(t("auth.verification-requested.new_email_verification_success"));
} else {
@@ -120,7 +125,7 @@ export const EditProfileDetailsForm = ({
...data,
name: data.name.trim(),
});
toast.success(t("environments.settings.profile.profile_updated_successfully"));
toast.success(t("environments.settings.profile.personal_information_updated"));
window.location.reload();
form.reset(data);
} catch (error: any) {
@@ -145,7 +150,7 @@ export const EditProfileDetailsForm = ({
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(t(errorMessage));
toast.error(errorMessage);
}
setIsResettingPassword(false);

View File

@@ -4,18 +4,27 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, title }: any) =>
// Mock the Dialog component
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<div data-testid="dialog" role="dialog">
{children}
<button data-testid="modal-close" onClick={() => setOpen(false)}>
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
// Mock the PasswordInput component
@@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => {
test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("renders modal content when open is true", () => {
test("renders dialog content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
});
test("displays old and new email addresses", () => {

View File

@@ -1,8 +1,16 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
@@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({
};
return (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("auth.email-change.confirm_password_description")}
</p>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
<DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 space-x-2 text-right">
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};

View File

@@ -26,8 +26,26 @@ vi.mock("@/modules/ui/components/button", () => ({
)),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => (
<div
data-testid="dialog-content"
data-hide-close-button={hideCloseButton}
data-width={width}
className={className}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
}));
const mockResponses = [
@@ -163,12 +181,12 @@ describe("ResponseCardModal", () => {
test("should not render if selectedResponseId is null", () => {
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("should render the modal when a response is selected", () => {
test("should render the dialog when a response is selected", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
});
@@ -204,14 +222,6 @@ describe("ResponseCardModal", () => {
expect(nextButton).toBeDisabled();
});
test("should call setSelectedResponseId with null when close button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} />);
const buttons = screen.getAllByTestId("mock-button");
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
if (closeButton) await userEvent.click(closeButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
});
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
expect(mockSetOpen).toHaveBeenCalledWith(true);
@@ -229,11 +239,10 @@ describe("ResponseCardModal", () => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
test("should render ChevronLeft and ChevronRight icons", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
@@ -64,42 +64,20 @@ export const ResponseCardModal = ({
}
};
const handleClose = () => {
setSelectedResponseId(null);
const handleClose = (open: boolean) => {
setOpen(open);
if (!open) {
setSelectedResponseId(null);
}
};
// If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null;
return (
<Modal
hideCloseButton
open={open}
setOpen={setOpen}
size="xxl"
className="max-h-[80vh] overflow-auto"
noPadding>
<div className="h-full rounded-lg">
<div className="relative h-full w-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-end space-x-2">
<Button
onClick={handleBack}
disabled={currentIndex === 0}
variant="ghost"
className="border bg-white p-2">
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="ghost"
className="border bg-white p-2">
<ChevronRight className="h-5 w-5" />
</Button>
<Button className="border bg-white p-2" onClick={handleClose} variant="ghost">
<XIcon className="h-5 w-5" />
</Button>
</div>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent width="wide">
<DialogBody>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
@@ -113,8 +91,20 @@ export const ResponseCardModal = ({
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</div>
</div>
</Modal>
</DialogBody>
<DialogFooter>
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="outline"
size="icon">
<ChevronRight />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,13 +1,15 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -33,6 +35,9 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
@@ -51,6 +56,9 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />

View File

@@ -1,18 +1,23 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -222,3 +227,89 @@ export const getEmailHtmlAction = authenticatedActionClient
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
});
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
environmentId: ZId,
expirationDays: z.number().optional(),
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
// Get contacts and generate personal links
const contactsResult = await generatePersonalLinks(
parsedInput.surveyId,
parsedInput.segmentId,
parsedInput.expirationDays
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
}
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
"User ID",
"First Name",
"Last Name",
"Email",
"Personal Link",
];
const csvData = contactsResult
.map((contact) => {
if (!contact) {
return null;
}
const attributes = contact.attributes ?? {};
return {
"Formbricks Contact ID": contact.contactId,
"User ID": attributes.userId ?? "",
"First Name": attributes.firstName ?? "",
"Last Name": attributes.lastName ?? "",
Email: attributes.email ?? "",
"Personal Link": contact.surveyUrl,
};
})
.filter((contact) => contact !== null);
// Convert to CSV using the file conversion utility
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
};
});

View File

@@ -117,9 +117,9 @@ vi.mock("./shareEmbedModal/EmbedView", () => ({
EmbedView: (props: any) => mockEmbedViewComponent(props),
}));
const mockPanelInfoViewComponent = vi.fn();
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
// Mock getSurveyUrl to return a predictable URL
vi.mock("@/modules/analysis/utils", () => ({
getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
}));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
@@ -133,8 +133,6 @@ vi.mock("@/modules/ui/components/dialog", async () => {
capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />;
},
// DialogTitle, DialogContent, DialogDescription will be the actual components
// due to ...actual spread and no specific mock for them here.
};
});
@@ -154,13 +152,15 @@ describe("ShareEmbedSurvey", () => {
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
user: mockUser,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: true,
};
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div>
@@ -171,9 +171,6 @@ describe("ShareEmbedSurvey", () => {
</div>
)
);
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
));
});
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
@@ -205,43 +202,15 @@ describe("ShareEmbedSurvey", () => {
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
await userEvent.click(embedButton);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
});
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
await userEvent.click(panelButton);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
// Panel view currently just shows a title, no component is rendered
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
@@ -267,7 +236,7 @@ describe("ShareEmbedSurvey", () => {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.length).toBe(4);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link");
@@ -297,24 +266,21 @@ describe("ShareEmbedSurvey", () => {
test("initial showView is set by modalView prop when open is true", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
// Panel view currently just shows a title
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
// The main check is that the previous view ('embed') is gone.
expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument();
});
test("renders correct label for link tab based on singleUse survey property", () => {

View File

@@ -12,15 +12,16 @@ import {
LinkIcon,
MailIcon,
SmartphoneIcon,
UserIcon,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { EmbedView } from "./shareEmbedModal/EmbedView";
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
@@ -29,6 +30,9 @@ interface ShareEmbedSurveyProps {
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const ShareEmbedSurvey = ({
@@ -38,6 +42,9 @@ export const ShareEmbedSurvey = ({
modalView,
setOpen,
user,
segments,
isContactsEnabled,
isFormbricksCloud,
}: ShareEmbedSurveyProps) => {
const router = useRouter();
const environmentId = survey.environmentId;
@@ -52,6 +59,7 @@ export const ShareEmbedSurvey = ({
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon,
},
{ id: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon },
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
@@ -60,8 +68,8 @@ export const ShareEmbedSurvey = ({
[t, isSingleUseLinkSurvey, survey.type]
);
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
@@ -80,7 +88,7 @@ export const ShareEmbedSurvey = ({
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
setActiveId(tabs[4].id);
}
}, [survey.type, tabs]);
@@ -93,7 +101,7 @@ export const ShareEmbedSurvey = ({
}, [open, modalView]);
const handleOpenChange = (open: boolean) => {
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id);
setOpen(open);
if (!open) {
setShowView("start");
@@ -101,10 +109,6 @@ export const ShareEmbedSurvey = ({
router.refresh();
};
const handleInitialPageButton = () => {
setShowView("start");
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
@@ -166,22 +170,28 @@ export const ShareEmbedSurvey = ({
</div>
</div>
) : showView === "embed" ? (
<EmbedView
handleInitialPageButton={handleInitialPageButton}
tabs={survey.type === "link" ? tabs : [tabs[3]]}
disableBack={false}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
<EmbedView
tabs={survey.type === "link" ? tabs : [tabs[4]]}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
</>
) : showView === "panel" ? (
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
</>
) : null}
</DialogContent>
</Dialog>

View File

@@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({
}),
}));
// Mock Modal
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
// Mock Dialog
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, ...props }) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
}));
// Mock useTranslate
@@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => {
test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { Clipboard } from "lucide-react";
@@ -26,70 +26,72 @@ export const ShareSurveyResults = ({
}: ShareEmbedSurveyProps) => {
const { t } = useTranslate();
return (
<Modal open={open} setOpen={setOpen} size="lg">
{showPublishModal && surveyUrl ? (
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogBody>
{showPublishModal && surveyUrl ? (
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="text-primary h-20 w-20" />
<div>
<p className="text-primary text-lg font-medium">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
)}
</Modal>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};

View File

@@ -15,6 +15,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -25,6 +26,9 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
interface ModalState {
@@ -41,6 +45,9 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
segments,
isContactsEnabled,
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
@@ -175,6 +182,9 @@ export const SurveyAnalysisCTA = ({
setOpen={setOpen}
user={user}
modalView={modalView}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
))}
<SuccessMessage environment={environment} survey={survey} />

View File

@@ -29,6 +29,22 @@ vi.mock("./WebsiteTab", () => ({
),
}));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</div>
),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
@@ -43,6 +59,21 @@ vi.mock("lucide-react", () => ({
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
</div>
),
}));
const mockTabs = [
@@ -56,7 +87,6 @@ const mockSurveyLink = { id: "survey1", type: "link" };
const mockSurveyWeb = { id: "survey2", type: "web" };
const defaultProps = {
handleInitialPageButton: vi.fn(),
tabs: mockTabs,
activeId: "email",
setActiveId: vi.fn(),
@@ -67,7 +97,9 @@ const defaultProps = {
publicDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: false,
};
describe("EmbedView", () => {
@@ -76,11 +108,6 @@ describe("EmbedView", () => {
vi.clearAllMocks();
});
test("does not render back button when disableBack is true", () => {
render(<EmbedView {...defaultProps} disableBack={true} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
});
test("does not render desktop tabs for non-link survey type", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// Desktop tabs container should not be present or not have lg:flex if it's a common parent

View File

@@ -2,33 +2,32 @@
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import { TSegment } from "@formbricks/types/segment";
import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab";
import { WebsiteTab } from "./WebsiteTab";
import { PersonalLinksTab } from "./personal-links-tab";
interface EmbedViewProps {
handleInitialPageButton: () => void;
tabs: Array<{ id: string; label: string; icon: any }>;
activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>;
environmentId: string;
disableBack: boolean;
survey: any;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const EmbedView = ({
handleInitialPageButton,
tabs,
disableBack,
activeId,
setActiveId,
environmentId,
@@ -38,18 +37,45 @@ export const EmbedView = ({
publicDomain,
setSurveyUrl,
locale,
segments,
isContactsEnabled,
isFormbricksCloud,
}: EmbedViewProps) => {
const { t } = useTranslate();
const renderActiveTab = () => {
switch (activeId) {
case "email":
return <EmailTab surveyId={survey.id} email={email} />;
case "webpage":
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
case "link":
return (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case "app":
return <AppTab />;
case "personal-links":
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
};
return (
<div className="h-full overflow-hidden">
{!disableBack && (
<div className="border-b border-slate-200 py-2 pl-2">
<Button variant="ghost" className="focus:ring-0" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-4">
{survey.type === "link" && (
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
@@ -75,21 +101,7 @@ export const EmbedView = ({
)}
<div
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
{activeId === "email" ? (
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
) : activeId === "link" ? (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
) : activeId === "app" ? (
<AppTab />
) : null}
{renderActiveTab()}
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => (
<Button

View File

@@ -1,108 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PanelInfoView } from "./PanelInfoView";
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src.src} alt={alt} className={className} />
),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target}>
{children}
</a>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, asChild }: any) => {
if (asChild) {
return <div onClick={onClick}>{children}</div>; // NOSONAR
}
return (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
);
},
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
}));
const mockHandleInitialPageButton = vi.fn();
describe("PanelInfoView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with back button and all sections", async () => {
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
// Check for back button
const backButton = screen.getByText("common.back");
expect(backButton).toBeInTheDocument();
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
// Check images
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
// Check text content (Tolgee keys)
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
).toBeInTheDocument();
// Check "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
// Click back button
await userEvent.click(backButton);
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
});
test("renders correctly without back button when disableBack is true", () => {
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
});
});

View File

@@ -1,98 +0,0 @@
"use client";
import ProlificLogo from "@/images/prolific-logo.webp";
import ProlificUI from "@/images/prolific-screenshot.webp";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
interface PanelInfoViewProps {
disableBack: boolean;
handleInitialPageButton: () => void;
}
export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden text-slate-900">
{!disableBack && (
<div className="border-b border-slate-200 py-2">
<Button variant="ghost" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-2">
<div className="flex flex-col gap-y-6 border-r border-slate-200 p-8">
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.when_do_i_need_it_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_prolific")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_prolific_answer")}</p>
</div>
</div>
<div className="relative flex flex-col gap-y-6 bg-slate-50 p-8">
<Image
src={ProlificLogo}
alt="Prolific panel selection UI"
className="absolute right-8 top-8 w-32"
/>
<div>
<h3 className="text-xl font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel")}
</h3>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_1")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_1_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_2")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_2_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_3")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_3_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_4")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_4_description")}
</p>
</div>
<Button className="justify-center" asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,519 @@
import { generatePersonalLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PersonalLinksTab } from "./personal-links-tab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
generatePersonalLinksAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: any) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, loading, className, ...props }: any) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled}
data-loading={loading}
className={className}
{...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/date-picker", () => ({
DatePicker: ({ date, updateSurveyDate, minDate, onClearDate }: any) => (
<div data-testid="date-picker">
<input
data-testid="date-input"
type="date"
value={date ? date.toISOString().split("T")[0] : ""}
onChange={(e) => {
const newDate = e.target.value ? new Date(e.target.value) : null;
updateSurveyDate(newDate);
}}
/>
<button data-testid="clear-date" onClick={() => onClearDate()}>
Clear
</button>
<div data-testid="min-date">{minDate ? minDate.toISOString() : ""}</div>
</div>
),
}));
vi.mock("@/modules/ui/components/select", () => {
let globalOnValueChange: ((value: string) => void) | null = null;
return {
Select: ({ children, value, onValueChange, disabled }: any) => {
globalOnValueChange = onValueChange;
return (
<div data-testid="select" data-disabled={disabled} data-value={value}>
<div data-testid="select-current-value">{value || "Select option"}</div>
{children}
</div>
);
},
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div
data-testid="select-item"
data-value={value}
onClick={() => {
if (globalOnValueChange) {
globalOnValueChange(value);
}
}}>
{children}
</div>
),
SelectTrigger: ({ children, className }: any) => (
<div data-testid="select-trigger" className={className}>
{children}
</div>
),
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
};
});
// Mock icons
vi.mock("lucide-react", () => ({
DownloadIcon: () => <div data-testid="download-icon" />,
KeyIcon: () => <div data-testid="key-icon" />,
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: ({ children, href, target, rel }: any) => (
<a data-testid="link" href={href} target={target} rel={rel}>
{children}
</a>
),
}));
const mockGeneratePersonalLinksAction = vi.mocked(generatePersonalLinksAction);
const mockToast = vi.mocked(toast);
const mockGetFormattedErrorMessage = vi.mocked(getFormattedErrorMessage);
// Mock segments data
const mockSegments = [
{
id: "segment1",
title: "Public Segment 1",
isPrivate: false,
description: "Test segment 1",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment2",
title: "Public Segment 2",
isPrivate: false,
description: "Test segment 2",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment3",
title: "Private Segment",
isPrivate: true,
description: "Test private segment",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
];
const defaultProps = {
environmentId: "test-env-id",
surveyId: "test-survey-id",
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
// Helper function to trigger select change
const selectOption = (value: string) => {
const selectItems = screen.getAllByTestId("select-item");
const targetItem = selectItems.find((item) => item.getAttribute("data-value") === value);
if (targetItem) {
fireEvent.click(targetItem);
}
};
describe("PersonalLinksTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders the component with correct title and description", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_description")
).toBeInTheDocument();
});
test("renders recipients section with segment selection", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("common.recipients")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
});
test("renders expiry date section with date picker", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
});
test("renders generate button with correct initial state", () => {
render(<PersonalLinksTab {...defaultProps} />);
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
test("renders info alert with correct content", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
).toBeInTheDocument();
expect(screen.getByTestId("link")).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
);
});
test("filters out private segments and shows only public segments", () => {
render(<PersonalLinksTab {...defaultProps} />);
const selectItems = screen.getAllByTestId("select-item");
expect(selectItems).toHaveLength(2); // Only public segments
expect(selectItems[0]).toHaveTextContent("Public Segment 1");
expect(selectItems[1]).toHaveTextContent("Public Segment 2");
});
test("shows no segments message when no public segments available", () => {
const propsWithPrivateSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segment
};
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("button")).toBeDisabled();
});
test("enables button when segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
// Initially disabled
expect(screen.getByTestId("button")).toBeDisabled();
// Select a segment
selectOption("segment1");
// Should now be enabled
expect(screen.getByTestId("button")).not.toBeDisabled();
});
test("handles date selection correctly", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const testDate = "2024-12-31";
fireEvent.change(dateInput, { target: { value: testDate } });
expect(dateInput).toHaveValue(testDate);
});
test("clears date when clear button is clicked", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const clearButton = screen.getByTestId("clear-date");
// Set a date first
fireEvent.change(dateInput, { target: { value: "2024-12-31" } });
// Clear the date
fireEvent.click(clearButton);
expect(dateInput).toHaveValue("");
});
test("sets minimum date to tomorrow", () => {
render(<PersonalLinksTab {...defaultProps} />);
const minDateElement = screen.getByTestId("min-date");
// Should have some ISO date string for a future date
expect(minDateElement.textContent).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
test("successfully generates and downloads links", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 5,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify action was called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Verify loading toast
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("generates links with expiry date when date is selected", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 3,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date (10 days from now)
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 9-10 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(9);
expect(callArgs.expirationDays).toBeLessThanOrEqual(10);
});
test("handles error response from generatePersonalLinksAction", async () => {
const mockErrorResult = {
serverError: "Test error message",
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockErrorResult);
mockGetFormattedErrorMessage.mockReturnValue("Formatted error message");
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Wait for the action to be called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Wait for error handling
await waitFor(() => {
expect(mockGetFormattedErrorMessage).toHaveBeenCalledWith(mockErrorResult);
expect(mockToast.error).toHaveBeenCalledWith("Formatted error message", {
duration: 5000,
id: "generating-links",
});
});
});
test("shows generating state when triggered", async () => {
// Mock a promise that resolves quickly
const mockResult = { data: { downloadUrl: "test", fileName: "test.csv", count: 1 } };
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify loading toast is called
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("button is disabled when no segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("button is disabled when no public segments are available", () => {
const propsWithNoPublicSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segments
};
render(<PersonalLinksTab {...propsWithNoPublicSegments} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("handles empty segments array", () => {
const propsWithEmptySegments = {
...defaultProps,
segments: [],
};
render(<PersonalLinksTab {...propsWithEmptySegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeDisabled();
});
test("calculates expiration days correctly for different dates", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "test.csv",
count: 1,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date to 5 days from now
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 4-5 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(4);
expect(callArgs.expirationDays).toBeLessThanOrEqual(5);
});
});

View File

@@ -0,0 +1,231 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSegment } from "@formbricks/types/segment";
import { generatePersonalLinksAction } from "../../actions";
interface PersonalLinksTabProps {
environmentId: string;
surveyId: string;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
// Custom DatePicker component with date restrictions
const RestrictedDatePicker = ({
date,
updateSurveyDate,
}: {
date: Date | null;
updateSurveyDate: (date: Date | null) => void;
}) => {
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const handleDateUpdate = (date: Date) => {
updateSurveyDate(date);
};
return (
<DatePicker
date={date}
updateSurveyDate={handleDateUpdate}
minDate={tomorrow}
onClearDate={() => updateSurveyDate(null)}
/>
);
};
export const PersonalLinksTab = ({
environmentId,
segments,
surveyId,
isContactsEnabled,
isFormbricksCloud,
}: PersonalLinksTabProps) => {
const { t } = useTranslate();
const [selectedSegment, setSelectedSegment] = useState<string>("");
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const publicSegments = segments.filter((segment) => !segment.isPrivate);
// Utility function for file downloads
const downloadFile = (url: string, filename: string) => {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleGenerateLinks = async () => {
if (!selectedSegment || isGenerating) return;
setIsGenerating(true);
// Show initial toast
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
duration: 5000,
id: "generating-links",
});
const result = await generatePersonalLinksAction({
surveyId: surveyId,
segmentId: selectedSegment,
environmentId: environmentId,
expirationDays: expiryDate
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
: undefined,
});
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage, {
duration: 5000,
id: "generating-links",
});
}
setIsGenerating(false);
};
// Button state logic
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
const buttonText = isGenerating
? t("environments.surveys.summary.generating_links")
: t("environments.surveys.summary.generate_and_download_links");
if (!isContactsEnabled) {
return (
<UpgradePrompt
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
);
}
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<h2 className="mb-2 text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.generate_personal_links_title")}
</h2>
<p className="text-sm text-slate-600">
{t("environments.surveys.summary.generate_personal_links_description")}
</p>
</div>
<div className="space-y-6">
{/* Recipients Section */}
<div>
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
{t("common.recipients")}
</label>
<Select
value={selectedSegment}
onValueChange={setSelectedSegment}
disabled={publicSegments.length === 0}>
<SelectTrigger id="segment-select" className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.summary.no_segments_available")
: t("environments.surveys.summary.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.create_and_manage_segments")}
</p>
</div>
{/* Expiry Date Section */}
<div>
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.surveys.summary.expiry_date_optional")}
</label>
<div id="expiry-date-picker">
<RestrictedDatePicker
date={expiryDate}
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
/>
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.expiry_date_description")}
</p>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<Alert variant="info" size="small">
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
<AlertButton>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
);
};

View File

@@ -101,7 +101,7 @@ const mockProject = {
highlightBorderColor: null,
cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" },
cardBorderColor: { light: "#FFFFFF", dark: "#000000" },
cardShadowColor: { light: "#FFFFFF", dark: "#000000" },
questionColor: { light: "#FFFFFF", dark: "#000000" },
inputColor: { light: "#FFFFFF", dark: "#000000" },
inputBorderColor: { light: "#FFFFFF", dark: "#000000" },
@@ -123,7 +123,7 @@ const mockComputedStyling = {
inputBorderColor: "#000000",
cardBackgroundColor: "#FFFFFF",
cardBorderColor: "#EEEEEE",
cardShadowColor: "#AAAAAA",
highlightBorderColor: null,
thankYouCardIconColor: "#007BFF",
thankYouCardIconBgColor: "#DDDDDD",

View File

@@ -2,10 +2,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -36,6 +38,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) {
throw new Error(t("common.user_not_found"));
}
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
@@ -54,6 +58,9 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />

View File

@@ -0,0 +1 @@
export { POST } from "@/modules/ee/contacts/api/v2/management/contacts/route";

View File

@@ -31,6 +31,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SENTRY_RELEASE: "mock-sentry-release",
SENTRY_ENVIRONMENT: "mock-sentry-environment",
}));
vi.mock("@/tolgee/language", () => ({
@@ -59,9 +61,18 @@ vi.mock("@/tolgee/client", () => ({
}));
vi.mock("@/app/sentry/SentryProvider", () => ({
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
SentryProvider: ({
children,
sentryDsn,
sentryRelease,
}: {
children: React.ReactNode;
sentryDsn?: string;
sentryRelease?: string;
}) => (
<div data-testid="sentry-provider">
SentryProvider: {sentryDsn}
{sentryRelease && ` - Release: ${sentryRelease}`}
{children}
</div>
),

View File

@@ -1,5 +1,5 @@
import { SentryProvider } from "@/app/sentry/SentryProvider";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
@@ -25,7 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}>
<SentryProvider
sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE}
sentryEnvironment={SENTRY_ENVIRONMENT}
isEnabled={IS_PRODUCTION}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>

View File

@@ -3517,21 +3517,7 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
styling: null,
segment: null,
questions: [
{
...buildRatingQuestion({
id: "lbdxozwikh838yc6a8vbwuju",
range: 5,
scale: "star",
headline: t("templates.preview_survey_question_1_headline", { projectName }),
required: true,
subheader: t("templates.preview_survey_question_1_subheader"),
lowerLabel: t("templates.preview_survey_question_1_lower_label"),
upperLabel: t("templates.preview_survey_question_1_upper_label"),
t,
}),
isDraft: true,
},
{
{
...buildMultipleChoiceQuestion({
id: "rjpu42ps6dzirsn9ds6eydgt",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
@@ -3548,6 +3534,20 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
}),
isDraft: true,
},
{
...buildRatingQuestion({
id: "lbdxozwikh838yc6a8vbwuju",
range: 5,
scale: "star",
headline: t("templates.preview_survey_question_1_headline", { projectName }),
required: true,
subheader: t("templates.preview_survey_question_1_subheader"),
lowerLabel: t("templates.preview_survey_question_1_lower_label"),
upperLabel: t("templates.preview_survey_question_1_upper_label"),
t,
}),
isDraft: true,
},
],
endings: [
{

View File

@@ -48,6 +48,24 @@ describe("SentryProvider", () => {
);
});
test("calls Sentry.init with sentryRelease when provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
const testRelease = "v1.2.3";
render(
<SentryProvider sentryDsn={sentryDsn} sentryRelease={testRelease} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: sentryDsn,
release: testRelease,
})
);
});
test("does not call Sentry.init when sentryDsn is not provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);

View File

@@ -6,14 +6,24 @@ import { useEffect } from "react";
interface SentryProviderProps {
children: React.ReactNode;
sentryDsn?: string;
sentryRelease?: string;
sentryEnvironment?: string;
isEnabled?: boolean;
}
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => {
export const SentryProvider = ({
children,
sentryDsn,
sentryRelease,
sentryEnvironment,
isEnabled,
}: SentryProviderProps) => {
useEffect(() => {
if (sentryDsn && isEnabled) {
Sentry.init({
dsn: sentryDsn,
release: sentryRelease,
environment: sentryEnvironment,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,

View File

@@ -233,8 +233,8 @@ export enum STRIPE_PROJECT_NAMES {
}
export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MONTHLY = "formbricks_startup_monthly",
STARTUP_YEARLY = "formbricks_startup_yearly",
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
SCALE_MONTHLY = "formbricks_scale_monthly",
SCALE_YEARLY = "formbricks_scale_yearly",
}
@@ -273,17 +273,30 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
// Use the app version for Sentry release (updated during build in production)
// Fallback to environment variable if package.json is not accessible
export const SENTRY_RELEASE = (() => {
if (process.env.NODE_ENV !== "production") {
return undefined;
}
// Try to read from package.json with proper error handling
try {
const pkg = require("../package.json");
return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`;
} catch {
// If package.json can't be read (e.g., in some deployment scenarios),
// return undefined and let Sentry work without release tracking
return undefined;
}
})();
export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT;
export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
export const AUDIT_LOG_ENABLED =
env.AUDIT_LOG_ENABLED === "1" &&
env.REDIS_URL &&
env.REDIS_URL !== "" &&
env.ENCRYPTION_KEY &&
env.ENCRYPTION_KEY !== ""; // The audit log requires Redis to be configured
export const AUDIT_LOG_ENABLED = env.AUDIT_LOG_ENABLED === "1";
export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;

View File

@@ -127,6 +127,7 @@ export const env = createEnv({
.string()
.transform((val) => parseInt(val))
.optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
},
/*
@@ -225,5 +226,6 @@ export const env = createEnv({
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
},
});

View File

@@ -8,7 +8,6 @@ export const COLOR_DEFAULTS = {
inputBorderColor: "#cbd5e1",
cardBackgroundColor: "#ffffff",
cardBorderColor: "#f8fafc",
cardShadowColor: "#000000",
highlightBorderColor: "#64748b",
} as const;
@@ -32,9 +31,6 @@ export const defaultStyling: TProjectStyling = {
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
cardShadowColor: {
light: COLOR_DEFAULTS.cardShadowColor,
},
isLogoHidden: false,
highlightBorderColor: undefined,
isDarkModeEnabled: false,

View File

@@ -143,7 +143,6 @@ export const mockPrismaPerson: Prisma.ContactGetPayload<{
include: typeof selectContact;
}> = {
id: mockId,
userId: mockId,
attributes: [
{
value: "de",

View File

@@ -1,5 +1,8 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TTag } from "@formbricks/types/tags";
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
@@ -110,7 +113,7 @@ describe("Tag Service", () => {
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
const result = await createTag("env1", "New Tag");
expect(result).toEqual(mockTag);
expect(result).toEqual({ ok: true, data: mockTag });
expect(prisma.tag.create).toHaveBeenCalledWith({
data: {
name: "New Tag",
@@ -118,5 +121,30 @@ describe("Tag Service", () => {
},
});
});
test("should handle duplicate tag name error", async () => {
// const duplicateError = new Error("Unique constraint failed");
// (duplicateError as any).code = "P2002";
const duplicateError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "4.0.0",
});
vi.mocked(prisma.tag.create).mockRejectedValue(duplicateError);
const result = await createTag("env1", "Duplicate Tag");
expect(result).toEqual({
ok: false,
error: { message: "Tag with this name already exists", code: TagError.TAG_NAME_ALREADY_EXISTS },
});
});
test("should handle general database errors", async () => {
const generalError = new Error("Database connection failed");
vi.mocked(prisma.tag.create).mockRejectedValue(generalError);
const result = await createTag("env1", "New Tag");
expect(result).toStrictEqual({
ok: false,
error: { message: "Database connection failed", code: TagError.UNEXPECTED_ERROR },
});
});
});
});

View File

@@ -1,7 +1,11 @@
import "server-only";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TTag } from "@formbricks/types/tags";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -42,7 +46,10 @@ export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
}
});
export const createTag = async (environmentId: string, name: string): Promise<TTag> => {
export const createTag = async (
environmentId: string,
name: string
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
validateInputs([environmentId, ZId], [name, ZString]);
try {
@@ -53,8 +60,19 @@ export const createTag = async (environmentId: string, name: string): Promise<TT
},
});
return tag;
return ok(tag);
} catch (error) {
throw error;
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
code: TagError.TAG_NAME_ALREADY_EXISTS,
message: "Tag with this name already exists",
});
}
}
return err({
code: TagError.UNEXPECTED_ERROR,
message: error.message,
});
}
};

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.",
"upgrade_successful": "Upgrade erfolgreich"
},
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
},
"common": {
"accepted": "Akzeptiert",
"account": "Konto",
@@ -135,7 +139,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",
@@ -313,9 +316,11 @@
"question_id": "Frage-ID",
"questions": "Fragen",
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"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",
@@ -411,7 +416,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.",
@@ -596,6 +600,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",
@@ -632,6 +637,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",
@@ -699,7 +705,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."
},
@@ -721,6 +726,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."
},
@@ -905,8 +911,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",
@@ -979,63 +984,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",
@@ -1074,6 +1069,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:",
@@ -1160,6 +1156,7 @@
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Dauerhafte Entfernung all deiner persönlichen Informationen und Daten",
"personal_information": "Persönliche Informationen",
"personal_information_updated": "Persönliche Informationen aktualisiert",
"please_enter_email_to_confirm_account_deletion": "Bitte gib {email} in das folgende Feld ein, um die endgültige Löschung deines Kontos zu bestätigen:",
"profile_updated_successfully": "Dein Profil wurde erfolgreich aktualisiert",
"remove_image": "Bild entfernen",
@@ -1230,6 +1227,7 @@
"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?",
"edit": {
@@ -1251,6 +1249,8 @@
"add_description": "Beschreibung hinzufügen",
"add_ending": "Abschluss hinzufügen",
"add_ending_below": "Abschluss unten hinzufügen",
"add_fallback": "Hinzufügen",
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
@@ -1304,17 +1304,15 @@
"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",
"caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?",
"caution_explanation_all_data_as_download": "Alle Daten, einschließlich früherer Antworten, stehen als Download zur Verfügung.",
"caution_explanation_intro": "Wir verstehen, dass du vielleicht noch Änderungen vornehmen möchtest. Hier erfährst du, was passiert, wenn du das tust:",
"caution_explanation_new_responses_separated": "Neue Antworten werden separat gesammelt.",
"caution_explanation_only_new_responses_in_summary": "Nur neue Antworten erscheinen in der Umfragezusammenfassung.",
"caution_explanation_responses_are_safe": "Vorhandene Antworten bleiben sicher.",
"caution_recommendation": "Das Bearbeiten deiner Umfrage kann zu Dateninkonsistenzen in der Umfragezusammenfassung führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_explanation_new_responses_separated": "Antworten vor der Änderung werden möglicherweise nicht oder nur teilweise in der Umfragezusammenfassung berücksichtigt.",
"caution_explanation_only_new_responses_in_summary": "Alle Daten, einschließlich früherer Antworten, bleiben auf der Umfrageübersichtsseite als Download verfügbar.",
"caution_explanation_responses_are_safe": "Ältere und neuere Antworten vermischen sich, was zu irreführenden Datensummen führen kann.",
"caution_recommendation": "Dies kann im Umfrageübersicht zu Dateninkonsistenzen führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"change_anyway": "Trotzdem ändern",
@@ -1329,7 +1327,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",
@@ -1392,6 +1389,7 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
"everyone": "Jeder",
"fallback_for": "Ersatz für",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
@@ -1716,6 +1714,7 @@
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
"create_single_use_links": "Single-Use Links erstellen",
"create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
"custom_range": "Benutzerdefinierter Bereich...",
@@ -1734,23 +1733,21 @@
"embed_on_website": "Auf Website einbetten",
"embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet",
"embed_survey": "Umfrage einbetten",
"expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.",
"expiry_date_optional": "Ablaufdatum (optional)",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
"formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau",
"generate_and_download_links": "Links generieren und herunterladen",
"generate_personal_links_description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu. Eine CSV-Datei Ihrer persönlichen Links inklusive relevanter Kontaktinformationen wird automatisch heruntergeladen.",
"generate_personal_links_title": "Maximieren Sie Erkenntnisse mit persönlichen Umfragelinks",
"generating_links": "Links werden generiert",
"generating_links_toast": "Links werden generiert, der Download startet in Kürze…",
"go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49",
"hide_embed_code": "Einbettungscode ausblenden",
"how_to_create_a_panel": "Wie man ein Panel erstellt",
"how_to_create_a_panel_step_1": "Schritt 1: Erstelle ein Konto bei Prolific",
"how_to_create_a_panel_step_1_description": "Erstelle ein Konto bei Prolific und bestätige deine E-Mail-Adresse.",
"how_to_create_a_panel_step_2": "Schritt 2: Eine Studie erstellen",
"how_to_create_a_panel_step_2_description": "Bei Prolific erstellst Du eine neue Studie, bei der Du dein bevorzugtes Publikum basierend auf Hunderten von Merkmalen auswählen kannst.",
"how_to_create_a_panel_step_3": "Schritt 3: Verbinde deine Umfrage",
"how_to_create_a_panel_step_3_description": "Richte in deiner Formbricks-Umfrage versteckte Felder ein, um nachzuverfolgen, welcher Teilnehmer welche Antwort gegeben hat.",
"how_to_create_a_panel_step_4": "Schritt 4: Starte deine Studie",
"how_to_create_a_panel_step_4_description": "Sobald alles eingerichtet ist, kannst Du deine Studie starten. Innerhalb weniger Stunden wirst Du die ersten Antworten erhalten.",
"impressions": "Eindrücke",
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
"includes_all": "Beinhaltet alles",
@@ -1765,12 +1762,18 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
"links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.",
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
"mobile_app": "Mobile App",
"no_responses_found": "Keine Antworten gefunden",
"no_segments_available": "Keine Segmente verfügbar",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"personal_links": "Persönliche Links",
"personal_links_upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.",
"personal_links_upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
"personal_links_work_with_segments": "Persönliche Links funktionieren mit Segmenten.",
"publish_to_web": "Im Web veröffentlichen",
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
@@ -1779,6 +1782,7 @@
"quickstart_web_apps": "Schnellstart: Web-Apps",
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
"results_are_public": "Ergebnisse sind öffentlich",
"select_segment": "Segment auswählen",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"send_preview": "Vorschau senden",
@@ -1813,13 +1817,7 @@
"view_site": "Seite ansehen",
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8",
"web_app": "Web-App",
"what_is_a_panel": "Was ist ein Panel?",
"what_is_a_panel_answer": "Ein Panel ist eine Gruppe von Teilnehmern, die basierend auf Merkmalen wie Alter, Beruf, Geschlecht usw. ausgewählt werden.",
"what_is_prolific": "Was ist Prolific?",
"what_is_prolific_answer": "Wir arbeiten mit Prolific zusammen, um dir Zugang zu einem Pool von über 200.000 geprüften Teilnehmern zu geben.",
"whats_next": "Was kommt als Nächstes?",
"when_do_i_need_it": "Wann brauche ich das?",
"when_do_i_need_it_answer": "Wenn Du keinen Zugang zu genügend Leuten hast, die deiner Zielgruppe entsprechen, macht es Sinn, für ein Panel zu bezahlen.",
"you_can_do_a_lot_more_with_links_surveys": "Mit Links-Umfragen kannst Du viel mehr machen \uD83D\uDCA1",
"your_survey_is_public": "Deine Umfrage ist öffentlich",
"youre_not_plugged_in_yet": "Du bist noch nicht verbunden!"
@@ -2587,7 +2585,7 @@
"preview_survey_question_2_back_button_label": "Zurück",
"preview_survey_question_2_choice_1_label": "Ja, halte mich auf dem Laufenden.",
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Willst du auf dem Laufenden bleiben?",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_welcome_card_headline": "Willkommen!",
"preview_survey_welcome_card_html": "Danke für dein Feedback - los geht's!",
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.",
"upgrade_successful": "Upgrade successful"
},
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
},
"common": {
"accepted": "Accepted",
"account": "Account",
@@ -135,7 +139,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",
@@ -313,9 +316,11 @@
"question_id": "Question ID",
"questions": "Questions",
"read_docs": "Read Docs",
"recipients": "Recipients",
"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",
@@ -411,7 +416,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.",
@@ -596,6 +600,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",
@@ -632,6 +637,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",
@@ -699,7 +705,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."
},
@@ -721,6 +726,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."
},
@@ -905,8 +911,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",
@@ -979,63 +984,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",
@@ -1074,6 +1069,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:",
@@ -1160,6 +1156,7 @@
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Permanent removal of all of your personal information and data",
"personal_information": "Personal information",
"personal_information_updated": "Personal information updated",
"please_enter_email_to_confirm_account_deletion": "Please enter {email} in the following field to confirm the definitive deletion of your account:",
"profile_updated_successfully": "Your profile was updated successfully",
"remove_image": "Remove image",
@@ -1230,6 +1227,7 @@
"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?",
"edit": {
@@ -1251,6 +1249,8 @@
"add_description": "Add description",
"add_ending": "Add ending",
"add_ending_below": "Add ending below",
"add_fallback": "Add",
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
"add_hidden_field_id": "Add hidden field ID",
"add_highlight_border": "Add highlight border",
"add_highlight_border_description": "Add an outer border to your survey card.",
@@ -1304,17 +1304,15 @@
"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",
"caution_edit_published_survey": "Edit a published survey?",
"caution_explanation_all_data_as_download": "All data, including past responses are available as download.",
"caution_explanation_intro": "We understand you might still want to make changes. Heres what happens if you do: ",
"caution_explanation_new_responses_separated": "New responses are collected separately.",
"caution_explanation_only_new_responses_in_summary": "Only new responses appear in the survey summary.",
"caution_explanation_responses_are_safe": "Existing responses remain safe.",
"caution_recommendation": "Editing your survey may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.",
"caution_explanation_new_responses_separated": "Responses before the change may not or only partially be included in the survey summary.",
"caution_explanation_only_new_responses_in_summary": "All data, including past responses, remain available as download on the survey summary page.",
"caution_explanation_responses_are_safe": "Older and newer responses get mixed which can lead to misleading data summaries.",
"caution_recommendation": "This may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.",
"caution_text": "Changes will lead to inconsistencies",
"centered_modal_overlay_color": "Centered modal overlay color",
"change_anyway": "Change anyway",
@@ -1329,7 +1327,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",
@@ -1392,6 +1389,7 @@
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
"everyone": "Everyone",
"fallback_for": "Fallback for ",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
"field_name_eg_score_price": "Field name e.g, score, price",
@@ -1716,6 +1714,7 @@
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"copy_link_to_public_results": "Copy link to public results",
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
"create_single_use_links": "Create single-use links",
"create_single_use_links_description": "Accept only one submission per link. Here is how.",
"custom_range": "Custom range...",
@@ -1734,23 +1733,21 @@
"embed_on_website": "Embed on website",
"embed_pop_up_survey_title": "How to embed a pop-up survey on your website",
"embed_survey": "Embed survey",
"expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.",
"expiry_date_optional": "Expiry date (optional)",
"failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
"filtered_responses_excel": "Filtered responses (Excel)",
"formbricks_email_survey_preview": "Formbricks Email Survey Preview",
"generate_and_download_links": "Generate & download links",
"generate_personal_links_description": "Generate personal links for a segment and match survey responses to each contact. A CSV of you personal links incl. relevant contact information will be downloaded automatically.",
"generate_personal_links_title": "Maximize insights with personal survey links",
"generating_links": "Generating links",
"generating_links_toast": "Generating links, download will start soon…",
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
"hide_embed_code": "Hide embed code",
"how_to_create_a_panel": "How to create a panel",
"how_to_create_a_panel_step_1": "Step 1: Create an account with Prolific",
"how_to_create_a_panel_step_1_description": "Create an account with Prolific and verify your email address.",
"how_to_create_a_panel_step_2": "Step 2: Create a study",
"how_to_create_a_panel_step_2_description": "At Prolific, you create a new study where you can pick your preferred audience based on hundreds of characteristics.",
"how_to_create_a_panel_step_3": "Step 3: Connect your survey",
"how_to_create_a_panel_step_3_description": "Set up hidden fields in your Formbricks survey to track which participant provided which answer.",
"how_to_create_a_panel_step_4": "Step 4: Launch your study",
"how_to_create_a_panel_step_4_description": "Once everything is setup, you can launch your study. Within a few hours youll receive the first responses.",
"impressions": "Impressions",
"impressions_tooltip": "Number of times the survey has been viewed.",
"includes_all": "Includes all",
@@ -1765,12 +1762,18 @@
"last_quarter": "Last quarter",
"last_year": "Last year",
"link_to_public_results_copied": "Link to public results copied",
"links_generated_success_toast": "Links generated successfully, your download will start soon.",
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
"mobile_app": "Mobile app",
"no_responses_found": "No responses found",
"no_segments_available": "No segments available",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
"personal_links": "Personal links",
"personal_links_upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.",
"personal_links_upgrade_prompt_title": "Use personal links with a higher plan",
"personal_links_work_with_segments": "Personal links work with segments.",
"publish_to_web": "Publish to web",
"publish_to_web_warning": "You are about to release these survey results to the public.",
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
@@ -1779,6 +1782,7 @@
"quickstart_web_apps": "Quickstart: Web apps",
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
"results_are_public": "Results are public",
"select_segment": "Select segment",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"send_preview": "Send preview",
@@ -1813,13 +1817,7 @@
"view_site": "View site",
"waiting_for_response": "Waiting for a response \uD83E\uDDD8",
"web_app": "Web app",
"what_is_a_panel": "What is a panel?",
"what_is_a_panel_answer": "A panel is a group of participants selected based on characteristics such as age, profession, gender, etc.",
"what_is_prolific": "What is Prolific?",
"what_is_prolific_answer": "We're partnering with Prolific to give you access to a pool of over 200.000 vetted participants.",
"whats_next": "What's next?",
"when_do_i_need_it": "When do I need it?",
"when_do_i_need_it_answer": "If you dont have access to enough people who match your target audience, it makes sense to pay for access to a panel.",
"you_can_do_a_lot_more_with_links_surveys": "You can do a lot more with links surveys \uD83D\uDCA1",
"your_survey_is_public": "Your survey is public",
"youre_not_plugged_in_yet": "You're not plugged in yet!"
@@ -2587,7 +2585,7 @@
"preview_survey_question_2_back_button_label": "Back",
"preview_survey_question_2_choice_1_label": "Yes, keep me informed.",
"preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "What to stay in the loop?",
"preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_welcome_card_headline": "Welcome!",
"preview_survey_welcome_card_html": "Thanks for providing your feedback - let's go!",
"prioritize_features_description": "Identify features your users need most and least.",

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.",
"upgrade_successful": "Mise à niveau réussie"
},
"c": {
"link_expired": "Votre lien est expiré.",
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
},
"common": {
"accepted": "Accepté",
"account": "Compte",
@@ -135,7 +139,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",
@@ -313,9 +316,11 @@
"question_id": "ID de la question",
"questions": "Questions",
"read_docs": "Lire les documents",
"recipients": "Destinataires",
"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",
@@ -411,7 +416,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.",
@@ -596,6 +600,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",
@@ -632,6 +637,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",
@@ -699,7 +705,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."
},
@@ -721,6 +726,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."
},
@@ -905,8 +911,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",
@@ -979,63 +984,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",
@@ -1074,6 +1069,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 :",
@@ -1160,6 +1156,7 @@
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Suppression permanente de toutes vos informations et données personnelles.",
"personal_information": "Informations personnelles",
"personal_information_updated": "Informations personnelles mises à jour",
"please_enter_email_to_confirm_account_deletion": "Veuillez entrer {email} dans le champ suivant pour confirmer la suppression définitive de votre compte :",
"profile_updated_successfully": "Votre profil a été mis à jour avec succès.",
"remove_image": "Supprimer l'image",
@@ -1230,6 +1227,7 @@
"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?",
"edit": {
@@ -1251,6 +1249,8 @@
"add_description": "Ajouter une description",
"add_ending": "Ajouter une fin",
"add_ending_below": "Ajouter une fin ci-dessous",
"add_fallback": "Ajouter",
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
"add_hidden_field_id": "Ajouter un champ caché ID",
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
@@ -1304,17 +1304,15 @@
"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",
"caution_edit_published_survey": "Modifier un sondage publié ?",
"caution_explanation_all_data_as_download": "Toutes les données, y compris les réponses passées, sont disponibles en téléchargement.",
"caution_explanation_intro": "Nous comprenons que vous souhaitiez encore apporter des modifications. Voici ce qui se passe si vous le faites : ",
"caution_explanation_new_responses_separated": "Les nouvelles réponses sont collectées séparément.",
"caution_explanation_only_new_responses_in_summary": "Seules les nouvelles réponses apparaissent dans le résumé de l'enquête.",
"caution_explanation_responses_are_safe": "Les réponses existantes restent en sécurité.",
"caution_recommendation": "Modifier votre enquête peut entraîner des incohérences dans le résumé de l'enquête. Nous vous recommandons de dupliquer l'enquête à la place.",
"caution_explanation_new_responses_separated": "Les réponses avant le changement peuvent ne pas être ou ne faire partie que partiellement du résumé de l'enquête.",
"caution_explanation_only_new_responses_in_summary": "Toutes les données, y compris les réponses passées, restent disponibles en téléchargement sur la page de résumé de l'enquête.",
"caution_explanation_responses_are_safe": "Les réponses anciennes et nouvelles se mélangent, ce qui peut entraîner des résumés de données trompeurs.",
"caution_recommendation": "Cela peut entraîner des incohérences de données dans le résumé du sondage. Nous recommandons de dupliquer le sondage à la place.",
"caution_text": "Les changements entraîneront des incohérences.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"change_anyway": "Changer de toute façon",
@@ -1329,7 +1327,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",
@@ -1392,6 +1389,7 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
"everyone": "Tout le monde",
"fallback_for": "Solution de repli pour ",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
@@ -1716,6 +1714,7 @@
"congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
"create_single_use_links": "Créer des liens à usage unique",
"create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.",
"custom_range": "Plage personnalisée...",
@@ -1734,23 +1733,21 @@
"embed_on_website": "Incorporer sur le site web",
"embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web",
"embed_survey": "Intégrer l'enquête",
"expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.",
"expiry_date_optional": "Date d'expiration (facultatif)",
"failed_to_copy_link": "Échec de la copie du lien",
"filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)",
"filtered_responses_excel": "Réponses filtrées (Excel)",
"formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks",
"generate_and_download_links": "Générer et télécharger les liens",
"generate_personal_links_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact. Un fichier CSV de vos liens personnels incluant les informations de contact pertinentes sera téléchargé automatiquement.",
"generate_personal_links_title": "Maximisez les insights avec des liens d'enquête personnels",
"generating_links": "Génération de liens",
"generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…",
"go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49",
"hide_embed_code": "Cacher le code d'intégration",
"how_to_create_a_panel": "Comment créer un panneau",
"how_to_create_a_panel_step_1": "Étape 1 : Créez un compte avec Prolific",
"how_to_create_a_panel_step_1_description": "Créez un compte avec Prolific et vérifiez votre adresse e-mail.",
"how_to_create_a_panel_step_2": "Étape 2 : Créer une étude",
"how_to_create_a_panel_step_2_description": "Chez Prolific, vous créez une nouvelle étude où vous pouvez choisir votre audience préférée en fonction de centaines de caractéristiques.",
"how_to_create_a_panel_step_3": "Étape 3 : Connectez votre enquête",
"how_to_create_a_panel_step_3_description": "Configurez des champs cachés dans votre enquête Formbricks pour suivre quel participant a fourni quelle réponse.",
"how_to_create_a_panel_step_4": "Étape 4 : Lancez votre étude",
"how_to_create_a_panel_step_4_description": "Une fois que tout est configuré, vous pouvez lancer votre étude. Dans quelques heures, vous recevrez les premières réponses.",
"impressions": "Impressions",
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
"includes_all": "Comprend tous",
@@ -1765,12 +1762,18 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"link_to_public_results_copied": "Lien vers les résultats publics copié",
"links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.",
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
"mobile_app": "Application mobile",
"no_responses_found": "Aucune réponse trouvée",
"no_segments_available": "Aucun segment disponible",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"personal_links": "Liens personnels",
"personal_links_upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
"personal_links_upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
"personal_links_work_with_segments": "Les liens personnels fonctionnent avec les segments.",
"publish_to_web": "Publier sur le web",
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
@@ -1779,6 +1782,7 @@
"quickstart_web_apps": "Démarrage rapide : Applications web",
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
"results_are_public": "Les résultats sont publics.",
"select_segment": "Sélectionner le segment",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
"send_preview": "Envoyer un aperçu",
@@ -1813,13 +1817,7 @@
"view_site": "Voir le site",
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8",
"web_app": "application web",
"what_is_a_panel": "Qu'est-ce qu'un panneau ?",
"what_is_a_panel_answer": "Un panel est un groupe de participants sélectionnés en fonction de caractéristiques telles que l'âge, la profession, le sexe, etc.",
"what_is_prolific": "Qu'est-ce que Prolific ?",
"what_is_prolific_answer": "Nous nous associons à Prolific pour vous donner accès à un panel de plus de 200 000 participants vérifiés.",
"whats_next": "Qu'est-ce qui vient ensuite ?",
"when_do_i_need_it": "Quand en ai-je besoin ?",
"when_do_i_need_it_answer": "Si vous n'avez pas accès à suffisamment de personnes correspondant à votre public cible, il est logique de payer pour accéder à un panel.",
"you_can_do_a_lot_more_with_links_surveys": "Vous pouvez faire beaucoup plus avec des sondages par lien \uD83D\uDCA1",
"your_survey_is_public": "Votre enquête est publique.",
"youre_not_plugged_in_yet": "Vous n'êtes pas encore branché !"
@@ -2587,7 +2585,7 @@
"preview_survey_question_2_back_button_label": "Retour",
"preview_survey_question_2_choice_1_label": "Oui, tiens-moi au courant.",
"preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Tu veux rester dans la boucle ?",
"preview_survey_question_2_headline": "Vous voulez rester informé ?",
"preview_survey_welcome_card_headline": "Bienvenue !",
"preview_survey_welcome_card_html": "Merci pour vos retours - allons-y !",
"prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.",

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida"
},
"c": {
"link_expired": "Seu link está expirado.",
"link_expired_description": "O link que você usou não é mais válido."
},
"common": {
"accepted": "Aceito",
"account": "conta",
@@ -135,7 +139,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",
@@ -204,7 +207,7 @@
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
"gathering_responses": "Recolhendo respostas",
"general": "geral",
"general": "Geral",
"go_back": "Voltar",
"go_to_dashboard": "Ir para o Painel",
"hidden": "Escondido",
@@ -313,9 +316,11 @@
"question_id": "ID da Pergunta",
"questions": "Perguntas",
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"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",
@@ -373,7 +378,7 @@
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso",
"table_settings": "Arrumação da mesa",
"tags": "etiquetas",
"tags": "Etiquetas",
"targeting": "mirando",
"team": "Time",
"team_access": "Acesso da equipe",
@@ -411,7 +416,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.",
@@ -596,6 +600,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",
@@ -632,6 +637,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",
@@ -699,7 +705,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."
},
@@ -721,6 +726,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."
},
@@ -905,8 +911,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",
@@ -979,63 +984,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",
@@ -1074,6 +1069,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:",
@@ -1160,6 +1156,7 @@
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
"personal_information": "Informações pessoais",
"personal_information_updated": "Informações pessoais atualizadas",
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo abaixo para confirmar a exclusão definitiva da sua conta:",
"profile_updated_successfully": "Seu perfil foi atualizado com sucesso",
"remove_image": "Remover imagem",
@@ -1230,6 +1227,7 @@
"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?",
"edit": {
@@ -1251,6 +1249,8 @@
"add_description": "Adicionar Descrição",
"add_ending": "Adicionar final",
"add_ending_below": "Adicione o final abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar campo oculto ID",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
@@ -1304,17 +1304,15 @@
"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",
"caution_edit_published_survey": "Editar uma pesquisa publicada?",
"caution_explanation_all_data_as_download": "Todos os dados, incluindo respostas anteriores, estão disponíveis para download.",
"caution_explanation_intro": "Entendemos que você ainda pode querer fazer alterações. Aqui está o que acontece se você fizer:",
"caution_explanation_new_responses_separated": "Novas respostas são coletadas separadamente.",
"caution_explanation_only_new_responses_in_summary": "Apenas novas respostas aparecem no resumo da pesquisa.",
"caution_explanation_responses_are_safe": "As respostas existentes permanecem seguras.",
"caution_recommendation": "Editar sua pesquisa pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_explanation_new_responses_separated": "Respostas antes da mudança podem não ser ou apenas parcialmente incluídas no resumo da pesquisa.",
"caution_explanation_only_new_responses_in_summary": "Todos os dados, incluindo respostas anteriores, permanecem disponíveis para download na página de resumo da pesquisa.",
"caution_explanation_responses_are_safe": "Respostas antigas e novas são misturadas, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_text": "Mudanças vão levar a inconsistências",
"centered_modal_overlay_color": "cor de sobreposição modal centralizada",
"change_anyway": "Mudar mesmo assim",
@@ -1329,7 +1327,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",
@@ -1392,6 +1389,7 @@
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todo mundo",
"fallback_for": "Alternativa para",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
@@ -1558,7 +1556,7 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"roundness": "redondeza",
"roundness": "Circularidade",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"rows": "linhas",
"save_and_close": "Salvar e Fechar",
@@ -1594,7 +1592,7 @@
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
"straight": "hétero",
"straight": "Alinhado",
"style_the_question_texts_descriptions_and_input_fields": "Estilize os textos das perguntas, descrições e campos de entrada.",
"style_the_survey_card": "Estilize o cartão da pesquisa.",
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
@@ -1716,6 +1714,7 @@
"congrats": "Parabéns! Sua pesquisa está no ar.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
"create_single_use_links": "Crie links de uso único",
"create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...",
@@ -1734,23 +1733,21 @@
"embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site",
"embed_survey": "Incorporar pesquisa",
"expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks",
"generate_and_download_links": "Gerar & baixar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato. Um CSV dos seus links pessoais com as informações de contato relevantes será baixado automaticamente.",
"generate_personal_links_title": "Maximize insights com links de pesquisa personalizados",
"generating_links": "Gerando links",
"generating_links_toast": "Gerando links, o download começará em breve…",
"go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49",
"hide_embed_code": "Esconder código de incorporação",
"how_to_create_a_panel": "Como criar um painel",
"how_to_create_a_panel_step_1": "Passo 1: Crie uma conta no Prolific",
"how_to_create_a_panel_step_1_description": "Cria uma conta no Prolific e verifica teu e-mail.",
"how_to_create_a_panel_step_2": "Passo 2: Crie um estudo",
"how_to_create_a_panel_step_2_description": "Na Prolific, você cria um novo estudo onde pode escolher seu público preferido com base em centenas de características.",
"how_to_create_a_panel_step_3": "Passo 3: Conecte sua pesquisa",
"how_to_create_a_panel_step_3_description": "Configure campos ocultos na sua pesquisa do Formbricks para rastrear qual participante forneceu qual resposta.",
"how_to_create_a_panel_step_4": "Passo 4: Lançar seu estudo",
"how_to_create_a_panel_step_4_description": "Depois que tudo estiver configurado, você pode iniciar seu estudo. Em algumas horas, você vai receber as primeiras respostas.",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
"includes_all": "Inclui tudo",
@@ -1765,12 +1762,18 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"link_to_public_results_copied": "Link pros resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
"mobile_app": "app de celular",
"no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Nenhum segmento disponível",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.",
"personal_links_upgrade_prompt_title": "Use links pessoais com um plano superior",
"personal_links_work_with_segments": "Links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web",
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
@@ -1779,6 +1782,7 @@
"quickstart_web_apps": "Início rápido: Aplicativos web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar prévia",
@@ -1813,13 +1817,7 @@
"view_site": "Ver site",
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8",
"web_app": "aplicativo web",
"what_is_a_panel": "O que é um painel?",
"what_is_a_panel_answer": "Um painel é um grupo de participantes selecionados com base em características como idade, profissão, gênero, etc.",
"what_is_prolific": "O que é Prolific?",
"what_is_prolific_answer": "Estamos fazendo parceria com a Prolific pra te dar acesso a um grupo de mais de 200.000 participantes verificados.",
"whats_next": "E agora?",
"when_do_i_need_it": "Quando eu preciso disso?",
"when_do_i_need_it_answer": "Se você não tem acesso a pessoas suficientes que correspondam ao seu público-alvo, faz sentido pagar por acesso a um painel.",
"you_can_do_a_lot_more_with_links_surveys": "Você pode fazer muito mais com pesquisas de links \uD83D\uDCA1",
"your_survey_is_public": "Sua pesquisa é pública",
"youre_not_plugged_in_yet": "Você ainda não tá conectado!"

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida"
},
"c": {
"link_expired": "O seu link expirou.",
"link_expired_description": "O link que utilizou já não é válido."
},
"common": {
"accepted": "Aceite",
"account": "Conta",
@@ -135,7 +139,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",
@@ -313,9 +316,11 @@
"question_id": "ID da pergunta",
"questions": "Perguntas",
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"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",
@@ -411,7 +416,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.",
@@ -596,6 +600,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",
@@ -632,6 +637,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",
@@ -699,7 +705,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."
},
@@ -721,6 +726,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."
},
@@ -905,8 +911,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",
@@ -979,63 +984,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",
@@ -1074,6 +1069,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:",
@@ -1160,6 +1156,7 @@
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
"personal_information": "Informações pessoais",
"personal_information_updated": "Informações pessoais atualizadas",
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo seguinte para confirmar a eliminação definitiva da sua conta:",
"profile_updated_successfully": "O seu perfil foi atualizado com sucesso",
"remove_image": "Remover imagem",
@@ -1230,6 +1227,7 @@
"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?",
"edit": {
@@ -1251,6 +1249,8 @@
"add_description": "Adicionar descrição",
"add_ending": "Adicionar encerramento",
"add_ending_below": "Adicionar encerramento abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
@@ -1304,17 +1304,15 @@
"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",
"caution_edit_published_survey": "Editar um inquérito publicado?",
"caution_explanation_all_data_as_download": "Todos os dados, incluindo respostas anteriores, estão disponíveis para download.",
"caution_explanation_intro": "Entendemos que ainda pode querer fazer alterações. Eis o que acontece se o fizer:",
"caution_explanation_new_responses_separated": "As novas respostas são recolhidas separadamente.",
"caution_explanation_only_new_responses_in_summary": "Apenas novas respostas aparecem no resumo do inquérito.",
"caution_explanation_responses_are_safe": "As respostas existentes permanecem seguras.",
"caution_recommendation": "Editar o seu inquérito pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_explanation_new_responses_separated": "Respostas antes da alteração podem não estar incluídas ou estar apenas parcialmente incluídas no resumo do inquérito.",
"caution_explanation_only_new_responses_in_summary": "Todos os dados, incluindo respostas anteriores, permanecem disponíveis para download na página de resumo do inquérito.",
"caution_explanation_responses_are_safe": "As respostas mais antigas e mais recentes se misturam, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_text": "As alterações levarão a inconsistências",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"change_anyway": "Alterar mesmo assim",
@@ -1329,7 +1327,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",
@@ -1392,6 +1389,7 @@
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todos",
"fallback_for": "Alternativa para ",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
@@ -1716,6 +1714,7 @@
"congrats": "Parabéns! O seu inquérito está ativo.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
"create_single_use_links": "Criar links de uso único",
"create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...",
@@ -1734,23 +1733,21 @@
"embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site",
"embed_survey": "Incorporar inquérito",
"expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks",
"generate_and_download_links": "Gerar & descarregar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto. Um ficheiro CSV dos seus links pessoais, incluindo a informação relevante de contacto, será descarregado automaticamente.",
"generate_personal_links_title": "Maximize os insights com links pessoais de inquérito",
"generating_links": "Gerando links",
"generating_links_toast": "A gerar links, o download começará em breve…",
"go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49",
"hide_embed_code": "Ocultar código de incorporação",
"how_to_create_a_panel": "Como criar um painel",
"how_to_create_a_panel_step_1": "Passo 1: Crie uma conta com a Prolific",
"how_to_create_a_panel_step_1_description": "Crie uma conta no Prolific e verifique o seu endereço de email.",
"how_to_create_a_panel_step_2": "Passo 2: Criar um estudo",
"how_to_create_a_panel_step_2_description": "No Prolific, cria um novo estudo onde pode escolher o seu público preferido com base em centenas de características.",
"how_to_create_a_panel_step_3": "Passo 3: Conecte o seu inquérito",
"how_to_create_a_panel_step_3_description": "Configure campos ocultos no seu inquérito Formbricks para rastrear qual participante forneceu qual resposta.",
"how_to_create_a_panel_step_4": "Passo 4: Lançar o seu estudo",
"how_to_create_a_panel_step_4_description": "Depois de tudo configurado, pode lançar o seu estudo. Dentro de algumas horas, receberá as primeiras respostas.",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
"includes_all": "Inclui tudo",
@@ -1765,12 +1762,18 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"link_to_public_results_copied": "Link para resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
"mobile_app": "Aplicação móvel",
"no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Sem segmentos disponíveis",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
"personal_links_upgrade_prompt_title": "Utilize links pessoais com um plano superior",
"personal_links_work_with_segments": "Os links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web",
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
@@ -1779,6 +1782,7 @@
"quickstart_web_apps": "Início rápido: Aplicações web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar pré-visualização",
@@ -1813,13 +1817,7 @@
"view_site": "Ver site",
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8",
"web_app": "Aplicação web",
"what_is_a_panel": "O que é um painel?",
"what_is_a_panel_answer": "Um painel é um grupo de participantes selecionados com base em características como idade, profissão, género, etc.",
"what_is_prolific": "O que é o Prolific?",
"what_is_prolific_answer": "Estamos a colaborar com a Prolific para lhe dar acesso a um grupo de mais de 200.000 participantes verificados.",
"whats_next": "O que se segue?",
"when_do_i_need_it": "Quando é que preciso disso?",
"when_do_i_need_it_answer": "Se não tiver acesso a pessoas suficientes que correspondam ao seu público-alvo, faz sentido pagar pelo acesso a um painel.",
"you_can_do_a_lot_more_with_links_surveys": "Pode fazer muito mais com inquéritos de links \uD83D\uDCA1",
"your_survey_is_public": "O seu inquérito é público",
"youre_not_plugged_in_yet": "Ainda não está ligado!"

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。",
"upgrade_successful": "升級成功"
},
"c": {
"link_expired": "您 的 連結 已過期。",
"link_expired_description": "您 使用 的 連結 已無效。"
},
"common": {
"accepted": "已接受",
"account": "帳戶",
@@ -135,7 +139,6 @@
"app_survey": "應用程式問卷",
"apply_filters": "套用篩選器",
"are_you_sure": "您確定嗎?",
"are_you_sure_this_action_cannot_be_undone": "您確定嗎?此操作無法復原。",
"attributes": "屬性",
"avatar": "頭像",
"back": "返回",
@@ -313,9 +316,11 @@
"question_id": "問題 ID",
"questions": "問題",
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
@@ -411,7 +416,6 @@
"website_survey": "網站問卷",
"weekly_summary": "每週摘要",
"welcome_card": "歡迎卡片",
"yes": "是",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorised_to_perform_this_action": "您未獲授權執行此操作。",
@@ -596,6 +600,7 @@
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字",
"last_name": "姓氏",
"no_responses_found": "找不到回應",
@@ -632,6 +637,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": "連結新表格",
@@ -699,7 +705,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": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
},
@@ -721,6 +726,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 工作區。"
},
@@ -905,8 +911,7 @@
"tag_already_exists": "標籤已存在",
"tag_deleted": "標籤已刪除",
"tag_updated": "標籤已更新",
"tags_merged": "標籤已合併",
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
"tags_merged": "標籤已合併"
},
"teams": {
"manage_teams": "管理團隊",
@@ -979,63 +984,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": "無限回應",
@@ -1074,6 +1069,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": "在您繼續刪除此組織之前,請注意以下後果:",
@@ -1160,6 +1156,7 @@
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "永久移除您的所有個人資訊和資料",
"personal_information": "個人資訊",
"personal_information_updated": "個人資訊已更新",
"please_enter_email_to_confirm_account_deletion": "請在以下欄位中輸入 '{'email'}' 以確認永久刪除您的帳戶:",
"profile_updated_successfully": "您的個人資料已成功更新",
"remove_image": "移除圖片",
@@ -1230,6 +1227,7 @@
"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": "您確定要刪除此問卷及其所有回應嗎?",
"edit": {
@@ -1251,6 +1249,8 @@
"add_description": "新增描述",
"add_ending": "新增結尾",
"add_ending_below": "在下方新增結尾",
"add_fallback": "新增",
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
"add_hidden_field_id": "新增隱藏欄位 ID",
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
@@ -1304,17 +1304,15 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色",
"card_shadow_color": "卡片陰影顏色",
"card_styling": "卡片樣式設定",
"casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯",
"caution_edit_published_survey": "編輯已發佈的調查?",
"caution_explanation_all_data_as_download": "所有數據,包括過去的回應,都可以下載。",
"caution_explanation_intro": "我們了解您可能仍然想要進行更改。如果您這樣做,將會發生以下情況:",
"caution_explanation_new_responses_separated": "新回應會分開收集。",
"caution_explanation_only_new_responses_in_summary": "只有新的回應會出現在調查摘要。",
"caution_explanation_responses_are_safe": "現有回應仍然安全。",
"caution_recommendation": "編輯您的調查可能導致調查摘要中的數據不一致。我們建議複製調查。",
"caution_explanation_new_responses_separated": "更改前的回應可能未被納入或只有部分包含在調查摘要中。",
"caution_explanation_only_new_responses_in_summary": "所有數據,包括過去的回應,仍可在調查摘要頁面下載。",
"caution_explanation_responses_are_safe": "較舊和較新的回應會混在一起,可能導致數據摘要失準。",
"caution_recommendation": "可能導致調查摘要中的數據不一致。我們建議複製這個調查。",
"caution_text": "變更會導致不一致",
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
"change_anyway": "仍然變更",
@@ -1329,7 +1327,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": "新增字元限制",
@@ -1392,6 +1389,7 @@
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
"everyone": "所有人",
"fallback_for": "備用 用於 ",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
@@ -1716,6 +1714,7 @@
"congrats": "恭喜!您的問卷已上線。",
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"copy_link_to_public_results": "複製公開結果的連結",
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
"create_single_use_links": "建立單次使用連結",
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
"custom_range": "自訂範圍...",
@@ -1734,23 +1733,21 @@
"embed_on_website": "嵌入網站",
"embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷",
"embed_survey": "嵌入問卷",
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
"expiry_date_optional": "到期日 (可選)",
"failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)",
"filtered_responses_excel": "篩選回應 (Excel)",
"formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽",
"generate_and_download_links": "生成 & 下載 連結",
"generate_personal_links_description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。含 有 相關 聯絡信息 的 個人 連結 CSV 會 自動 下載。",
"generate_personal_links_title": "透過個人化調查連結最大化洞察",
"generating_links": "生成 連結",
"generating_links_toast": "生成 連結,下載 將 會 很快 開始…",
"go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49",
"hide_embed_code": "隱藏嵌入程式碼",
"how_to_create_a_panel": "如何建立小組",
"how_to_create_a_panel_step_1": "步驟 1使用 Prolific 建立帳戶",
"how_to_create_a_panel_step_1_description": "使用 Prolific 建立帳戶並驗證您的電子郵件地址。",
"how_to_create_a_panel_step_2": "步驟 2建立研究",
"how_to_create_a_panel_step_2_description": "在 Prolific 中,您建立一個新的研究,您可以在其中根據數百個特徵選擇您偏好的受眾。",
"how_to_create_a_panel_step_3": "步驟 3連線您的問卷",
"how_to_create_a_panel_step_3_description": "在您的 Formbricks 問卷中設定隱藏欄位,以追蹤哪個參與者提供了哪個答案。",
"how_to_create_a_panel_step_4": "步驟 4啟動您的研究",
"how_to_create_a_panel_step_4_description": "設定完成後,您可以啟動您的研究。在幾個小時內,您就會收到第一個回應。",
"impressions": "曝光數",
"impressions_tooltip": "問卷已檢視的次數。",
"includes_all": "包含全部",
@@ -1765,12 +1762,18 @@
"last_quarter": "上一季",
"last_year": "去年",
"link_to_public_results_copied": "已複製公開結果的連結",
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
"mobile_app": "行動應用程式",
"no_responses_found": "找不到回應",
"no_segments_available": "沒有可用的區段",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
"personal_links": "個人 連結",
"personal_links_upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
"personal_links_upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
"personal_links_work_with_segments": "個人 連結 可 與 分段 一起 使用",
"publish_to_web": "發布至網站",
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
@@ -1779,6 +1782,7 @@
"quickstart_web_apps": "快速入門Web apps",
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
"results_are_public": "結果是公開的",
"select_segment": "選擇 區隔",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
"send_preview": "發送預覽",
@@ -1813,13 +1817,7 @@
"view_site": "檢視網站",
"waiting_for_response": "正在等待回應 \uD83E\uDDD8",
"web_app": "Web 應用程式",
"what_is_a_panel": "什麼是小組?",
"what_is_a_panel_answer": "小組是一組根據年齡、職業、性別等特徵選取的參與者。",
"what_is_prolific": "什麼是 Prolific",
"what_is_prolific_answer": "我們正在與 Prolific 合作,為您提供超過 200,000 名經過審核的參與者。",
"whats_next": "下一步是什麼?",
"when_do_i_need_it": "我何時需要它?",
"when_do_i_need_it_answer": "如果您無法存取足夠的符合您目標受眾的人員,則可以付費存取小組。",
"you_can_do_a_lot_more_with_links_surveys": "使用連結問卷,您可以做更多事情 \uD83D\uDCA1",
"your_survey_is_public": "您的問卷是公開的",
"youre_not_plugged_in_yet": "您尚未插入任何內容!"
@@ -2587,7 +2585,7 @@
"preview_survey_question_2_back_button_label": "返回",
"preview_survey_question_2_choice_1_label": "是,請保持通知我。",
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要保持最新消息嗎?",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_welcome_card_headline": "歡迎!",
"preview_survey_welcome_card_html": "感謝您提供回饋 - 開始吧!",
"prioritize_features_description": "找出您的使用者最需要和最不需要的功能。",

View File

@@ -13,6 +13,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
value={surveyUrl}
readOnly
/>
) : (
//loading state

View File

@@ -50,8 +50,14 @@ export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
ctx.auditLoggingCtx.tagId = result.id;
ctx.auditLoggingCtx.newObject = result;
if (result.ok) {
ctx.auditLoggingCtx.tagId = result.data.id;
ctx.auditLoggingCtx.newObject = result.data;
} else {
ctx.auditLoggingCtx.newObject = null;
}
return result;
}
)

View File

@@ -1,3 +1,5 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
@@ -150,7 +152,9 @@ describe("ResponseTagsWrapper", () => {
});
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any);
vi.mocked(createTagAction).mockResolvedValueOnce({
data: { ok: true, data: { id: "newTagId", name: "NewTag" } },
} as any);
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
render(
<ResponseTagsWrapper
@@ -176,7 +180,10 @@ describe("ResponseTagsWrapper", () => {
test("handles createTagAction failure and shows toast error", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({
error: { details: [{ issue: "Unique constraint failed on the fields" }] },
data: {
ok: false,
error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS },
},
} as any);
render(
<ResponseTagsWrapper

View File

@@ -1,6 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Button } from "@/modules/ui/components/button";
import { Tag } from "@/modules/ui/components/tag";
import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
@@ -58,6 +59,60 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
return () => clearTimeout(timeoutId);
}, [tagIdToHighlight]);
const handleCreateTag = async (tagName: string) => {
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data?.ok) {
const tag = createTagResponse.data.data;
setTagsState((prevTags) => [
...prevTags,
{
tagId: tag.id,
tagName: tag.name,
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: tag.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
} else {
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
toast.error(errorMessage);
}
return;
}
if (
createTagResponse?.data?.ok === false &&
createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS
) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
setSearchValue("");
return;
}
const errorMessage = getFormattedErrorMessage(createTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
setSearchValue("");
};
return (
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
{!isReadOnly && (
@@ -93,46 +148,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={async (tagName) => {
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data) {
setTagsState((prevTags) => [
...prevTags,
{
tagId: createTagResponse.data?.id ?? "",
tagName: createTagResponse.data?.name ?? "",
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: createTagResponse.data.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
}
} else {
const errorMessage = getFormattedErrorMessage(createTagResponse);
if (errorMessage.includes("Unique constraint failed on the fields")) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
} else {
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
}
setSearchValue("");
}
}}
createTag={handleCreateTag}
addTag={(tagId) => {
setTagsState((prevTags) => [
...prevTags,

View File

@@ -150,9 +150,10 @@ export const SingleResponseCard = ({
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
deleteWhat={t("common.response")}
onDelete={handleDeleteResponse}
isDeleting={isDeleting}
text={t("environments.surveys.responses.delete_response_confirmation")}
/>
</div>
{user && pageType === "response" && (

View File

@@ -1,79 +0,0 @@
import { ZContactAttributeInput } from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttribute",
summary: "Get a contact attribute",
description: "Gets a contact attribute from the database.",
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
tags: ["Management API - Contact Attributes"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttribute",
summary: "Delete a contact attribute",
description: "Deletes a contact attribute from the database.",
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttribute",
summary: "Update a contact attribute",
description: "Updates a contact attribute in the database.",
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};

View File

@@ -1,68 +0,0 @@
import {
deleteContactAttributeEndpoint,
getContactAttributeEndpoint,
updateContactAttributeEndpoint,
} from "@/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi";
import {
ZContactAttributeInput,
ZGetContactAttributesFilter,
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributes",
summary: "Get contact attributes",
description: "Gets contact attributes from the database.",
tags: ["Management API - Contact Attributes"],
requestParams: {
query: ZGetContactAttributesFilter,
},
responses: {
"200": {
description: "Contact attributes retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttribute),
},
},
},
},
};
export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttribute",
summary: "Create a contact attribute",
description: "Creates a contact attribute in the database.",
tags: ["Management API - Contact Attributes"],
requestBody: {
required: true,
description: "The contact attribute to create",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"201": {
description: "Contact attribute created successfully.",
},
},
};
export const contactAttributePaths: ZodOpenApiPathsObject = {
"/contact-attributes": {
servers: managementServer,
get: getContactAttributesEndpoint,
post: createContactAttributeEndpoint,
},
"/contact-attributes/{id}": {
servers: managementServer,
get: getContactAttributeEndpoint,
put: updateContactAttributeEndpoint,
delete: deleteContactAttributeEndpoint,
},
};

View File

@@ -1,34 +0,0 @@
import { z } from "zod";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const ZGetContactAttributesFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactAttributeInput = ZContactAttribute.pick({
attributeKeyId: true,
contactId: true,
value: true,
}).openapi({
ref: "contactAttributeInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeInput = z.infer<typeof ZContactAttributeInput>;

View File

@@ -1,79 +0,0 @@
import { ZContactInput } from "@/modules/api/v2/management/contacts/types/contacts";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactEndpoint: ZodOpenApiOperationObject = {
operationId: "getContact",
summary: "Get a contact",
description: "Gets a contact from the database.",
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const deleteContactEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContact",
summary: "Delete a contact",
description: "Deletes a contact from the database.",
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const updateContactEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContact",
summary: "Update a contact",
description: "Updates a contact in the database.",
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};

View File

@@ -1,70 +0,0 @@
import {
deleteContactEndpoint,
getContactEndpoint,
updateContactEndpoint,
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactsEndpoint: ZodOpenApiOperationObject = {
operationId: "getContacts",
summary: "Get contacts",
description: "Gets contacts from the database.",
requestParams: {
query: ZGetContactsFilter,
},
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contacts retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContact),
},
},
},
},
};
export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description: "Creates a contact in the database.",
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description: "The contact to create",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"201": {
description: "Contact created successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
get: getContactsEndpoint,
post: createContactEndpoint,
},
"/contacts/{id}": {
servers: managementServer,
get: getContactEndpoint,
put: updateContactEndpoint,
delete: deleteContactEndpoint,
},
};

View File

@@ -1,40 +0,0 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
extendZodWithOpenApi(z);
export const ZGetContactsFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactInput = ZContact.pick({
userId: true,
environmentId: true,
})
.partial({
userId: true,
})
.openapi({
ref: "contactCreate",
description: "A contact to create",
});
export type TContactInput = z.infer<typeof ZContactInput>;

View File

@@ -1,6 +1,4 @@
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
// import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
// import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
@@ -11,6 +9,7 @@ import { teamPaths } from "@/modules/api/v2/organizations/[organizationId]/teams
import { userPaths } from "@/modules/api/v2/organizations/[organizationId]/users/lib/openapi";
import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
import { contactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/lib/openapi";
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
@@ -40,8 +39,7 @@ const document = createDocument({
...mePaths,
...responsePaths,
...bulkContactPaths,
// ...contactPaths,
// ...contactAttributePaths,
...contactPaths,
...contactAttributeKeyPaths,
...surveyPaths,
...surveyContactLinksBySegmentPaths,

View File

@@ -1,113 +0,0 @@
import redis from "@/modules/cache/redis";
import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
import {
AUDIT_LOG_HASH_KEY,
getPreviousAuditLogHash,
runAuditLogHashTransaction,
setPreviousAuditLogHash,
} from "./cache";
// Mock redis module
vi.mock("@/modules/cache/redis", () => {
let store: Record<string, string | null> = {};
return {
default: {
del: vi.fn(async (key: string) => {
store[key] = null;
return 1;
}),
quit: vi.fn(async () => {
return "OK";
}),
get: vi.fn(async (key: string) => {
return store[key] ?? null;
}),
set: vi.fn(async (key: string, value: string) => {
store[key] = value;
return "OK";
}),
watch: vi.fn(async (_key: string) => {
return "OK";
}),
unwatch: vi.fn(async () => {
return "OK";
}),
multi: vi.fn(() => {
return {
set: vi.fn(function (key: string, value: string) {
store[key] = value;
return this;
}),
exec: vi.fn(async () => {
return [[null, "OK"]];
}),
} as unknown as import("ioredis").ChainableCommander;
}),
},
};
});
describe("audit log cache utils", () => {
beforeEach(async () => {
await redis?.del(AUDIT_LOG_HASH_KEY);
});
afterAll(async () => {
await redis?.quit();
});
test("should get and set the previous audit log hash", async () => {
expect(await getPreviousAuditLogHash()).toBeNull();
await setPreviousAuditLogHash("testhash");
expect(await getPreviousAuditLogHash()).toBe("testhash");
});
test("should run a successful audit log hash transaction", async () => {
let logCalled = false;
await runAuditLogHashTransaction(async (previousHash) => {
expect(previousHash).toBeNull();
return {
auditEvent: async () => {
logCalled = true;
},
integrityHash: "hash1",
};
});
expect(await getPreviousAuditLogHash()).toBe("hash1");
expect(logCalled).toBe(true);
});
test("should retry and eventually throw if the hash keeps changing", async () => {
// Simulate another process changing the hash every time
let callCount = 0;
const originalMulti = redis?.multi;
(redis?.multi as any).mockImplementation(() => {
return {
set: vi.fn(function () {
return this;
}),
exec: vi.fn(async () => {
callCount++;
return null; // Simulate transaction failure
}),
} as unknown as import("ioredis").ChainableCommander;
});
let errorCaught = false;
try {
await runAuditLogHashTransaction(async () => {
return {
auditEvent: async () => {},
integrityHash: "conflict-hash",
};
});
throw new Error("Error was not thrown by runAuditLogHashTransaction");
} catch (e) {
errorCaught = true;
expect((e as Error).message).toContain("Failed to update audit log hash after multiple retries");
}
expect(errorCaught).toBe(true);
expect(callCount).toBe(5);
// Restore
(redis?.multi as any).mockImplementation(originalMulti);
});
});

View File

@@ -1,67 +0,0 @@
import redis from "@/modules/cache/redis";
import { logger } from "@formbricks/logger";
export const AUDIT_LOG_HASH_KEY = "audit:lastHash";
export async function getPreviousAuditLogHash(): Promise<string | null> {
if (!redis) {
logger.error("Redis is not initialized");
return null;
}
return (await redis.get(AUDIT_LOG_HASH_KEY)) ?? null;
}
export async function setPreviousAuditLogHash(hash: string): Promise<void> {
if (!redis) {
logger.error("Redis is not initialized");
return;
}
await redis.set(AUDIT_LOG_HASH_KEY, hash);
}
/**
* Runs a concurrency-safe Redis transaction for the audit log hash chain.
* The callback receives the previous hash and should return the audit event to log.
* Handles retries and atomicity.
*/
export async function runAuditLogHashTransaction(
buildAndLogEvent: (previousHash: string | null) => Promise<{ auditEvent: any; integrityHash: string }>
): Promise<void> {
let retry = 0;
while (retry < 5) {
if (!redis) {
logger.error("Redis is not initialized");
throw new Error("Redis is not initialized");
}
let result;
let auditEvent;
try {
await redis.watch(AUDIT_LOG_HASH_KEY);
const previousHash = await getPreviousAuditLogHash();
const buildResult = await buildAndLogEvent(previousHash);
auditEvent = buildResult.auditEvent;
const integrityHash = buildResult.integrityHash;
const tx = redis.multi();
tx.set(AUDIT_LOG_HASH_KEY, integrityHash);
result = await tx.exec();
} finally {
await redis.unwatch();
}
if (result) {
// Success: now log the audit event
await auditEvent();
return;
}
// Retry if the hash was changed by another process
retry++;
}
// Debug log for test diagnostics
// eslint-disable-next-line no-console
console.error("runAuditLogHashTransaction: throwing after 5 retries");
throw new Error("Failed to update audit log hash after multiple retries (concurrency issue)");
}

View File

@@ -5,8 +5,6 @@ import * as OriginalHandler from "./handler";
// Use 'var' for all mock handles used in vi.mock factories to avoid hoisting/TDZ issues
var serviceLogAuditEventMockHandle: ReturnType<typeof vi.fn>; // NOSONAR / test code
var cacheRunAuditLogHashTransactionMockHandle: ReturnType<typeof vi.fn>; // NOSONAR / test code
var utilsComputeAuditLogHashMockHandle: ReturnType<typeof vi.fn>; // NOSONAR / test code
var loggerErrorMockHandle: ReturnType<typeof vi.fn>; // NOSONAR / test code
// Use 'var' for mutableConstants due to hoisting issues with vi.mock factories
@@ -23,7 +21,6 @@ vi.mock("@/lib/constants", () => ({
return mutableConstants ? mutableConstants.AUDIT_LOG_ENABLED : true; // Default to true if somehow undefined
},
AUDIT_LOG_GET_USER_IP: true,
ENCRYPTION_KEY: "testsecret",
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: vi.fn().mockResolvedValue("127.0.0.1"),
@@ -35,19 +32,10 @@ vi.mock("@/modules/ee/audit-logs/lib/service", () => {
return { logAuditEvent: mock };
});
vi.mock("./cache", () => {
const mock = vi.fn((fn) => fn(null).then((res: any) => res.auditEvent())); // Keep original mock logic
cacheRunAuditLogHashTransactionMockHandle = mock;
return { runAuditLogHashTransaction: mock };
});
vi.mock("./utils", async () => {
const actualUtils = await vi.importActual("./utils");
const mock = vi.fn();
utilsComputeAuditLogHashMockHandle = mock;
return {
...(actualUtils as object),
computeAuditLogHash: mock, // This is the one we primarily care about controlling
redactPII: vi.fn((obj) => obj), // Keep others as simple mocks or actuals if needed
deepDiff: vi.fn((a, b) => ({ diff: true })),
};
@@ -139,12 +127,6 @@ const mockCtxBase = {
// Helper to clear all mock handles
function clearAllMockHandles() {
if (serviceLogAuditEventMockHandle) serviceLogAuditEventMockHandle.mockClear().mockResolvedValue(undefined);
if (cacheRunAuditLogHashTransactionMockHandle)
cacheRunAuditLogHashTransactionMockHandle
.mockClear()
.mockImplementation((fn) => fn(null).then((res: any) => res.auditEvent()));
if (utilsComputeAuditLogHashMockHandle)
utilsComputeAuditLogHashMockHandle.mockClear().mockReturnValue("testhash");
if (loggerErrorMockHandle) loggerErrorMockHandle.mockClear();
if (mutableConstants) {
// Check because it's a var and could be re-assigned (though not in this code)
@@ -164,25 +146,23 @@ describe("queueAuditEvent", () => {
await OriginalHandler.queueAuditEvent(baseEventParams);
// Now, OriginalHandler.queueAuditEvent will call the REAL OriginalHandler.buildAndLogAuditEvent
// We expect the MOCKED dependencies of buildAndLogAuditEvent to be called.
expect(cacheRunAuditLogHashTransactionMockHandle).toHaveBeenCalled();
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
// Add more specific assertions on what serviceLogAuditEventMockHandle was called with if necessary
// This would be similar to the direct tests for buildAndLogAuditEvent
const logCall = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(logCall.action).toBe(baseEventParams.action);
expect(logCall.integrityHash).toBe("testhash");
});
test("handles errors from buildAndLogAuditEvent dependencies", async () => {
const testError = new Error("DB hash error in test");
cacheRunAuditLogHashTransactionMockHandle.mockImplementationOnce(() => {
const testError = new Error("Service error in test");
serviceLogAuditEventMockHandle.mockImplementationOnce(() => {
throw testError;
});
await OriginalHandler.queueAuditEvent(baseEventParams);
// queueAuditEvent should catch errors from buildAndLogAuditEvent and log them
// buildAndLogAuditEvent in turn logs errors from its dependencies
expect(loggerErrorMockHandle).toHaveBeenCalledWith(testError, "Failed to create audit log event");
expect(serviceLogAuditEventMockHandle).not.toHaveBeenCalled();
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
});
});
@@ -197,11 +177,9 @@ describe("queueAuditEventBackground", () => {
test("correctly processes event in background and dependencies are called", async () => {
await OriginalHandler.queueAuditEventBackground(baseEventParams);
await new Promise(setImmediate); // Wait for setImmediate to run
expect(cacheRunAuditLogHashTransactionMockHandle).toHaveBeenCalled();
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const logCall = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(logCall.action).toBe(baseEventParams.action);
expect(logCall.integrityHash).toBe("testhash");
});
});
@@ -226,7 +204,6 @@ describe("withAuditLogging", () => {
expect(callArgs.action).toBe("created");
expect(callArgs.status).toBe("success");
expect(callArgs.target.id).toBe("t1");
expect(callArgs.integrityHash).toBe("testhash");
});
test("logs audit event for failed handler and throws", async () => {

View File

@@ -13,12 +13,11 @@ import {
} from "@/modules/ee/audit-logs/types/audit-log";
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { runAuditLogHashTransaction } from "./cache";
import { computeAuditLogHash, deepDiff, redactPII } from "./utils";
import { deepDiff, redactPII } from "./utils";
/**
* Builds an audit event and logs it.
* Redacts sensitive data from the old and new objects and computes the hash of the event before logging it.
* Redacts sensitive data from the old and new objects before logging.
*/
export const buildAndLogAuditEvent = async ({
action,
@@ -63,7 +62,7 @@ export const buildAndLogAuditEvent = async ({
changes = redactPII(oldObject);
}
const eventBase: Omit<TAuditLogEvent, "integrityHash" | "previousHash" | "chainStart"> = {
const auditEvent: TAuditLogEvent = {
actor: { id: userId, type: userType },
action,
target: { id: targetId, type: targetType },
@@ -76,20 +75,7 @@ export const buildAndLogAuditEvent = async ({
...(status === "failure" && eventId ? { eventId } : {}),
};
await runAuditLogHashTransaction(async (previousHash) => {
const isChainStart = !previousHash;
const integrityHash = computeAuditLogHash(eventBase, previousHash);
const auditEvent: TAuditLogEvent = {
...eventBase,
integrityHash,
previousHash,
...(isChainStart ? { chainStart: true } : {}),
};
return {
auditEvent: async () => await logAuditEvent(auditEvent),
integrityHash,
};
});
await logAuditEvent(auditEvent);
} catch (logError) {
logger.error(logError, "Failed to create audit log event");
}
@@ -199,21 +185,21 @@ export const queueAuditEvent = async ({
* @param targetType - The type of target (e.g., "segment", "survey").
* @param handler - The handler function to wrap. It can be used with both authenticated and unauthenticated actions.
**/
export const withAuditLogging = <TParsedInput = Record<string, unknown>>(
export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult = unknown>(
action: TAuditAction,
targetType: TAuditTarget,
handler: (args: {
ctx: ActionClientCtx | AuthenticatedActionClientCtx;
parsedInput: TParsedInput;
}) => Promise<unknown>
}) => Promise<TResult>
) => {
return async function wrappedAction(args: {
ctx: ActionClientCtx | AuthenticatedActionClientCtx;
parsedInput: TParsedInput;
}) {
}): Promise<TResult> {
const { ctx, parsedInput } = args;
const { auditLoggingCtx } = ctx;
let result: any;
let result!: TResult;
let status: TAuditStatus = "success";
let error: any = undefined;

View File

@@ -19,9 +19,6 @@ const validEvent = {
status: "success" as const,
timestamp: new Date().toISOString(),
organizationId: "org-1",
integrityHash: "hash",
previousHash: null,
chainStart: true,
};
describe("logAuditEvent", () => {

View File

@@ -183,118 +183,3 @@ describe("withAuditLogging", () => {
expect(handler).toHaveBeenCalled();
});
});
describe("runtime config checks", () => {
test("throws if AUDIT_LOG_ENABLED is true and ENCRYPTION_KEY is missing", async () => {
// Unset the secret and reload the module
process.env.ENCRYPTION_KEY = "";
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
AUDIT_LOG_GET_USER_IP: true,
ENCRYPTION_KEY: undefined,
}));
await expect(import("./utils")).rejects.toThrow(
/ENCRYPTION_KEY must be set when AUDIT_LOG_ENABLED is enabled/
);
// Restore for other tests
process.env.ENCRYPTION_KEY = "testsecret";
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
AUDIT_LOG_GET_USER_IP: true,
ENCRYPTION_KEY: "testsecret",
}));
});
});
describe("computeAuditLogHash", () => {
let utils: any;
beforeEach(async () => {
vi.unmock("crypto");
utils = await import("./utils");
});
test("produces deterministic hash for same input", () => {
const event = {
actor: { id: "u1", type: "user" },
action: "survey.created",
target: { id: "t1", type: "survey" },
timestamp: "2024-01-01T00:00:00.000Z",
organizationId: "org1",
status: "success",
ipAddress: "127.0.0.1",
apiUrl: "/api/test",
};
const hash1 = utils.computeAuditLogHash(event, null);
const hash2 = utils.computeAuditLogHash(event, null);
expect(hash1).toBe(hash2);
});
test("hash changes if previous hash changes", () => {
const event = {
actor: { id: "u1", type: "user" },
action: "survey.created",
target: { id: "t1", type: "survey" },
timestamp: "2024-01-01T00:00:00.000Z",
organizationId: "org1",
status: "success",
ipAddress: "127.0.0.1",
apiUrl: "/api/test",
};
const hash1 = utils.computeAuditLogHash(event, "prev1");
const hash2 = utils.computeAuditLogHash(event, "prev2");
expect(hash1).not.toBe(hash2);
});
});
describe("buildAndLogAuditEvent", () => {
let buildAndLogAuditEvent: any;
let redis: any;
let logAuditEvent: any;
beforeEach(async () => {
vi.resetModules();
(globalThis as any).__logAuditEvent = vi.fn().mockResolvedValue(undefined);
vi.mock("@/modules/cache/redis", () => ({
default: {
watch: vi.fn().mockResolvedValue("OK"),
multi: vi.fn().mockReturnValue({
set: vi.fn(),
exec: vi.fn().mockResolvedValue([["OK"]]),
}),
get: vi.fn().mockResolvedValue(null),
},
}));
vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
AUDIT_LOG_GET_USER_IP: true,
ENCRYPTION_KEY: "testsecret",
}));
({ buildAndLogAuditEvent } = await import("./handler"));
redis = (await import("@/modules/cache/redis")).default;
logAuditEvent = (globalThis as any).__logAuditEvent;
});
afterEach(() => {
delete (globalThis as any).__logAuditEvent;
});
test("retries and logs error if hash update fails", async () => {
redis.multi.mockReturnValue({
set: vi.fn(),
exec: vi.fn().mockResolvedValue(null),
});
await buildAndLogAuditEvent({
actionType: "survey.created",
targetType: "survey",
userId: "u1",
userType: "user",
targetId: "t1",
organizationId: "org1",
ipAddress: "127.0.0.1",
status: "success",
oldObject: { foo: "bar" },
newObject: { foo: "baz" },
apiUrl: "/api/test",
});
expect(logAuditEvent).not.toHaveBeenCalled();
// The error is caught and logged, not thrown
});
});

View File

@@ -1,8 +1,3 @@
import { AUDIT_LOG_ENABLED, ENCRYPTION_KEY } from "@/lib/constants";
import { TAuditLogEvent } from "@/modules/ee/audit-logs/types/audit-log";
import { createHash } from "crypto";
import { logger } from "@formbricks/logger";
const SENSITIVE_KEYS = [
"email",
"name",
@@ -41,31 +36,6 @@ const SENSITIVE_KEYS = [
"fileName",
];
/**
* Computes the hash of the audit log event using the SHA256 algorithm.
* @param event - The audit log event.
* @param prevHash - The previous hash of the audit log event.
* @returns The hash of the audit log event. The hash is computed by concatenating the secret, the previous hash, and the event and then hashing the result.
*/
export const computeAuditLogHash = (
event: Omit<TAuditLogEvent, "integrityHash" | "previousHash" | "chainStart">,
prevHash: string | null
): string => {
let secret = ENCRYPTION_KEY;
if (!secret) {
// Log an error but don't throw an error to avoid blocking the main request
logger.error(
"ENCRYPTION_KEY is not set, creating audit log hash without it. Please set ENCRYPTION_KEY in the environment variables to avoid security issues."
);
secret = "";
}
const hash = createHash("sha256");
hash.update(secret + (prevHash ?? "") + JSON.stringify(event));
return hash.digest("hex");
};
/**
* Redacts sensitive data from the object by replacing the sensitive keys with "********".
* @param obj - The object to redact.
@@ -120,9 +90,3 @@ export const deepDiff = (oldObj: any, newObj: any): any => {
}
return Object.keys(diff).length > 0 ? diff : undefined;
};
if (AUDIT_LOG_ENABLED && !ENCRYPTION_KEY) {
throw new Error(
"ENCRYPTION_KEY must be set when AUDIT_LOG_ENABLED is enabled. Refusing to start for security reasons."
);
}

View File

@@ -51,6 +51,7 @@ export const ZAuditAction = z.enum([
"emailVerificationAttempted",
"userSignedOut",
"passwordReset",
"bulkCreated",
]);
export const ZActor = z.enum(["user", "api", "system"]);
export const ZAuditStatus = z.enum(["success", "failure"]);
@@ -78,9 +79,6 @@ export const ZAuditLogEventSchema = z.object({
changes: z.record(z.any()).optional(),
eventId: z.string().optional(),
apiUrl: z.string().url().optional(),
integrityHash: z.string(),
previousHash: z.string().nullable(),
chainStart: z.boolean().optional(),
});
export type TAuditLogEvent = z.infer<typeof ZAuditLogEventSchema>;

View File

@@ -1,87 +1,87 @@
import { TFnType } from "@tolgee/react";
export const getCloudPricingData = (t: TFnType) => {
return {
plans: [
{
name: t("environments.settings.billing.free"),
id: "free",
featured: false,
description: t("environments.settings.billing.free_description"),
price: { monthly: "$0", yearly: "$0" },
mainFeatures: [
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.unlimited_team_members"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.1500_monthly_responses"),
t("environments.settings.billing.2000_monthly_identified_users"),
t("environments.settings.billing.website_surveys"),
t("environments.settings.billing.app_surveys"),
t("environments.settings.billing.unlimited_apps_websites"),
t("environments.settings.billing.link_surveys"),
t("environments.settings.billing.email_embedded_surveys"),
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
t("environments.settings.billing.api_webhooks"),
t("environments.settings.billing.all_integrations"),
t("environments.settings.billing.all_surveying_features"),
],
href: "https://app.formbricks.com/auth/signup?plan=free",
},
{
name: t("environments.settings.billing.startup"),
id: "startup",
featured: false,
description: t("environments.settings.billing.startup_description"),
price: { monthly: "$39", yearly: "$390 " },
mainFeatures: [
t("environments.settings.billing.everything_in_free"),
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_support"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_monthly_identified_users"),
],
href: "https://app.formbricks.com/auth/signup?plan=startup",
},
{
name: t("environments.settings.billing.scale"),
id: "scale",
featured: true,
description: t("environments.settings.billing.scale_description"),
price: { monthly: "$149", yearly: "$1,490" },
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.team_access_roles"),
t("environments.settings.billing.multi_language_surveys"),
t("environments.settings.billing.advanced_targeting"),
t("environments.settings.billing.priority_support"),
t("environments.settings.billing.5_projects"),
t("environments.settings.billing.10000_monthly_responses"),
t("environments.settings.billing.30000_monthly_identified_users"),
],
href: "https://app.formbricks.com/auth/signup?plan=scale",
},
{
name: t("environments.settings.billing.enterprise"),
id: "enterprise",
featured: false,
description: t("environments.settings.billing.enterprise_description"),
price: {
monthly: t("environments.settings.billing.say_hi"),
yearly: t("environments.settings.billing.say_hi"),
},
mainFeatures: [
t("environments.settings.billing.everything_in_scale"),
t("environments.settings.billing.custom_project_limit"),
t("environments.settings.billing.custom_miu_limit"),
t("environments.settings.billing.premium_support_with_slas"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.customer_success_manager"),
t("environments.settings.billing.technical_onboarding"),
],
href: "https://cal.com/johannes/enterprise-cloud",
},
export type TPricingPlan = {
id: string;
name: string;
featured: boolean;
CTA?: string;
description: string;
price: {
monthly: string;
yearly: string;
};
mainFeatures: string[];
href?: string;
};
export const getCloudPricingData = (t: TFnType): { plans: TPricingPlan[] } => {
const freePlan: TPricingPlan = {
id: "free",
name: t("environments.settings.billing.free"),
featured: false,
description: t("environments.settings.billing.free_description"),
price: { monthly: "$0", yearly: "$0" },
mainFeatures: [
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.1000_monthly_responses"),
t("environments.settings.billing.2000_contacts"),
t("environments.settings.billing.1_project"),
t("environments.settings.billing.unlimited_team_members"),
t("environments.settings.billing.link_surveys"),
t("environments.settings.billing.website_surveys"),
t("environments.settings.billing.app_surveys"),
t("environments.settings.billing.ios_android_sdks"),
t("environments.settings.billing.email_embedded_surveys"),
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
t("environments.settings.billing.api_webhooks"),
t("environments.settings.billing.all_integrations"),
t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺",
],
};
const startupPlan: TPricingPlan = {
id: "startup",
name: t("environments.settings.billing.startup"),
featured: true,
CTA: t("common.start_free_trial"),
description: t("environments.settings.billing.startup_description"),
price: { monthly: "$49", yearly: "$490" },
mainFeatures: [
t("environments.settings.billing.everything_in_free"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_contacts"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.attribute_based_targeting"),
],
};
const customPlan: TPricingPlan = {
id: "enterprise",
name: t("environments.settings.billing.custom"),
featured: false,
CTA: t("common.request_pricing"),
description: t("environments.settings.billing.enterprise_description"),
price: {
monthly: t("environments.settings.billing.custom"),
yearly: t("environments.settings.billing.custom"),
},
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.custom_response_limit"),
t("environments.settings.billing.custom_contacts_limit"),
t("environments.settings.billing.custom_project_limit"),
t("environments.settings.billing.team_access_roles"),
t("environments.project.languages.multi_language_surveys"),
t("environments.settings.enterprise.saml_sso"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.premium_support_with_slas"),
],
href: "https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud",
};
return {
plans: [freePlan, startupPlan, customPlan],
};
};

View File

@@ -8,19 +8,10 @@ import { useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { TPricingPlan } from "../api/lib/constants";
interface PricingCardProps {
plan: {
id: string;
name: string;
featured: boolean;
price: {
monthly: string;
yearly: string;
};
mainFeatures: string[];
href: string;
};
plan: TPricingPlan;
planPeriod: TOrganizationBillingPeriod;
organization: TOrganization;
onUpgrade: () => Promise<void>;
@@ -28,7 +19,6 @@ interface PricingCardProps {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
SCALE: string;
ENTERPRISE: string;
};
}
@@ -72,18 +62,33 @@ export const PricingCard = ({
return null;
}
if (plan.id !== projectFeatureKeys.ENTERPRISE && plan.id !== projectFeatureKeys.FREE) {
if (plan.id === projectFeatureKeys.ENTERPRISE) {
return (
<Button
variant="outline"
loading={loading}
onClick={() => {
window.open(plan.href, "_blank", "noopener,noreferrer");
}}
className="flex justify-center bg-white">
{t(plan.CTA ?? "common.request_pricing")}
</Button>
);
}
if (plan.id === projectFeatureKeys.STARTUP) {
if (organization.billing.plan === projectFeatureKeys.FREE) {
return (
<Button
loading={loading}
variant="default"
onClick={async () => {
setLoading(true);
await onUpgrade();
setLoading(false);
}}
className="flex justify-center">
{t("common.start_free_trial")}
{t(plan.CTA ?? "common.start_free_trial")}
</Button>
);
}
@@ -100,15 +105,20 @@ export const PricingCard = ({
);
}
return <></>;
return null;
}, [
isCurrentPlan,
loading,
onUpgrade,
organization.billing.plan,
plan.CTA,
plan.featured,
plan.href,
plan.id,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP,
t,
]);
return (
@@ -147,7 +157,7 @@ export const PricingCard = ({
: plan.price.yearly
: t(plan.price.monthly)}
</p>
{plan.name !== "Enterprise" && (
{plan.id !== projectFeatureKeys.ENTERPRISE && (
<div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"}
@@ -171,16 +181,9 @@ export const PricingCard = ({
{t("environments.settings.billing.manage_subscription")}
</Button>
)}
{organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && (
<Button loading={loading} onClick={() => onUpgrade()} className="flex justify-center">
{t("environments.settings.billing.contact_us")}
</Button>
)}
</div>
<div className="mt-8 flow-root sm:mt-10">
<ul
role="list"
className={cn(
plan.featured
? "divide-slate-900/5 border-slate-900/5 text-slate-600"
@@ -193,7 +196,6 @@ export const PricingCard = ({
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
aria-hidden="true"
/>
{t(mainFeature)}
</li>
))}

View File

@@ -21,15 +21,12 @@ interface PricingTableProps {
responseCount: number;
projectCount: number;
stripePriceLookupKeys: {
STARTUP_MONTHLY: string;
STARTUP_YEARLY: string;
SCALE_MONTHLY: string;
SCALE_YEARLY: string;
STARTUP_MAY25_MONTHLY: string;
STARTUP_MAY25_YEARLY: string;
};
projectFeatureKeys: {
FREE: string;
STARTUP: string;
SCALE: string;
ENTERPRISE: string;
};
hasBillingRights: boolean;
@@ -102,35 +99,32 @@ export const PricingTable = ({
throw new Error(t("common.something_went_wrong_please_try_again"));
}
} catch (err) {
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
if (err instanceof Error) {
toast.error(err.message);
} else {
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
}
}
};
const onUpgrade = async (planId: string) => {
if (planId === "scale") {
await upgradePlan(
planPeriod === "monthly" ? stripePriceLookupKeys.SCALE_MONTHLY : stripePriceLookupKeys.SCALE_YEARLY
);
return;
}
if (planId === "startup") {
await upgradePlan(
planPeriod === "monthly"
? stripePriceLookupKeys.STARTUP_MONTHLY
: stripePriceLookupKeys.STARTUP_YEARLY
? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
: stripePriceLookupKeys.STARTUP_MAY25_YEARLY
);
return;
}
if (planId === "enterprise") {
window.location.href = "https://cal.com/johannes/license";
if (planId === "custom") {
window.location.href =
"https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud";
return;
}
if (planId === "free") {
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
return;
}
};
@@ -233,7 +227,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-12",
"relative mx-8 flex flex-col gap-4 pb-6",
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
@@ -282,7 +276,7 @@ export const PricingTable = ({
</span>
</button>
</div>
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
<div
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
aria-hidden="true"

View File

@@ -2,6 +2,7 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
@@ -48,18 +49,21 @@ export const DeleteContactButton = ({ environmentId, contactId, isReadOnly }: De
return (
<>
<button
<Button
variant="destructive"
size="icon"
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
<TrashIcon />
</Button>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
isDeleting={isDeletingPerson}
text={t("environments.contacts.delete_contact_confirmation")}
/>
</>
);

View File

@@ -20,7 +20,6 @@ const mockContact = {
environmentId: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [],
};
describe("contact lib", () => {
@@ -38,7 +37,9 @@ describe("contact lib", () => {
const result = await getContact(mockContactId);
expect(result).toEqual(mockContact);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } });
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
});
});
test("should return null if contact not found", async () => {
@@ -46,7 +47,9 @@ describe("contact lib", () => {
const result = await getContact(mockContactId);
expect(result).toBeNull();
expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } });
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
});
});
test("should throw DatabaseError if prisma throws PrismaClientKnownRequestError", async () => {

View File

@@ -20,18 +20,12 @@ const mockContacts = [
{
id: "contactId1",
environmentId: mockEnvironmentId1,
name: "Contact 1",
email: "contact1@example.com",
attributes: {},
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "contactId2",
environmentId: mockEnvironmentId2,
name: "Contact 2",
email: "contact2@example.com",
attributes: {},
createdAt: new Date(),
updatedAt: new Date(),
},

View File

@@ -12,30 +12,48 @@ export const PUT = async (request: Request) =>
schemas: {
body: ZContactBulkUploadRequest,
},
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return handleApiError(request, {
type: "forbidden",
details: [{ field: "error", issue: "Contacts are not enabled for this environment." }],
});
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "error", issue: "Contacts are not enabled for this environment." }],
},
auditLog
);
}
const environmentId = parsedInput.body?.environmentId;
if (!environmentId) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "environmentId", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "environmentId", issue: "missing" }],
},
auditLog
);
}
const { contacts } = parsedInput.body ?? { contacts: [] };
if (!hasPermission(authentication.environmentPermissions, environmentId, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
});
return handleApiError(
request,
{
type: "forbidden",
details: [
{
field: "environmentId",
issue: "insufficient permissions to create contact in this environment",
},
],
},
auditLog
);
}
const emails = contacts.map(
@@ -45,7 +63,7 @@ export const PUT = async (request: Request) =>
const upsertBulkContactsResult = await upsertBulkContacts(contacts, environmentId, emails);
if (!upsertBulkContactsResult.ok) {
return handleApiError(request, upsertBulkContactsResult.error);
return handleApiError(request, upsertBulkContactsResult.error, auditLog);
}
const { contactIdxWithConflictingUserIds } = upsertBulkContactsResult.data;
@@ -73,4 +91,6 @@ export const PUT = async (request: Request) =>
},
});
},
action: "bulkCreated",
targetType: "contact",
});

View File

@@ -0,0 +1,340 @@
import { TContactCreateRequest } from "@/modules/ee/contacts/types/contact";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { createContact } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
create: vi.fn(),
},
contactAttributeKey: {
findMany: vi.fn(),
},
},
}));
describe("contact.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createContact", () => {
test("returns bad_request error when email attribute is missing", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
firstName: "John",
},
};
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([{ field: "attributes", issue: "email attribute is required" }]);
}
});
test("returns bad_request error when email attribute value is empty", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "",
},
};
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([{ field: "attributes", issue: "email attribute is required" }]);
}
});
test("returns bad_request error when attribute keys do not exist", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
nonExistentKey: "value",
},
};
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[]);
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([
{ field: "attributes", issue: "attribute keys not found: nonExistentKey. " },
]);
}
});
test("returns conflict error when contact with same email already exists", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
vi.mocked(prisma.contact.findFirst).mockResolvedValueOnce({
id: "existing-contact-id",
environmentId: "env123",
userId: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("conflict");
expect(result.error.details).toEqual([
{ field: "email", issue: "contact with this email already exists" },
]);
}
});
test("returns conflict error when contact with same userId already exists", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
userId: "user123",
},
};
vi.mocked(prisma.contact.findFirst)
.mockResolvedValueOnce(null) // No existing contact by email
.mockResolvedValueOnce({
id: "existing-contact-id",
environmentId: "env123",
userId: "user123",
createdAt: new Date(),
updatedAt: new Date(),
}); // Existing contact by userId
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("conflict");
expect(result.error.details).toEqual([
{ field: "userId", issue: "contact with this userId already exists" },
]);
}
});
test("successfully creates contact with existing attribute keys", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
firstName: "John",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
{ id: "attr2", key: "firstName", name: "First Name", type: "custom", environmentId: "env123" },
] as TContactAttributeKey[];
const contactWithAttributes = {
id: "contact123",
environmentId: "env123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
userId: null,
attributes: [
{
attributeKey: existingAttributeKeys[0],
value: "john@example.com",
},
{
attributeKey: existingAttributeKeys[1],
value: "John",
},
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
vi.mocked(prisma.contact.create).mockResolvedValue(contactWithAttributes);
const result = await createContact(contactData);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
id: "contact123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
environmentId: "env123",
attributes: {
email: "john@example.com",
firstName: "John",
},
});
}
});
test("returns internal_server_error when contact creation returns null", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[];
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
vi.mocked(prisma.contact.create).mockResolvedValue(null as any);
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "contact", issue: "Cannot read properties of null (reading 'attributes')" },
]);
}
});
test("returns internal_server_error when database error occurs", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
vi.mocked(prisma.contact.findFirst).mockRejectedValue(new Error("Database connection failed"));
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "contact", issue: "Database connection failed" }]);
}
});
test("does not check for userId conflict when userId is not provided", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[];
const contactWithAttributes = {
id: "contact123",
environmentId: "env123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
userId: null,
attributes: [
{
attributeKey: existingAttributeKeys[0],
value: "john@example.com",
},
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValueOnce(null); // No existing contact by email
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
vi.mocked(prisma.contact.create).mockResolvedValue(contactWithAttributes);
const result = await createContact(contactData);
expect(result.ok).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(1); // Only called once for email check
});
test("returns bad_request error when multiple attribute keys are missing", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
nonExistentKey1: "value1",
nonExistentKey2: "value2",
},
};
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[]);
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([
{ field: "attributes", issue: "attribute keys not found: nonExistentKey1, nonExistentKey2. " },
]);
}
});
test("correctly handles userId extraction from attributes", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
userId: "user123",
firstName: "John",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
{ id: "attr2", key: "userId", name: "User ID", type: "default", environmentId: "env123" },
{ id: "attr3", key: "firstName", name: "First Name", type: "custom", environmentId: "env123" },
] as TContactAttributeKey[];
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
const contactWithAttributes = {
id: "contact123",
environmentId: "env123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
userId: null,
attributes: [
{ attributeKey: existingAttributeKeys[0], value: "john@example.com" },
{ attributeKey: existingAttributeKeys[1], value: "user123" },
{ attributeKey: existingAttributeKeys[2], value: "John" },
],
};
vi.mocked(prisma.contact.create).mockResolvedValue(contactWithAttributes);
const result = await createContact(contactData);
expect(result.ok).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(2); // Called once for email check and once for userId check
});
});
});

View File

@@ -0,0 +1,138 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TContactCreateRequest, TContactResponse } from "@/modules/ee/contacts/types/contact";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const createContact = async (
contactData: TContactCreateRequest
): Promise<Result<TContactResponse, ApiErrorResponseV2>> => {
const { environmentId, attributes } = contactData;
try {
const emailValue = attributes.email;
if (!emailValue) {
return err({
type: "bad_request",
details: [{ field: "attributes", issue: "email attribute is required" }],
});
}
// Extract userId if present
const userId = attributes.userId;
// Check for existing contact with same email
const existingContactByEmail = await prisma.contact.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeKey: { key: "email" },
value: emailValue,
},
},
},
});
if (existingContactByEmail) {
return err({
type: "conflict",
details: [{ field: "email", issue: "contact with this email already exists" }],
});
}
// Check for existing contact with same userId (if provided)
if (userId) {
const existingContactByUserId = await prisma.contact.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeKey: { key: "userId" },
value: userId,
},
},
},
});
if (existingContactByUserId) {
return err({
type: "conflict",
details: [{ field: "userId", issue: "contact with this userId already exists" }],
});
}
}
// Get all attribute keys that need to exist
const attributeKeys = Object.keys(attributes);
// Check which attribute keys exist in the environment
const existingAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
environmentId,
key: { in: attributeKeys },
},
});
const existingKeySet = new Set(existingAttributeKeys.map((key) => key.key));
// Identify missing attribute keys
const missingKeys = attributeKeys.filter((key) => !existingKeySet.has(key));
// If any keys are missing, return an error
if (missingKeys.length > 0) {
return err({
type: "bad_request",
details: [{ field: "attributes", issue: `attribute keys not found: ${missingKeys.join(", ")}. ` }],
});
}
const attributeData = Object.entries(attributes).map(([key, value]) => {
const attributeKey = existingAttributeKeys.find((ak) => ak.key === key)!;
return {
attributeKeyId: attributeKey.id,
value,
};
});
const result = await prisma.contact.create({
data: {
environmentId,
attributes: {
createMany: {
data: attributeData,
},
},
},
select: {
id: true,
createdAt: true,
environmentId: true,
attributes: {
include: {
attributeKey: true,
},
},
},
});
// Format the response with flattened attributes
const flattenedAttributes: Record<string, string> = {};
result.attributes.forEach((attr) => {
flattenedAttributes[attr.attributeKey.key] = attr.value;
});
const response: TContactResponse = {
id: result.id,
createdAt: result.createdAt,
environmentId: result.environmentId,
attributes: flattenedAttributes,
};
return ok(response);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contact", issue: error.message }],
});
}
};

View File

@@ -0,0 +1,61 @@
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZContactCreateRequest, ZContactResponse } from "@/modules/ee/contacts/types/contact";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description:
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description:
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.",
content: {
"application/json": {
schema: ZContactCreateRequest,
example: {
environmentId: "env_01h2xce9q8p3w4x5y6z7a8b9c0",
attributes: {
email: "john.doe@example.com",
firstName: "John",
lastName: "Doe",
userId: "h2xce9q8p3w4x5y6z7a8b9c1",
},
},
},
},
},
responses: {
"201": {
description: "Contact created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactResponse),
example: {
id: "ctc_01h2xce9q8p3w4x5y6z7a8b9c2",
createdAt: "2023-01-01T12:00:00.000Z",
environmentId: "env_01h2xce9q8p3w4x5y6z7a8b9c0",
attributes: {
email: "john.doe@example.com",
firstName: "John",
lastName: "Doe",
userId: "h2xce9q8p3w4x5y6z7a8b9c1",
},
},
},
},
},
},
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
post: createContactEndpoint,
},
};

View File

@@ -0,0 +1,66 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { createContact } from "@/modules/ee/contacts/api/v2/management/contacts/lib/contact";
import { ZContactCreateRequest } from "@/modules/ee/contacts/types/contact";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactCreateRequest,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "contacts", issue: "Contacts feature is not enabled for this environment" }],
},
auditLog
);
}
const { environmentId } = body;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return handleApiError(
request,
{
type: "forbidden",
details: [
{
field: "environmentId",
issue: "insufficient permissions to create contact in this environment",
},
],
},
auditLog
);
}
const createContactResult = await createContact(body);
if (!createContactResult.ok) {
return handleApiError(request, createContactResult.error, auditLog);
}
const createdContact = createContactResult.data;
if (auditLog) {
auditLog.targetId = createdContact.id;
auditLog.newObject = createdContact;
}
return responses.createdResponse(createContactResult);
},
action: "created",
targetType: "contact",
});

View File

@@ -6,12 +6,21 @@ import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
import { CsvTable } from "@/modules/ee/contacts/components/csv-table";
import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute";
import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
import { useTranslate } from "@tolgee/react";
import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, CircleAlertIcon, FileUpIcon, PlusIcon, XIcon } from "lucide-react";
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
@@ -286,190 +295,155 @@ export const UploadContactsCSVButton = ({
{t("common.upload")} CSV
<PlusIcon />
</Button>
<Modal
open={open}
setOpen={setOpen}
noPadding
closeOnOutsideClick={false}
className="overflow-auto"
size="xl"
hideCloseButton>
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={() => {
resetState(true);
}}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<FileUpIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">{t("common.upload")} CSV</div>
<div className="text-sm text-slate-500">
{t("environments.contacts.upload_contacts_modal_description")}
</div>
</div>
</div>
</div>
</div>
</div>
{error ? (
<div
className="mx-6 my-4 flex items-center gap-2 rounded-md border-2 border-red-200 bg-red-50 p-4"
ref={errorContainerRef}>
<CircleAlertIcon className="text-red-600" />
<p className="text-red-600">{error}</p>
</div>
) : null}
<div className="flex flex-col gap-8 px-6 py-4">
<div className="flex flex-col gap-2">
<div className="no-scrollbar max-h-[400px] overflow-auto rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{!csvResponse.length ? (
<div>
<label
htmlFor="file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800"
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<input
type="file"
id={"file"}
name={"file"}
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</label>
</div>
) : (
<div className="flex flex-col items-center gap-8">
<h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")}
</h3>
<div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} />
</div>
</div>
)}
</div>
{!csvResponse.length && (
<div className="flex justify-start">
<Button onClick={handleDownloadExampleCSV} variant="secondary">
{t("environments.contacts.upload_contacts_modal_download_example_csv")}
</Button>
</div>
)}
</div>
{csvResponse.length > 0 ? (
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_attributes_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_attributes_description")}
</p>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick={true} className="overflow-auto">
<DialogHeader>
<FileUpIcon />
<DialogTitle>{t("common.upload")} CSV</DialogTitle>
<DialogDescription>
{t("environments.contacts.upload_contacts_modal_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-6">
{error ? (
<Alert variant="error" size="small">
{error}
</Alert>
) : null}
<div className="flex flex-col gap-2">
{csvColumns.map((column, index) => {
return (
<UploadContactsAttributes
key={index}
csvColumn={column}
attributeMap={attributeMap}
setAttributeMap={setAttributeMap}
contactAttributeKeys={contactAttributeKeys}
/>
);
})}
<div className="no-scrollbar rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{!csvResponse.length ? (
<div>
<label
htmlFor="file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800"
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<input
type="file"
id={"file"}
name={"file"}
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</label>
</div>
) : (
<div className="flex flex-col items-center gap-8">
<h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")}
</h3>
<div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} />
</div>
</div>
)}
</div>
{!csvResponse.length && (
<div className="flex justify-start">
<Button onClick={handleDownloadExampleCSV} variant="secondary">
{t("environments.contacts.upload_contacts_modal_download_example_csv")}
</Button>
</div>
)}
</div>
{csvResponse.length > 0 ? (
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_attributes_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_attributes_description")}
</p>
<div className="flex flex-col gap-2">
{csvColumns.map((column, index) => {
return (
<UploadContactsAttributes
key={index}
csvColumn={column}
attributeMap={attributeMap}
setAttributeMap={setAttributeMap}
contactAttributeKeys={contactAttributeKeys}
/>
);
})}
</div>
</div>
) : null}
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p>
<StylingTabs
id="duplicate-contacts"
options={[
{
value: "skip",
label: t("environments.contacts.upload_contacts_modal_duplicates_skip_title"),
},
{
value: "update",
label: t("environments.contacts.upload_contacts_modal_duplicates_update_title"),
},
{
value: "overwrite",
label: t("environments.contacts.upload_contacts_modal_duplicates_overwrite_title"),
},
]}
defaultSelected={duplicateContactsAction}
onChange={(value) => setDuplicateContactsAction(value)}
className="max-w-[400px]"
tabsContainerClassName="p-1 rounded-lg"
/>
<div className="mt-1">
<p className="text-sm font-medium text-slate-500">
{duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" &&
t("environments.contacts.upload_contacts_modal_duplicates_update_description")}
{duplicateContactsAction === "overwrite" &&
t("environments.contacts.upload_contacts_modal_duplicates_overwrite_description")}
</p>
</div>
</div>
</div>
) : null}
</DialogBody>
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p>
<StylingTabs
id="duplicate-contacts"
options={[
{
value: "skip",
label: t("environments.contacts.upload_contacts_modal_duplicates_skip_title"),
},
{
value: "update",
label: t("environments.contacts.upload_contacts_modal_duplicates_update_title"),
},
{
value: "overwrite",
label: t("environments.contacts.upload_contacts_modal_duplicates_overwrite_title"),
},
]}
defaultSelected={duplicateContactsAction}
onChange={(value) => setDuplicateContactsAction(value)}
className="max-w-[400px]"
tabsContainerClassName="p-1 rounded-lg"
/>
<div className="mt-1">
<p className="text-sm font-medium text-slate-500">
{duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" &&
t("environments.contacts.upload_contacts_modal_duplicates_update_description")}
{duplicateContactsAction === "overwrite" &&
t("environments.contacts.upload_contacts_modal_duplicates_overwrite_description")}
</p>
</div>
</div>
</div>
<div className="sticky bottom-0 w-full bg-white">
<div className="flex justify-end rounded-b-lg p-4">
<DialogFooter>
{csvResponse.length > 0 ? (
<Button
size="sm"
variant="secondary"
onClick={() => {
resetState();
}}
className="mr-2">
}}>
{t("environments.contacts.upload_contacts_modal_pick_different_file")}
</Button>
) : null}
<Button
size="sm"
onClick={handleUpload}
loading={loading}
disabled={loading || !csvResponse.length}>
<Button onClick={handleUpload} loading={loading} disabled={loading || !csvResponse.length}>
{t("environments.contacts.upload_contacts_modal_upload_btn")}
</Button>
</div>
</div>
</Modal>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -10,6 +10,12 @@ vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(),
verify: vi.fn(),
TokenExpiredError: class TokenExpiredError extends Error {
constructor(message: string) {
super(message);
this.name = "TokenExpiredError";
}
},
},
}));
@@ -145,8 +151,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
message: "Invalid survey token",
details: [{ field: "token", issue: "invalid_token" }],
});
}
});
@@ -166,8 +172,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
message: "Invalid survey token",
details: [{ field: "token", issue: "invalid_token" }],
});
}
});

View File

@@ -3,6 +3,7 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
@@ -73,11 +74,22 @@ export const verifyContactSurveyToken = (
surveyId,
});
} catch (error) {
console.error("Error verifying contact survey token:", error);
logger.error("Error verifying contact survey token:", error);
// Check if the error is specifically a JWT expiration error
if (error instanceof jwt.TokenExpiredError) {
return err({
type: "bad_request",
message: "Survey link has expired",
details: [{ field: "token", issue: "token_expired" }],
});
}
// Handle other JWT errors or general validation errors
return err({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
message: "Invalid survey token",
details: [{ field: "token", issue: "invalid_token" }],
});
}
};

View File

@@ -6,10 +6,31 @@ import {
buildContactWhereClause,
createContactsFromCSV,
deleteContact,
generatePersonalLinks,
getContact,
getContacts,
getContactsInSegment,
} from "./contacts";
// Mock additional dependencies for the new functions
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
getSegment: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/filter/prisma-query", () => ({
segmentFilterToPrismaQuery: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-survey-link", () => ({
getContactSurveyLink: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
@@ -31,11 +52,18 @@ vi.mock("@formbricks/database", () => ({
},
},
}));
vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 }));
vi.mock("@/lib/constants", () => ({
ITEMS_PER_PAGE: 2,
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
IS_PRODUCTION: false,
IS_POSTHOG_CONFIGURED: false,
POSTHOG_API_HOST: "test-posthog-host",
POSTHOG_API_KEY: "test-posthog-key",
}));
const environmentId = "env1";
const contactId = "contact1";
const userId = "user1";
const environmentId = "cm123456789012345678901237";
const contactId = "cm123456789012345678901238";
const userId = "cm123456789012345678901239";
const mockContact: Contact & {
attributes: { value: string; attributeKey: { key: string; name: string } }[];
} = {
@@ -159,7 +187,7 @@ describe("createContactsFromCSV", () => {
.mockResolvedValueOnce([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "c1",
@@ -183,12 +211,12 @@ describe("createContactsFromCSV", () => {
test("skips duplicate contact with 'skip' action", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{ id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] },
]);
] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
email: "email",
@@ -206,12 +234,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" },
],
},
]);
] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
environmentId,
@@ -239,12 +267,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" },
],
},
]);
] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
@@ -326,8 +354,333 @@ describe("buildContactWhereClause", () => {
});
test("returns where clause without search", () => {
const environmentId = "env-1";
const environmentId = "cm123456789012345678901240";
const result = buildContactWhereClause(environmentId);
expect(result).toEqual({ environmentId });
});
});
describe("getContactsInSegment", () => {
const mockSegmentId = "cm123456789012345678901235";
const mockEnvironmentId = "cm123456789012345678901236";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contacts when segment and filters are valid", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const mockContacts = [
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
] as any;
const mockWhereClause = {
environmentId: mockEnvironmentId,
attributes: {
some: {
attributeKey: { key: "email" },
value: "test@example.com",
},
},
};
// Mock the dependencies
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: mockWhereClause },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
const result = await getContactsInSegment(mockSegmentId);
expect(result).toEqual([
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
]);
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: mockWhereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: ["userId", "firstName", "lastName", "email"],
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
});
test("returns null when segment is not found", async () => {
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("returns null when segment filter to prisma query fails", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: false,
error: { type: "bad_request" },
} as any);
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("returns null when prisma query fails", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockRejectedValue(new Error("Database error"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("handles errors gracefully", async () => {
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Database error"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull(); // The function catches errors and returns null
});
});
describe("generatePersonalLinks", () => {
const mockSurveyId = "cm123456789012345678901234"; // Valid CUID2 format
const mockSegmentId = "cm123456789012345678901235"; // Valid CUID2 format
const mockExpirationDays = 7;
beforeEach(() => {
vi.clearAllMocks();
});
test("returns null when getContactsInSegment fails", async () => {
// Mock getSegment to fail which will cause getContactsInSegment to return null
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
expect(result).toBeNull();
});
test("returns empty array when no contacts in segment", async () => {
// Mock successful segment retrieval but no contacts
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue({
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-123",
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
});
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
expect(result).toEqual([]);
});
test("generates personal links for contacts successfully", async () => {
// Mock all the dependencies that getContactsInSegment needs
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
const { getContactSurveyLink } = await import("@/modules/ee/contacts/lib/contact-survey-link");
vi.mocked(getSegment).mockResolvedValue({
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-123",
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
});
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
] as any);
// Mock getContactSurveyLink to return successful results
vi.mocked(getContactSurveyLink)
.mockReturnValueOnce({
ok: true,
data: "https://example.com/survey/link1",
})
.mockReturnValueOnce({
ok: true,
data: "https://example.com/survey/link2",
});
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId, mockExpirationDays);
expect(result).toEqual([
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
surveyUrl: "https://example.com/survey/link1",
expirationDays: mockExpirationDays,
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
surveyUrl: "https://example.com/survey/link2",
expirationDays: mockExpirationDays,
},
]);
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-1", mockSurveyId, mockExpirationDays);
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-2", mockSurveyId, mockExpirationDays);
});
});

View File

@@ -1,9 +1,13 @@
import "server-only";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
@@ -15,6 +19,76 @@ import {
} from "../types/contact";
import { transformPrismaContact } from "./utils";
export const getContactsInSegment = reactCache(async (segmentId: string) => {
try {
const segment = await getSegment(segmentId);
if (!segment) {
return null;
}
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
segment.id,
segment.filters,
segment.environmentId
);
if (!segmentFilterToPrismaQueryResult.ok) {
return null;
}
const { whereClause } = segmentFilterToPrismaQueryResult.data;
const requiredAttributes = ["userId", "firstName", "lastName", "email"];
const contacts = await prisma.contact.findMany({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: requiredAttributes,
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
const contactsWithAttributes = contacts.map((contact) => {
const attributes = contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
return {
contactId: contact.id,
attributes,
};
});
return contactsWithAttributes;
} catch (error) {
logger.error(error, "Failed to get contacts in segment");
return null;
}
});
const selectContact = {
id: true,
createdAt: true,
@@ -418,3 +492,37 @@ export const createContactsFromCSV = async (
throw error;
}
};
export const generatePersonalLinks = async (surveyId: string, segmentId: string, expirationDays?: number) => {
const contactsResult = await getContactsInSegment(segmentId);
if (!contactsResult) {
return null;
}
// Generate survey links for each contact
const contactLinks = contactsResult
.map((contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
if (!surveyUrlResult.ok) {
logger.error(
{ error: surveyUrlResult.error, contactId: contactId, surveyId: surveyId },
"Failed to generate survey URL for contact"
);
return null;
}
return {
contactId,
attributes,
surveyUrl: surveyUrlResult.data,
expirationDays,
};
})
.filter(Boolean);
return contactLinks;
};

Some files were not shown because too many files have changed in this diff Show More