diff --git a/.cursor/commands/create-question.md b/.cursor/commands/create-question.md
new file mode 100644
index 0000000000..c34efdcebe
--- /dev/null
+++ b/.cursor/commands/create-question.md
@@ -0,0 +1,352 @@
+# Create New Question Element
+
+Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
+
+## Usage
+
+When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
+
+1. **Create the component file** `{question-type}.tsx` with this structure:
+
+```typescript
+import * as React from "react";
+import { ElementHeader } from "../components/element-header";
+import { useTextDirection } from "../hooks/use-text-direction";
+import { cn } from "../lib/utils";
+
+interface {QuestionType}Props {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main question or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the input/control group */
+ inputId: string;
+ /** Current value */
+ value?: {ValueType};
+ /** Callback function called when the value changes */
+ onChange: (value: {ValueType}) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the controls are disabled */
+ disabled?: boolean;
+ // Add question-specific props here
+}
+
+function {QuestionType}({
+ elementId,
+ headline,
+ description,
+ inputId,
+ value,
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+ // ... question-specific props
+}: {QuestionType}Props): React.JSX.Element {
+ // Ensure value is always the correct type (handle undefined/null)
+ const currentValue = value ?? {defaultValue};
+
+ // Detect text direction from content
+ const detectedDir = useTextDirection({
+ dir,
+ textContent: [headline, description ?? "", /* add other text content from question */],
+ });
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Question-specific controls */}
+ {/* TODO: Add your question-specific UI here */}
+
+ {/* Error message */}
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+ );
+}
+
+export { {QuestionType} };
+export type { {QuestionType}Props };
+```
+
+2. **Create the Storybook file** `{question-type}.stories.tsx`:
+
+```typescript
+import type { Decorator, Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
+
+// Styling options for the StylingPlayground story
+interface StylingOptions {
+ // Question styling
+ questionHeadlineFontFamily: string;
+ questionHeadlineFontSize: string;
+ questionHeadlineFontWeight: string;
+ questionHeadlineColor: string;
+ questionDescriptionFontFamily: string;
+ questionDescriptionFontWeight: string;
+ questionDescriptionFontSize: string;
+ questionDescriptionColor: string;
+ // Add component-specific styling options here
+}
+
+type StoryProps = {QuestionType}Props & Partial;
+
+const meta: Meta = {
+ title: "UI-package/Elements/{QuestionType}",
+ component: {QuestionType},
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component: "A complete {question type} question element...",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ headline: {
+ control: "text",
+ description: "The main question text",
+ table: { category: "Content" },
+ },
+ description: {
+ control: "text",
+ description: "Optional description or subheader text",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "object",
+ description: "Current value",
+ table: { category: "State" },
+ },
+ required: {
+ control: "boolean",
+ description: "Whether the field is required",
+ table: { category: "Validation" },
+ },
+ errorMessage: {
+ control: "text",
+ description: "Error message to display",
+ table: { category: "Validation" },
+ },
+ dir: {
+ control: { type: "select" },
+ options: ["ltr", "rtl", "auto"],
+ description: "Text direction for RTL support",
+ table: { category: "Layout" },
+ },
+ disabled: {
+ control: "boolean",
+ description: "Whether the controls are disabled",
+ table: { category: "State" },
+ },
+ onChange: {
+ action: "changed",
+ table: { category: "Events" },
+ },
+ // Add question-specific argTypes here
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+const withCSSVariables: Decorator = (Story, context) => {
+ const args = context.args as StoryProps;
+ const {
+ questionHeadlineFontFamily,
+ questionHeadlineFontSize,
+ questionHeadlineFontWeight,
+ questionHeadlineColor,
+ questionDescriptionFontFamily,
+ questionDescriptionFontSize,
+ questionDescriptionFontWeight,
+ questionDescriptionColor,
+ // Extract component-specific styling options
+ } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-question-headline-font-family": questionHeadlineFontFamily,
+ "--fb-question-headline-font-size": questionHeadlineFontSize,
+ "--fb-question-headline-font-weight": questionHeadlineFontWeight,
+ "--fb-question-headline-color": questionHeadlineColor,
+ "--fb-question-description-font-family": questionDescriptionFontFamily,
+ "--fb-question-description-font-size": questionDescriptionFontSize,
+ "--fb-question-description-font-weight": questionDescriptionFontWeight,
+ "--fb-question-description-color": questionDescriptionColor,
+ // Add component-specific CSS variables
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "Example question?",
+ description: "Example description",
+ // Default styling values
+ questionHeadlineFontFamily: "system-ui, sans-serif",
+ questionHeadlineFontSize: "1.125rem",
+ questionHeadlineFontWeight: "600",
+ questionHeadlineColor: "#1e293b",
+ questionDescriptionFontFamily: "system-ui, sans-serif",
+ questionDescriptionFontSize: "0.875rem",
+ questionDescriptionFontWeight: "400",
+ questionDescriptionColor: "#64748b",
+ // Add component-specific default values
+ },
+ argTypes: {
+ // Question styling argTypes
+ questionHeadlineFontFamily: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionHeadlineFontSize: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionHeadlineFontWeight: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionHeadlineColor: {
+ control: "color",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionFontFamily: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionFontSize: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionFontWeight: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionColor: {
+ control: "color",
+ table: { category: "Question Styling" },
+ },
+ // Add component-specific argTypes
+ },
+ decorators: [withCSSVariables],
+};
+
+export const Default: Story = {
+ args: {
+ headline: "Example question?",
+ // Add default props
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "Example question?",
+ description: "Example description text",
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "Example question?",
+ required: true,
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "Example question?",
+ errorMessage: "This field is required",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "Example question?",
+ disabled: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "مثال على السؤال؟",
+ description: "مثال على الوصف",
+ // Add RTL-specific props
+ },
+};
+```
+
+3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
+
+```css
+/* Component-specific CSS variables */
+--fb-{component}-{property}: {default-value};
+```
+
+4. **Export from** `packages/survey-ui/src/index.ts`:
+
+```typescript
+export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
+```
+
+## Key Requirements
+
+- ✅ Always use `ElementHeader` component for headline/description
+- ✅ Always use `useTextDirection` hook for RTL support
+- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
+- ✅ Always include error message display if applicable
+- ✅ Always support disabled state if applicable
+- ✅ Always add JSDoc comments to props interface
+- ✅ Always create Storybook stories with styling playground
+- ✅ Always export types from component file
+- ✅ Always add to index.ts exports
+
+## Examples
+
+- `open-text.tsx` - Text input/textarea question (string value)
+- `multi-select.tsx` - Multiple checkbox selection (string[] value)
+
+## Checklist
+
+When creating a new question element, verify:
+
+- [ ] Component file created with proper structure
+- [ ] Props interface with JSDoc comments for all props
+- [ ] Uses `ElementHeader` component (don't duplicate header logic)
+- [ ] Uses `useTextDirection` hook for RTL support
+- [ ] Handles undefined/null values safely
+- [ ] Storybook file created with styling playground
+- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
+- [ ] CSS variables added to `globals.css` if component needs custom styling
+- [ ] Exported from `index.ts` with types
+- [ ] TypeScript types properly exported
+- [ ] Error message display included if applicable
+- [ ] Disabled state supported if applicable
+
diff --git a/.env.example b/.env.example
index 2441c96863..81f846846c 100644
--- a/.env.example
+++ b/.env.example
@@ -9,8 +9,12 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
+# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000
+# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
+# BASE_PATH=
+
# Encryption keys
# Please set both for now, we will change this in the future
@@ -189,8 +193,9 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
-# INTERCOM_APP_ID=
-# INTERCOM_SECRET_KEY=
+# Chatwoot
+# CHATWOOT_BASE_URL=
+# CHATWOOT_WEBSITE_TOKEN=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index ebdfa42909..bf12561d94 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -3,14 +3,10 @@ name: E2E Tests
on:
workflow_call:
secrets:
- AZURE_CLIENT_ID:
- required: false
- AZURE_TENANT_ID:
- required: false
- AZURE_SUBSCRIPTION_ID:
- required: false
PLAYWRIGHT_SERVICE_URL:
required: false
+ PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
+ required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
@@ -21,7 +17,6 @@ env:
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
- id-token: write
contents: read
actions: read
@@ -114,7 +109,7 @@ jobs:
- name: Start MinIO Server
run: |
set -euo pipefail
-
+
# Start MinIO server in background
docker run -d \
--name minio-server \
@@ -124,7 +119,7 @@ jobs:
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-
+
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
@@ -207,32 +202,30 @@ jobs:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- - name: Set Azure Secret Variables
- run: |
- if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
- echo "AZURE_ENABLED=true" >> $GITHUB_ENV
- else
- echo "AZURE_ENABLED=false" >> $GITHUB_ENV
- fi
-
- - name: Azure login
- if: env.AZURE_ENABLED == 'true'
- uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
- with:
- client-id: ${{ secrets.AZURE_CLIENT_ID }}
- tenant-id: ${{ secrets.AZURE_TENANT_ID }}
- subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
-
- - name: Run E2E Tests (Azure)
- if: env.AZURE_ENABLED == 'true'
+ - name: Determine Playwright execution mode
+ shell: bash
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
- CI: true
+ PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
run: |
- pnpm test-e2e:azure
+ set -euo pipefail
+
+ if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
+ echo "PW_MODE=service" >> "$GITHUB_ENV"
+ else
+ echo "PW_MODE=local" >> "$GITHUB_ENV"
+ fi
+
+ - name: Run E2E Tests (Playwright Service)
+ if: env.PW_MODE == 'service'
+ env:
+ PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
+ PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
+ CI: true
+ run: pnpm test-e2e:azure
- name: Run E2E Tests (Local)
- if: env.AZURE_ENABLED == 'false'
+ if: env.PW_MODE == 'local'
env:
CI: true
run: |
diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts
index fa597f552f..0b16b26f64 100644
--- a/apps/storybook/.storybook/main.ts
+++ b/apps/storybook/.storybook/main.ts
@@ -1,8 +1,11 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
-import { dirname, join } from "path";
+import { dirname, join, resolve } from "path";
+import { fileURLToPath } from "url";
const require = createRequire(import.meta.url);
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
/**
* This function is used to resolve the absolute path of a package.
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
- stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
+ stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
+ async viteFinal(config) {
+ const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
+ const rootPath = resolve(__dirname, "../../../");
+
+ // Configure server to allow files from outside the storybook directory
+ config.server = config.server || {};
+ config.server.fs = {
+ ...config.server.fs,
+ allow: [...(config.server.fs?.allow || []), rootPath],
+ };
+
+ // Configure simple alias resolution
+ config.resolve = config.resolve || {};
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ "@": surveyUiPath,
+ };
+
+ return config;
+ },
};
export default config;
diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts
index b0c7224444..58c0444d37 100644
--- a/apps/storybook/.storybook/preview.ts
+++ b/apps/storybook/.storybook/preview.ts
@@ -1,19 +1,6 @@
import type { Preview } from "@storybook/react-vite";
import React from "react";
-import { I18nProvider } from "../../web/lingodotdev/client";
-import "../../web/modules/ui/globals.css";
-
-// Create a Storybook-specific Lingodot Dev decorator
-const withLingodotDev = (Story: any) => {
- return React.createElement(
- I18nProvider,
- {
- language: "en-US",
- defaultLanguage: "en-US",
- } as any,
- React.createElement(Story)
- );
-};
+import "../../../packages/survey-ui/src/styles/globals.css";
const preview: Preview = {
parameters: {
@@ -22,9 +9,23 @@ const preview: Preview = {
color: /(background|color)$/i,
date: /Date$/i,
},
+ expanded: true,
+ },
+ backgrounds: {
+ default: "light",
},
},
- decorators: [withLingodotDev],
+ decorators: [
+ (Story) =>
+ React.createElement(
+ "div",
+ {
+ id: "fbjs",
+ className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
+ },
+ React.createElement(Story)
+ ),
+ ],
};
export default preview;
diff --git a/apps/storybook/package.json b/apps/storybook/package.json
index 85ff2a6110..5b32e7d321 100644
--- a/apps/storybook/package.json
+++ b/apps/storybook/package.json
@@ -11,22 +11,24 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
- "eslint-plugin-react-refresh": "0.4.20"
+ "@formbricks/survey-ui": "workspace:*",
+ "eslint-plugin-react-refresh": "0.4.24"
},
"devDependencies": {
- "@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": "9.0.15",
+ "@chromatic-com/storybook": "^4.1.3",
+ "@storybook/addon-a11y": "10.0.8",
+ "@storybook/addon-links": "10.0.8",
+ "@storybook/addon-onboarding": "10.0.8",
+ "@storybook/react-vite": "10.0.8",
+ "@typescript-eslint/eslint-plugin": "8.48.0",
+ "@tailwindcss/vite": "4.1.17",
+ "@typescript-eslint/parser": "8.48.0",
+ "@vitejs/plugin-react": "5.1.1",
+ "esbuild": "0.27.0",
+ "eslint-plugin-storybook": "10.0.8",
"prop-types": "15.8.1",
- "storybook": "9.0.15",
- "vite": "6.4.1",
- "@storybook/addon-docs": "9.0.15"
+ "storybook": "10.0.8",
+ "vite": "7.2.4",
+ "@storybook/addon-docs": "10.0.8"
}
}
diff --git a/apps/storybook/postcss.config.js b/apps/storybook/postcss.config.js
deleted file mode 100644
index 2aa7205d4b..0000000000
--- a/apps/storybook/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-};
diff --git a/apps/storybook/tailwind.config.js b/apps/storybook/tailwind.config.js
index 574e3b7b54..3b65545a60 100644
--- a/apps/storybook/tailwind.config.js
+++ b/apps/storybook/tailwind.config.js
@@ -1,7 +1,15 @@
/** @type {import('tailwindcss').Config} */
-import base from "../web/tailwind.config";
+import surveyUi from "../../packages/survey-ui/tailwind.config";
export default {
- ...base,
- content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ "../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ ...surveyUi.theme?.extend,
+ },
+ },
};
diff --git a/apps/storybook/vite.config.ts b/apps/storybook/vite.config.ts
index 37337dae71..f4068dd20b 100644
--- a/apps/storybook/vite.config.ts
+++ b/apps/storybook/vite.config.ts
@@ -1,16 +1,17 @@
+import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
define: {
"process.env": {},
},
resolve: {
alias: {
- "@": path.resolve(__dirname, "../web"),
+ "@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
},
},
});
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 1a3848b400..e9c50266d6 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
+# Base path for the application (optional)
+ARG BASE_PATH=""
+ENV BASE_PATH=${BASE_PATH}
+
# Set the working directory
WORKDIR /app
@@ -73,8 +77,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
-RUN npm install --ignore-scripts -g corepack@latest
-RUN corepack enable
+RUN npm install --ignore-scripts -g corepack@latest && \
+ corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -134,12 +138,13 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
-# Prepare volume for uploads
-RUN mkdir -p /home/nextjs/apps/web/uploads/
-VOLUME /home/nextjs/apps/web/uploads/
+# Prepare pnpm as the nextjs user to ensure it's available at runtime
+# Prepare volumes for uploads and SAML connections
+RUN corepack prepare pnpm@9.15.9 --activate && \
+ mkdir -p /home/nextjs/apps/web/uploads/ && \
+ mkdir -p /home/nextjs/apps/web/saml-connection
-# Prepare volume for SAML preloaded connection
-RUN mkdir -p /home/nextjs/apps/web/saml-connection
+VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]
\ No newline at end of file
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx
index 27048e46aa..64d5ed60d1 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx
@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
+ publicDomain: string;
}
export const ProjectSettings = ({
@@ -55,6 +56,7 @@ export const ProjectSettings = ({
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
+ publicDomain,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -231,6 +233,7 @@ export const ProjectSettings = ({
{t("common.preview")}
{
throw new Error(t("common.organization_teams_not_found"));
}
+ const publicDomain = getPublicDomain();
+
return (
{
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
+ publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{
const t = await getTranslate();
+ const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
@@ -72,6 +74,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
+ publicDomain={publicDomain}
/>
{
const router = useRouter();
const pathname = usePathname();
@@ -286,15 +288,16 @@ export const MainNavigation = ({
{/* Logout */}
{
+ const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
- redirectUrl: "/auth/login",
+ redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
- callbackUrl: "/auth/login",
+ callbackUrl: loginUrl,
clearEnvironmentId: true,
});
- router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
+ router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}}
icon={ }>
{t("common.logout")}
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx
index 8edf6d648e..95ca15d5f6 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx
@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
},
{
id: "teams",
- label: t("common.teams"),
+ label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
index a57e803981..10390b0bc9 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx
@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
},
{
id: "teams",
- label: t("common.teams"),
+ label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/components/pretty-urls-table.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/components/pretty-urls-table.tsx
index 7eb0787c33..557494d599 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/components/pretty-urls-table.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/components/pretty-urls-table.tsx
@@ -1,5 +1,6 @@
"use client";
+import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurveyStatus } from "@formbricks/types/surveys/types";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -59,10 +60,16 @@ export const PrettyUrlsTable = ({ surveys = [] }: PrettyUrlsTableProps) => {
)}
{surveys.map((survey) => (
- {survey.name}
+
+
+ {survey.name}
+
+
{survey.environment.project.name}
-
+
{
+export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabProps) => {
const { t } = useTranslation();
+ const router = useRouter();
+ const { survey } = useSurvey();
const [isEditing, setIsEditing] = useState(!survey.slug);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
- const form = useForm({
- defaultValues: {
+ // Initialize form with current values - memoize to prevent re-initialization
+ const initialFormData = useMemo(() => {
+ return {
slug: survey.slug || "",
- },
+ };
+ }, [survey.slug]);
+
+ const form = useForm({
+ defaultValues: initialFormData,
});
const { handleSubmit, reset } = form;
+ // Sync isEditing state and form with survey.slug changes
+ useEffect(() => {
+ setIsEditing(!survey.slug);
+ reset({ slug: survey.slug || "" });
+ }, [survey.slug, reset]);
+
const onSubmit = async (data: PrettyUrlFormData) => {
if (!data.slug.trim()) {
toast.error(t("environments.surveys.share.pretty_url.slug_required"));
@@ -60,6 +73,7 @@ export const PrettyUrlTab = ({ survey, publicDomain, isReadOnly = false }: Prett
if (result?.data) {
if (result.data.ok) {
toast.success(t("environments.surveys.share.pretty_url.save_success"));
+ router.refresh();
setIsEditing(false);
} else {
toast.error(result.data.error.message);
@@ -93,6 +107,7 @@ export const PrettyUrlTab = ({ survey, publicDomain, isReadOnly = false }: Prett
if (result.data.ok) {
setShowRemoveDialog(false);
reset({ slug: "" });
+ router.refresh();
setIsEditing(true);
toast.success(t("environments.surveys.share.pretty_url.remove_success"));
} else {
@@ -109,6 +124,13 @@ export const PrettyUrlTab = ({ survey, publicDomain, isReadOnly = false }: Prett
}
};
+ const handleCopyUrl = () => {
+ if (!survey.slug) return;
+ const prettyUrl = `${publicDomain}/p/${survey.slug}`;
+ navigator.clipboard.writeText(prettyUrl);
+ toast.success(t("common.copied_to_clipboard"));
+ };
+
return (
@@ -151,14 +173,20 @@ export const PrettyUrlTab = ({ survey, publicDomain, isReadOnly = false }: Prett
)}
{survey.slug && !isEditing && (
- setShowRemoveDialog(true)}
- disabled={isReadOnly}>
-
- {t("common.remove")}
-
+ <>
+
+
+ {t("common.copy")} URL
+
+ setShowRemoveDialog(true)}
+ disabled={isReadOnly}>
+
+ {t("common.remove")}
+
+ >
)}
@@ -169,11 +197,7 @@ export const PrettyUrlTab = ({ survey, publicDomain, isReadOnly = false }: Prett
setOpen={setShowRemoveDialog}
deleteWhat={t("environments.surveys.share.pretty_url.title")}
onDelete={handleRemove}
- text={t("environments.surveys.share.pretty_url.remove_description")}>
-
-
{`${publicDomain}/p/${survey.slug || ""}`}
-
-
+ text={t("environments.surveys.share.pretty_url.remove_description")}>
);
};
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
index 65465c32a4..64b174a46f 100644
--- a/apps/web/app/(app)/layout.tsx
+++ b/apps/web/app/(app)/layout.tsx
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
-import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
+import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
+import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -18,7 +19,15 @@ const AppLayout = async ({ children }) => {
return (
<>
-
+ {IS_CHATWOOT_CONFIGURED && (
+
+ )}
{children}
>
diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx
index ddebf022be..fe322623f6 100644
--- a/apps/web/app/(auth)/layout.tsx
+++ b/apps/web/app/(auth)/layout.tsx
@@ -1,11 +1,9 @@
-import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
return (
<>
-
{children}
>
);
diff --git a/apps/web/app/chatwoot/ChatwootWidget.tsx b/apps/web/app/chatwoot/ChatwootWidget.tsx
new file mode 100644
index 0000000000..c175d373d0
--- /dev/null
+++ b/apps/web/app/chatwoot/ChatwootWidget.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+
+interface ChatwootWidgetProps {
+ chatwootBaseUrl: string;
+ chatwootWebsiteToken?: string;
+ userEmail?: string | null;
+ userName?: string | null;
+ userId?: string | null;
+}
+
+const CHATWOOT_SCRIPT_ID = "chatwoot-script";
+
+export const ChatwootWidget = ({
+ userEmail,
+ userName,
+ userId,
+ chatwootWebsiteToken,
+ chatwootBaseUrl,
+}: ChatwootWidgetProps) => {
+ const userSetRef = useRef(false);
+
+ const setUserInfo = useCallback(() => {
+ const $chatwoot = (
+ globalThis as unknown as {
+ $chatwoot: {
+ setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
+ };
+ }
+ ).$chatwoot;
+ if (userId && $chatwoot && !userSetRef.current) {
+ $chatwoot.setUser(userId, {
+ email: userEmail,
+ name: userName,
+ });
+ userSetRef.current = true;
+ }
+ }, [userId, userEmail, userName]);
+
+ useEffect(() => {
+ if (!chatwootWebsiteToken) return;
+
+ const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
+ if (existingScript) return;
+
+ const script = document.createElement("script");
+ script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
+ script.id = CHATWOOT_SCRIPT_ID;
+ script.async = true;
+
+ script.onload = () => {
+ (
+ globalThis as unknown as {
+ chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
+ }
+ ).chatwootSDK?.run({
+ websiteToken: chatwootWebsiteToken,
+ baseUrl: chatwootBaseUrl,
+ });
+ };
+
+ document.head.appendChild(script);
+
+ const handleChatwootReady = () => setUserInfo();
+ globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
+
+ // Check if Chatwoot is already ready
+ if (
+ (
+ globalThis as unknown as {
+ $chatwoot: {
+ setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
+ };
+ }
+ ).$chatwoot
+ ) {
+ setUserInfo();
+ }
+
+ return () => {
+ globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
+
+ const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
+ if ($chatwoot) {
+ $chatwoot.reset();
+ }
+
+ const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
+ scriptElement?.remove();
+
+ userSetRef.current = false;
+ };
+ }, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
+
+ return null;
+};
diff --git a/apps/web/app/intercom/IntercomClient.tsx b/apps/web/app/intercom/IntercomClient.tsx
deleted file mode 100644
index 25581184ca..0000000000
--- a/apps/web/app/intercom/IntercomClient.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client";
-
-import Intercom from "@intercom/messenger-js-sdk";
-import { useCallback, useEffect } from "react";
-import { TUser } from "@formbricks/types/user";
-
-interface IntercomClientProps {
- isIntercomConfigured: boolean;
- intercomUserHash?: string;
- user?: TUser | null;
- intercomAppId?: string;
-}
-
-export const IntercomClient = ({
- user,
- intercomUserHash,
- isIntercomConfigured,
- intercomAppId,
-}: IntercomClientProps) => {
- const initializeIntercom = useCallback(() => {
- let initParams = {};
-
- if (user && intercomUserHash) {
- const { id, name, email, createdAt } = user;
-
- initParams = {
- user_id: id,
- user_hash: intercomUserHash,
- name,
- email,
- created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
- };
- }
-
- Intercom({
- app_id: intercomAppId!,
- ...initParams,
- });
- }, [user, intercomUserHash, intercomAppId]);
-
- useEffect(() => {
- try {
- if (isIntercomConfigured) {
- if (!intercomAppId) {
- throw new Error("Intercom app ID is required");
- }
-
- if (user && !intercomUserHash) {
- throw new Error("Intercom user hash is required");
- }
-
- initializeIntercom();
- }
-
- return () => {
- // Shutdown Intercom when component unmounts
- if (typeof window !== "undefined" && window.Intercom) {
- window.Intercom("shutdown");
- }
- };
- } catch (error) {
- console.error("Failed to initialize Intercom:", error);
- }
- }, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
-
- return null;
-};
diff --git a/apps/web/app/intercom/IntercomClientWrapper.tsx b/apps/web/app/intercom/IntercomClientWrapper.tsx
deleted file mode 100644
index 488e0fe898..0000000000
--- a/apps/web/app/intercom/IntercomClientWrapper.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { createHmac } from "crypto";
-import type { TUser } from "@formbricks/types/user";
-import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
-import { IntercomClient } from "./IntercomClient";
-
-interface IntercomClientWrapperProps {
- user?: TUser | null;
-}
-
-export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
- let intercomUserHash: string | undefined;
- if (user) {
- const secretKey = INTERCOM_SECRET_KEY;
- if (secretKey) {
- intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
- }
- }
- return (
-
- );
-};
diff --git a/apps/web/app/p/[slug]/page.tsx b/apps/web/app/p/[slug]/page.tsx
index 2ab69f6ba9..4e5c5277c8 100644
--- a/apps/web/app/p/[slug]/page.tsx
+++ b/apps/web/app/p/[slug]/page.tsx
@@ -27,7 +27,8 @@ export default async function PrettyUrlPage(props: PrettyUrlPageProps) {
Object.entries(searchParams).filter(([_, v]) => v !== undefined) as [string, string][]
).toString();
- const redirectUrl = `/s/${survey.id}${queryString ? `?${queryString}` : ""}`;
+ const baseUrl = `/s/${survey.id}`;
+ const redirectUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
redirect(redirectUrl);
}
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 21abccb92f..9af0ccf57b 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -237,6 +237,7 @@ checksums:
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
+ common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
@@ -314,9 +315,10 @@ checksums:
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
common/quotas: e6afead11b5b8ae627885ce2b84a548f
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
- common/read_docs: 426ba960bfedf186a878b7467867f9d2
+ common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
+ common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
@@ -326,10 +328,10 @@ checksums:
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f
- common/role_organization: e7dbf80450ceac1c6c22ba5602ea7e66
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
+ common/save_as_draft: b1b38812110113627d141db981fb1b12
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
common/saving: 27ad05746d65e2f3f17d327eb181725d
common/search: 49dd6c21604b5e8d4153ff1aff2177e1
@@ -384,7 +386,8 @@ checksums:
common/team_access: 45c6232c71b760eaa33b932dabab4c1c
common/team_id: 134e32d6f7184577a46b2fd83e85e532
common/team_name: 549d949de4b9adad4afd6427a60a329e
- common/teams: a2fbdec69342366a2b6033d119aa279a
+ common/team_role: 66db395781aef64ef3791417b3b67c0b
+ common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
@@ -445,6 +448,7 @@ checksums:
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
+ emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
@@ -456,12 +460,14 @@ checksums:
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
+ emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
emails/password_changed_email_heading: 601f68fc8bef9c5ecf79f4ec4de5ad06
emails/password_changed_email_text: f9ed4db250ec1b2adf4cb4527ec72d78
emails/password_reset_notify_email_subject: 0a6805fc27c5bb7999f0d311ef5981e1
emails/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
emails/reject: 417c19f66db70a0548bdeb398cdc46e0
emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f
+ emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9
emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9
emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5
emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06
@@ -473,6 +479,7 @@ checksums:
emails/survey_response_finished_email_turn_off_notifications_for_this_form: 7b6a7074490ceaf3d1903a37169364d6
emails/survey_response_finished_email_view_more_responses: fe053505f470cbbb5823ca15ceefcedd
emails/survey_response_finished_email_view_survey_summary: c4e8b5207c0dc856a01011c8b91e0d94
+ emails/text_variable: 5fdfcc48b8010a4f44e16b8051272a75
emails/verification_email_click_on_this_link: 3c9ad15bd2e3822d3ecd85a421311ebc
emails/verification_email_heading: 0f86a46d434bb4595b8753d3cf2524e0
emails/verification_email_hey: 20c5157a424f7d49ceeb27e6fb13d194
@@ -844,7 +851,6 @@ checksums:
environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553
environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad
environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d
- environments/project/teams/only_organization_owners_and_managers_can_manage_teams: 179056fade669d34f63fb1ee965b8024
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
@@ -1096,13 +1102,17 @@ checksums:
environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab
environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac
environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f
+ environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2
environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18
environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12
+ environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10
environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf
environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
+ environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
+ environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca
environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253
@@ -1186,6 +1196,10 @@ checksums:
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
+ environments/surveys/edit/bulk_edit: 59bd1a55587c8cbad716afbf2509e5bb
+ environments/surveys/edit/bulk_edit_description: 9b5b2c6183c6c51689e16d7ba02ec9bb
+ environments/surveys/edit/bulk_edit_options: 74ebec7c53be729f33e38d7605b25815
+ environments/surveys/edit/bulk_edit_options_for: 986af3a8286f34c9e4ad7c74d3c65ada
environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a
environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
@@ -1305,11 +1319,13 @@ checksums:
environments/surveys/edit/follow_ups_ending_card_delete_modal_text: 71ac1865afe2b2f76836dcbebd1a813e
environments/surveys/edit/follow_ups_ending_card_delete_modal_title: 11d0b31535034e0a86c906557fb6f22e
environments/surveys/edit/follow_ups_hidden_field_error: 28aa017b194fb6d7d6c06a8a0bf843ff
+ environments/surveys/edit/follow_ups_include_hidden_fields: 8f0c2f8ddd3b95a3e7456a42be9362bb
+ environments/surveys/edit/follow_ups_include_variables: 2604dd580ceafec167ff9136d800f31e
environments/surveys/edit/follow_ups_item_ending_tag: 159c4e3bc953aae9a9dba27f7917228b
environments/surveys/edit/follow_ups_item_issue_detected_tag: bfb6b1f7b9f0a0a76bac853f01f72ba8
environments/surveys/edit/follow_ups_item_response_tag: 4b63073494e2224e1333624c6cee4240
environments/surveys/edit/follow_ups_item_send_email_tag: 0ef83c0bb40de25921a9ee7fa05babec
- environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: d23abb5a7e610b1ec3273c60d36a81e7
+ environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: 901a493d60331420da61d0e76bf07eae
environments/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
environments/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
environments/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88
@@ -1432,6 +1448,7 @@ checksums:
environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
+ environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
@@ -1579,6 +1596,7 @@ checksums:
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
+ environments/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
@@ -1911,9 +1929,9 @@ checksums:
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
- setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
+ setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
- setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
+ setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts
index fb2afe4cda..3e6f93497a 100644
--- a/apps/web/lib/constants.ts
+++ b/apps/web/lib/constants.ts
@@ -215,9 +215,9 @@ export const BILLING_LIMITS = {
},
} as const;
-export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
-export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
-export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
+export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
+export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
+export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts
index 03226736ac..0d1be9f08b 100644
--- a/apps/web/lib/env.ts
+++ b/apps/web/lib/env.ts
@@ -39,11 +39,12 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
- INTERCOM_SECRET_KEY: z.string().optional(),
- INTERCOM_APP_ID: z.string().optional(),
+ CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
+ CHATWOOT_BASE_URL: z.string().url().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
+ NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -162,15 +163,16 @@ export const env = createEnv({
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
- INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
+ CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
+ CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
+ NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
- INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
diff --git a/apps/web/lib/survey/service.test.ts b/apps/web/lib/survey/service.test.ts
index 6608dfd2a5..09955d4ba0 100644
--- a/apps/web/lib/survey/service.test.ts
+++ b/apps/web/lib/survey/service.test.ts
@@ -33,6 +33,7 @@ import {
handleTriggerUpdates,
loadNewSegmentInSurvey,
updateSurvey,
+ updateSurveyInternal,
} from "./service";
// Mock organization service
@@ -948,3 +949,74 @@ describe("Tests for getSurveysBySegmentId", () => {
});
});
});
+
+describe("updateSurveyDraftAction", () => {
+ beforeEach(() => {
+ vi.mocked(getActionClasses).mockResolvedValue([mockActionClass] as TActionClass[]);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganizationOutput);
+ });
+
+ describe("Happy Path", () => {
+ test("should save draft with missing translations", async () => {
+ prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
+ prisma.survey.update.mockResolvedValue(mockSurveyOutput);
+
+ // Create a survey with incomplete i18n/fields
+ const incompleteSurvey = {
+ ...updateSurveyInput,
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ // Missing headline or other required fields
+ },
+ ],
+ } as unknown as TSurvey;
+
+ // Expect success (skipValidation = true)
+ const result = await updateSurveyInternal(incompleteSurvey, true);
+ expect(result).toBeDefined();
+ expect(prisma.survey.update).toHaveBeenCalled();
+ });
+
+ test("should allow draft with invalid images if gating is applied", async () => {
+ prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
+ prisma.survey.update.mockResolvedValue(mockSurveyOutput);
+
+ const surveyWithInvalidImage = {
+ ...updateSurveyInput,
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question" },
+ imageUrl: "http://invalid-image-url.com/image.txt", // Invalid image extension
+ },
+ ],
+ } as unknown as TSurvey;
+
+ // Expect success (skipValidation = true)
+ await updateSurveyInternal(surveyWithInvalidImage, true);
+ expect(prisma.survey.update).toHaveBeenCalled();
+ });
+ });
+
+ describe("Sad Path", () => {
+ test("should reject publishing survey with incomplete translations", async () => {
+ // Create a draft with missing translations
+ const incompleteSurvey = {
+ ...updateSurveyInput,
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ // Missing headline
+ },
+ ],
+ } as unknown as TSurvey;
+
+ // Expect validation error (skipValidation = false)
+ await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
+ });
+ });
+});
diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts
index 9f45fb6857..1667e1088c 100644
--- a/apps/web/lib/survey/service.ts
+++ b/apps/web/lib/survey/service.ts
@@ -285,8 +285,13 @@ export const getSurveyCount = reactCache(async (environmentId: string): Promise<
}
});
-export const updateSurvey = async (updatedSurvey: TSurvey): Promise => {
- validateInputs([updatedSurvey, ZSurvey]);
+export const updateSurveyInternal = async (
+ updatedSurvey: TSurvey,
+ skipValidation = false
+): Promise => {
+ if (!skipValidation) {
+ validateInputs([updatedSurvey, ZSurvey]);
+ }
try {
const surveyId = updatedSurvey.id;
@@ -302,10 +307,12 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise =>
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
- checkForInvalidImagesInQuestions(questions);
+ if (!skipValidation) {
+ checkForInvalidImagesInQuestions(questions);
+ }
// Add blocks media validation
- if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
+ if (!skipValidation && updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
if (!blocksValidation.ok) {
throw new InvalidInputError(blocksValidation.error.message);
@@ -369,7 +376,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise =>
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
- if (!parsedFilters.success) {
+ if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
@@ -569,6 +576,15 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise =>
}
};
+export const updateSurvey = async (updatedSurvey: TSurvey): Promise => {
+ return updateSurveyInternal(updatedSurvey);
+};
+
+// Draft update without validation
+export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise => {
+ return updateSurveyInternal(updatedSurvey, true);
+};
+
export const createSurvey = async (
environmentId: string,
surveyBody: TSurveyCreateInput
diff --git a/apps/web/lib/utils/helper.ts b/apps/web/lib/utils/helper.ts
index 62f6ddebbd..bc4da7ece0 100644
--- a/apps/web/lib/utils/helper.ts
+++ b/apps/web/lib/utils/helper.ts
@@ -31,7 +31,8 @@ export const getFormattedErrorMessage = (result): string => {
if (key && fieldError?.toLowerCase().startsWith(key.toLowerCase())) {
return fieldError;
}
- return `${key ? `${key}: ` : ""}${fieldError}`;
+ const keyPrefix = key ? `${key}: ` : "";
+ return `${keyPrefix}${fieldError}`;
})
.join("\n");
}
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index febd3fd23a..c9abbac7a1 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -264,6 +264,7 @@
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
+ "members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
@@ -344,6 +345,7 @@
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen",
+ "remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
@@ -353,10 +355,10 @@
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
- "role_organization": "Rolle (Organisation)",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
+ "save_as_draft": "Als Entwurf speichern",
"save_changes": "Änderungen speichern",
"saving": "Speichern",
"search": "Suchen",
@@ -411,7 +413,8 @@
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
- "teams": "Zugriffskontrolle",
+ "team_role": "Team-Rolle",
+ "teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
+ "hidden_field": "Verstecktes Feld",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
+ "number_variable": "Zahlenvariable",
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
+ "response_data": "Antwortdaten",
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅",
"response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅",
"schedule_your_meeting": "Termin planen",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Benachrichtigungen für dieses Formular ausschalten",
"survey_response_finished_email_view_more_responses": "Zeige {responseCount} weitere Antworten",
"survey_response_finished_email_view_survey_summary": "Umfragezusammenfassung anzeigen",
+ "text_variable": "Textvariable",
"verification_email_click_on_this_link": "Du kannst auch auf diesen Link klicken:",
"verification_email_heading": "Fast geschafft!",
"verification_email_hey": "Hey 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Teams verwalten",
"no_teams_found": "Keine Teams gefunden",
- "only_organization_owners_and_managers_can_manage_teams": "Nur Organisationsinhaber und -manager können Teams verwalten.",
"permission": "Berechtigung",
"team_name": "Teamname",
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
@@ -1179,13 +1185,17 @@
"manage_team": "Team verwalten",
"manage_team_disabled": "Nur Organisationsbesitzer, Manager und Team-Admins können Teams verwalten.",
"manager_role_description": "Manager können auf alle Projekte zugreifen und Mitglieder hinzufügen und entfernen.",
+ "member": "Mitglied",
"member_role_description": "Mitglieder können in ausgewählten Projekten arbeiten.",
"member_role_info_message": "Um neuen Mitgliedern Zugriff auf ein Projekt zu geben, füge sie bitte unten einem Team hinzu. Mit Teams kannst du steuern, wer auf welches Projekt zugreifen kann.",
+ "organization_role": "Organisationsrolle",
"owner_role_description": "Besitzer haben die volle Kontrolle über die Organisation.",
"please_fill_all_member_fields": "Bitte fülle alle Felder aus, um ein neues Mitglied hinzuzufügen.",
"please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
+ "select_member": "Mitglied auswählen",
+ "select_project": "Projekt auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",
@@ -1273,6 +1283,10 @@
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
+ "bulk_edit": "Massenbearbeitung",
+ "bulk_edit_description": "Bearbeiten Sie alle Optionen unten, eine pro Zeile. Leere Zeilen werden übersprungen und Duplikate entfernt.",
+ "bulk_edit_options": "Optionen massenbearbeiten",
+ "bulk_edit_options_for": "Optionen massenbearbeiten für {language}",
"button_external": "Externen Link aktivieren",
"button_external_description": "Fügen Sie eine Schaltfläche hinzu, die eine externe URL in einem neuen Tab öffnet",
"button_label": "Beschriftung",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "Dieser Abschluss wird in Follow-ups verwendet. Wenn Sie ihn löschen, wird er aus allen Follow-ups entfernt. Sind Sie sicher, dass Sie ihn löschen möchten?",
"follow_ups_ending_card_delete_modal_title": "Abschlusskarte löschen?",
"follow_ups_hidden_field_error": "Verstecktes Feld wird in einem Follow-up verwendet. Bitte entfernen Sie es zuerst aus dem Follow-up.",
+ "follow_ups_include_hidden_fields": "Werte versteckter Felder einbeziehen",
+ "follow_ups_include_variables": "Variablenwerte einbeziehen",
"follow_ups_item_ending_tag": "Abschluss",
"follow_ups_item_issue_detected_tag": "Problem erkannt",
"follow_ups_item_response_tag": "Jede Antwort",
"follow_ups_item_send_email_tag": "E-Mail senden",
- "follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu",
+ "follow_ups_modal_action_attach_response_data_description": "Fügt nur die Fragen bei, die in der Umfrageantwort beantwortet wurden",
"follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen",
"follow_ups_modal_action_body_label": "Inhalt",
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Diese Option wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"optional": "Optional",
"options": "Optionen",
+ "options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.",
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
"until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben",
"untitled_block": "Unbenannter Block",
+ "update_options": "Optionen aktualisieren",
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
"upload": "Hochladen",
@@ -2053,7 +2071,7 @@
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der am schnellsten wachsenden Open-Source-Umfrageplattform weltweit basiert.",
"paragraph_2": "Führe gezielte Umfragen auf Websites, in Apps oder überall online durch. Sammle wertvolle Insights, um unwiderstehliche Erlebnisse für Kunden, Nutzer und Mitarbeiter zu gestalten.",
- "paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um volle Kontrolle über deine Daten zu behalten.",
+ "paragraph_3": "Wir verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die volle Kontrolle über Ihre Daten zu behalten.",
"welcome_to_formbricks": "Willkommen bei Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index a67740dcdc..8f68b38fb0 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -264,6 +264,7 @@
"maximum": "Maximum",
"member": "Member",
"members": "Members",
+ "members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -341,9 +342,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
- "read_docs": "Read Docs",
+ "read_docs": "Read docs",
"recipients": "Recipients",
"remove": "Remove",
+ "remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
@@ -353,10 +355,10 @@
"responses": "Responses",
"restart": "Restart",
"role": "Role",
- "role_organization": "Role (Organization)",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
+ "save_as_draft": "Save as draft",
"save_changes": "Save changes",
"saving": "Saving",
"search": "Search",
@@ -411,7 +413,8 @@
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
- "teams": "Access Control",
+ "team_role": "Team role",
+ "teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
+ "hidden_field": "Hidden field",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "You've got a new organization member!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"new_email_verification_text": "To verify your new email address, please click the button below:",
+ "number_variable": "Number variable",
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
+ "response_data": "Response data",
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
"response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
"schedule_your_meeting": "Schedule your meeting",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form",
"survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
"survey_response_finished_email_view_survey_summary": "View survey summary",
+ "text_variable": "Text variable",
"verification_email_click_on_this_link": "You can also click on this link:",
"verification_email_heading": "Almost there!",
"verification_email_hey": "Hey \uD83D\uDC4B",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Manage teams",
"no_teams_found": "No teams found",
- "only_organization_owners_and_managers_can_manage_teams": "Only organization owners and managers can manage teams.",
"permission": "Permission",
"team_name": "Team Name",
"team_settings_description": "See which teams can access this project."
@@ -1032,7 +1038,7 @@
"pretty_url": "Pretty URL",
"project": "Project",
"survey_name": "Survey Name",
- "title": "Domain"
+ "title": "Pretty URLs"
},
"enterprise": {
"audit_logs": "Audit Logs",
@@ -1179,13 +1185,17 @@
"manage_team": "Manage team",
"manage_team_disabled": "Only organization owners, managers and team admins can manage teams.",
"manager_role_description": "Managers can access all projects and add and remove members.",
+ "member": "Member",
"member_role_description": "Members can work in selected projects.",
"member_role_info_message": "To give new members access to a project, please add them to a Team below. With Teams you can manage who has access to which project.",
+ "organization_role": "Organization role",
"owner_role_description": "Owners have full control over the organization.",
"please_fill_all_member_fields": "Please fill all the fields to add a new member.",
"please_fill_all_project_fields": "Please fill all the fields to add a new project.",
"read": "Read",
"read_write": "Read & Write",
+ "select_member": "Select member",
+ "select_project": "Select project",
"team_admin": "Team Admin",
"team_created_successfully": "Team created successfully.",
"team_deleted_successfully": "Team deleted successfully.",
@@ -1273,6 +1283,10 @@
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
+ "bulk_edit": "Bulk edit",
+ "bulk_edit_description": "Edit all options below, one per line. Empty lines will be skipped and duplicates removed.",
+ "bulk_edit_options": "Bulk edit options",
+ "bulk_edit_options_for": "Bulk edit options for {language}",
"button_external": "Enable External Link",
"button_external_description": "Add a button that opens an external URL in a new tab",
"button_label": "Button Label",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "This ending card is used in follow-ups. Deleting it will remove it from all follow-ups. Are you sure you want to delete it?",
"follow_ups_ending_card_delete_modal_title": "Delete ending card?",
"follow_ups_hidden_field_error": "Hidden field is used in a follow-up. Please remove it from follow-up first.",
+ "follow_ups_include_hidden_fields": "Include hidden field values",
+ "follow_ups_include_variables": "Include variable values",
"follow_ups_item_ending_tag": "Ending(s)",
"follow_ups_item_issue_detected_tag": "Issue detected",
"follow_ups_item_response_tag": "Any response",
"follow_ups_item_send_email_tag": "Send email",
- "follow_ups_modal_action_attach_response_data_description": "Add the data of the survey response to the follow-up",
+ "follow_ups_modal_action_attach_response_data_description": "Attaches only the questions that were answered in the survey response",
"follow_ups_modal_action_attach_response_data_label": "Attach response data",
"follow_ups_modal_action_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "This option is used in logic of question {questionIndex}. Please remove it from logic first.",
"optional": "Optional",
"options": "Options",
+ "options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Set custom waiting time",
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
"until_they_submit_a_response": "Ask until they submit a response",
"untitled_block": "Untitled Block",
+ "update_options": "Update options",
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
"upload": "Upload",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Get started",
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
- "paragraph_1": "Formbricks is an Experience Management Suite built of the fastest growing open source survey platform worldwide.",
+ "paragraph_1": "Formbricks is an Experience Management Suite built on the fastest growing open-source survey platform worldwide.",
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to craft irresistible experiences for customers, users and employees.",
- "paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep full control over your data .",
+ "paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep full control over your data .",
"welcome_to_formbricks": "Welcome to Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index afda965ef1..bbde1bd7d8 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -264,6 +264,7 @@
"maximum": "Máximo",
"member": "Miembro",
"members": "Miembros",
+ "members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos",
"minimum": "Mínimo",
@@ -344,6 +345,7 @@
"read_docs": "Leer documentación",
"recipients": "Destinatarios",
"remove": "Eliminar",
+ "remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
"report_survey": "Reportar encuesta",
"request_pricing": "Solicitar precios",
@@ -353,10 +355,10 @@
"responses": "Respuestas",
"restart": "Reiniciar",
"role": "Rol",
- "role_organization": "Rol (organización)",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
+ "save_as_draft": "Guardar como borrador",
"save_changes": "Guardar cambios",
"saving": "Guardando",
"search": "Buscar",
@@ -411,7 +413,8 @@
"team_access": "Acceso de equipo",
"team_id": "ID de equipo",
"team_name": "Nombre del equipo",
- "teams": "Control de acceso",
+ "team_role": "Rol del equipo",
+ "teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
+ "hidden_field": "Campo oculto",
"imprint": "Aviso legal",
"invite_accepted_email_heading": "Hola",
"invite_accepted_email_subject": "¡Tienes un nuevo miembro en la organización!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "te ha invitado a unirte a Formbricks. Para aceptar la invitación, por favor haz clic en el enlace a continuación:",
"invite_member_email_subject": "¡Estás invitado a colaborar en Formbricks!",
"new_email_verification_text": "Para verificar tu nueva dirección de correo electrónico, por favor haz clic en el botón a continuación:",
+ "number_variable": "Variable numérica",
"password_changed_email_heading": "Contraseña cambiada",
"password_changed_email_text": "Tu contraseña se ha cambiado correctamente.",
"password_reset_notify_email_subject": "Tu contraseña de Formbricks ha sido cambiada",
"privacy_policy": "Política de privacidad",
"reject": "Rechazar",
"render_email_response_value_file_upload_response_link_not_included": "El enlace al archivo subido no está incluido por razones de privacidad de datos",
+ "response_data": "Datos de respuesta",
"response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅",
"response_finished_email_subject_with_email": "{personEmail} acaba de completar tu encuesta {surveyName} ✅",
"schedule_your_meeting": "Programa tu reunión",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desactivar notificaciones para este formulario",
"survey_response_finished_email_view_more_responses": "Ver {responseCount} respuestas más",
"survey_response_finished_email_view_survey_summary": "Ver resumen de la encuesta",
+ "text_variable": "Variable de texto",
"verification_email_click_on_this_link": "También puedes hacer clic en este enlace:",
"verification_email_heading": "¡Ya casi está!",
"verification_email_hey": "Hola 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Gestionar equipos",
"no_teams_found": "No se han encontrado equipos",
- "only_organization_owners_and_managers_can_manage_teams": "Solo los propietarios y gestores de la organización pueden gestionar equipos.",
"permission": "Permiso",
"team_name": "Nombre del equipo",
"team_settings_description": "Consulta qué equipos pueden acceder a este proyecto."
@@ -1179,13 +1185,17 @@
"manage_team": "Gestionar equipo",
"manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
+ "member": "Miembro",
"member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.",
"member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.",
+ "organization_role": "Rol en la organización",
"owner_role_description": "Los propietarios tienen control total sobre la organización.",
"please_fill_all_member_fields": "Por favor, rellena todos los campos para añadir un nuevo miembro.",
"please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.",
"read": "Lectura",
"read_write": "Lectura y escritura",
+ "select_member": "Seleccionar miembro",
+ "select_project": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",
@@ -1273,6 +1283,10 @@
"bold": "Negrita",
"brand_color": "Color de marca",
"brightness": "Brillo",
+ "bulk_edit": "Edición masiva",
+ "bulk_edit_description": "Edita todas las opciones a continuación, una por línea. Las líneas vacías se omitirán y los duplicados se eliminarán.",
+ "bulk_edit_options": "Edición masiva de opciones",
+ "bulk_edit_options_for": "Edición masiva de opciones para {language}",
"button_external": "Habilitar enlace externo",
"button_external_description": "Añadir un botón que abre una URL externa en una nueva pestaña",
"button_label": "Etiqueta del botón",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "Esta tarjeta de finalización se utiliza en seguimientos. Al eliminarla se quitará de todos los seguimientos. ¿Estás seguro de que quieres eliminarla?",
"follow_ups_ending_card_delete_modal_title": "¿Eliminar tarjeta de finalización?",
"follow_ups_hidden_field_error": "El campo oculto se utiliza en un seguimiento. Por favor, elimínalo primero del seguimiento.",
+ "follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
+ "follow_ups_include_variables": "Incluir valores de variables",
"follow_ups_item_ending_tag": "Finalización(es)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Cualquier respuesta",
"follow_ups_item_send_email_tag": "Enviar correo electrónico",
- "follow_ups_modal_action_attach_response_data_description": "Añadir los datos de la respuesta de la encuesta al seguimiento",
+ "follow_ups_modal_action_attach_response_data_description": "Adjunta solo las preguntas que fueron respondidas en la respuesta de la encuesta",
"follow_ups_modal_action_attach_response_data_label": "Adjuntar datos de respuesta",
"follow_ups_modal_action_body_label": "Cuerpo",
"follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Esta opción se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"optional": "Opcional",
"options": "Opciones",
+ "options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.",
"override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.",
"overwrite_global_waiting_time": "Establecer tiempo de espera personalizado",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Tienes cambios sin guardar en tu encuesta. ¿Quieres guardarlos antes de salir?",
"until_they_submit_a_response": "Preguntar hasta que envíen una respuesta",
"untitled_block": "Bloque sin título",
+ "update_options": "Actualizar opciones",
"upgrade_notice_description": "Crea encuestas multilingües y desbloquea muchas más funciones",
"upgrade_notice_title": "Desbloquea encuestas multilingües con un plan superior",
"upload": "Subir",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Comenzar",
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
- "paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la plataforma de encuestas de código abierto de más rápido crecimiento en todo el mundo.",
+ "paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la plataforma de encuestas de código abierto de más rápido crecimiento a nivel mundial.",
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para crear experiencias irresistibles para clientes, usuarios y empleados.",
- "paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener control total sobre tus datos .",
+ "paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el control total sobre tus datos .",
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index 754776661e..d6af59632b 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -264,6 +264,7 @@
"maximum": "Max",
"member": "Membre",
"members": "Membres",
+ "members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
@@ -341,9 +342,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
- "read_docs": "Lire les documents",
+ "read_docs": "Lire la documentation",
"recipients": "Destinataires",
"remove": "Retirer",
+ "remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif",
@@ -353,10 +355,10 @@
"responses": "Réponses",
"restart": "Recommencer",
"role": "Rôle",
- "role_organization": "Rôle (Organisation)",
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
+ "save_as_draft": "Enregistrer comme brouillon",
"save_changes": "Enregistrer les modifications",
"saving": "Sauvegarder",
"search": "Recherche",
@@ -411,7 +413,8 @@
"team_access": "Accès",
"team_id": "Identifiant de l'équipe",
"team_name": "Nom de l'équipe",
- "teams": "Contrôle d'accès",
+ "team_role": "Rôle dans l'équipe",
+ "teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
+ "hidden_field": "Champ caché",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
+ "number_variable": "Variable numérique",
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
+ "response_data": "Données de réponse",
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
"response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
"schedule_your_meeting": "Planifier votre rendez-vous",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Désactiver les notifications pour ce formulaire",
"survey_response_finished_email_view_more_responses": "Voir {responseCount} réponses supplémentaires",
"survey_response_finished_email_view_survey_summary": "Voir le résumé de l'enquête",
+ "text_variable": "Variable texte",
"verification_email_click_on_this_link": "Vous pouvez également cliquer sur ce lien :",
"verification_email_heading": "Presque là !",
"verification_email_hey": "Salut 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Gérer les équipes",
"no_teams_found": "Aucune équipe trouvée",
- "only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.",
"permission": "Permission",
"team_name": "Nom de l'équipe",
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
@@ -1179,13 +1185,17 @@
"manage_team": "Gérer l'équipe",
"manage_team_disabled": "Seuls les propriétaires de l'organisation, les gestionnaires et les administrateurs d'équipe peuvent gérer les équipes.",
"manager_role_description": "Les gestionnaires peuvent accéder à tous les projets et ajouter et supprimer des membres.",
+ "member": "Membre",
"member_role_description": "Les membres peuvent travailler sur des projets sélectionnés.",
"member_role_info_message": "Pour donner accès à un projet aux nouveaux membres, veuillez les ajouter à une équipe ci-dessous. Avec les équipes, vous pouvez gérer qui a accès à quel projet.",
+ "organization_role": "Rôle dans l'organisation",
"owner_role_description": "Les propriétaires ont un contrôle total sur l'organisation.",
"please_fill_all_member_fields": "Veuillez remplir tous les champs pour ajouter un nouveau membre.",
"please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.",
"read": "Lire",
"read_write": "Lire et Écrire",
+ "select_member": "Sélectionner membre",
+ "select_project": "Sélectionner projet",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",
@@ -1273,6 +1283,10 @@
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
+ "bulk_edit": "Modification en masse",
+ "bulk_edit_description": "Modifiez toutes les options ci-dessous, une par ligne. Les lignes vides seront ignorées et les doublons supprimés.",
+ "bulk_edit_options": "Modifier les options en masse",
+ "bulk_edit_options_for": "Modifier les options en masse pour {language}",
"button_external": "Activer le lien externe",
"button_external_description": "Ajouter un bouton qui ouvre une URL externe dans un nouvel onglet",
"button_label": "Label du bouton",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "Cette carte de fin est utilisée dans les suivis. La supprimer la retirera de tous les suivis. Êtes-vous sûr de vouloir la supprimer ?",
"follow_ups_ending_card_delete_modal_title": "Supprimer la carte de fin ?",
"follow_ups_hidden_field_error": "Le champ caché est utilisé dans un suivi. Veuillez d'abord le supprimer du suivi.",
+ "follow_ups_include_hidden_fields": "Inclure les valeurs des champs cachés",
+ "follow_ups_include_variables": "Inclure les valeurs des variables",
"follow_ups_item_ending_tag": "Fin(s)",
"follow_ups_item_issue_detected_tag": "Problème détecté",
"follow_ups_item_response_tag": "Une réponse quelconque",
"follow_ups_item_send_email_tag": "Envoyer un e-mail",
- "follow_ups_modal_action_attach_response_data_description": "Ajouter les données de la réponse à l'enquête au suivi",
+ "follow_ups_modal_action_attach_response_data_description": "Joint uniquement les questions auxquelles on a répondu dans la réponse au sondage",
"follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse",
"follow_ups_modal_action_body_label": "Corps",
"follow_ups_modal_action_body_placeholder": "Corps de l'email",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Cette option est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"optional": "Optionnel",
"options": "Options",
+ "options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique : {questionIndexes}. Veuillez d'abord les supprimer de la logique.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
"until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse",
"untitled_block": "Bloc sans titre",
+ "update_options": "Mettre à jour les options",
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
"upload": "Télécharger",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Commencer",
"made_with_love_in_kiel": "Fabriqué avec 🤍 en Allemagne",
- "paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la plateforme d'enquête open source à la croissance la plus rapide au monde.",
+ "paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la plateforme de sondage open-source à la croissance la plus rapide au monde.",
"paragraph_2": "Réalisez des enquêtes ciblées sur des sites web, dans des applications ou partout en ligne. Collectez des informations précieuses pour créer des expériences irrésistibles pour les clients, les utilisateurs et les employés.",
- "paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder le contrôle total sur vos données . Toujours.",
+ "paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder le contrôle total de vos données .",
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
},
"invite": {
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index 58de5ca4ad..93f9ccada5 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -264,6 +264,7 @@
"maximum": "最大",
"member": "メンバー",
"members": "メンバー",
+ "members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
@@ -344,6 +345,7 @@
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"remove": "削除",
+ "remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
"report_survey": "フォームを報告",
"request_pricing": "料金を問い合わせる",
@@ -353,10 +355,10 @@
"responses": "回答",
"restart": "再開",
"role": "役割",
- "role_organization": "役割(組織)",
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
+ "save_as_draft": "下書きとして保存",
"save_changes": "変更を保存",
"saving": "保存中",
"search": "検索",
@@ -411,7 +413,8 @@
"team_access": "チームアクセス",
"team_id": "チームID",
"team_name": "チーム名",
- "teams": "アクセス制御",
+ "team_role": "チームの役割",
+ "teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
+ "hidden_field": "非表示フィールド",
"imprint": "企業情報",
"invite_accepted_email_heading": "こんにちは",
"invite_accepted_email_subject": "新しい組織メンバーが加わりました!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "が、Formbricksへの参加をあなたに招待しました。招待を承認するには、以下のリンクをクリックしてください。",
"invite_member_email_subject": "Formbricksでのコラボレーションに招待されました!",
"new_email_verification_text": "新しいメールアドレスを認証するには、以下のボタンをクリックしてください。",
+ "number_variable": "数値変数",
"password_changed_email_heading": "パスワードが変更されました",
"password_changed_email_text": "パスワードが正常に変更されました。",
"password_reset_notify_email_subject": "Formbricksのパスワードが変更されました",
"privacy_policy": "プライバシーポリシー",
"reject": "拒否",
"render_email_response_value_file_upload_response_link_not_included": "データプライバシーのため、アップロードされたファイルへのリンクは含まれていません",
+ "response_data": "回答データ",
"response_finished_email_subject": "{surveyName} の回答が完了しました ✅",
"response_finished_email_subject_with_email": "{personEmail} が {surveyName} フォームを完了しました ✅",
"schedule_your_meeting": "ミーティングを予約",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "このフォームの通知をオフにする",
"survey_response_finished_email_view_more_responses": "さらに {responseCount} 件の回答を見る",
"survey_response_finished_email_view_survey_summary": "フォームの概要を見る",
+ "text_variable": "テキスト変数",
"verification_email_click_on_this_link": "このリンクをクリックすることもできます:",
"verification_email_heading": "あと少しです!",
"verification_email_hey": "こんにちは 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "チームを管理",
"no_teams_found": "チームが見つかりません",
- "only_organization_owners_and_managers_can_manage_teams": "組織のオーナーまたは管理者のみがチームを管理できます。",
"permission": "権限",
"team_name": "チーム名",
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
@@ -1179,13 +1185,17 @@
"manage_team": "チームを管理",
"manage_team_disabled": "組織のオーナー、管理者、チーム管理者のみがチームを管理できます。",
"manager_role_description": "管理者はすべてのプロジェクトにアクセスでき、メンバーを追加および削除できます。",
+ "member": "メンバー",
"member_role_description": "メンバーは選択されたプロジェクトで作業できます。",
"member_role_info_message": "新しいメンバーにプロジェクトへのアクセス権を付与するには、以下のチームに追加してください。チームを使用すると、誰がどのプロジェクトにアクセスできるかを管理できます。",
+ "organization_role": "組織の役割",
"owner_role_description": "オーナーは組織を完全に制御できます。",
"please_fill_all_member_fields": "新しいメンバーを追加するには、すべてのフィールドを記入してください。",
"please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。",
"read": "読み取り",
"read_write": "読み書き",
+ "select_member": "メンバーを選択",
+ "select_project": "プロジェクトを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",
@@ -1273,6 +1283,10 @@
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
+ "bulk_edit": "一括編集",
+ "bulk_edit_description": "以下のオプションを1行ずつ編集してください。空の行はスキップされ、重複は削除されます。",
+ "bulk_edit_options": "オプションの一括編集",
+ "bulk_edit_options_for": "{language}のオプションを一括編集",
"button_external": "外部リンクを有効にする",
"button_external_description": "新しいタブで外部URLを開くボタンを追加する",
"button_label": "ボタンのラベル",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "この終了カードはフォローアップで使用されています。これを削除すると、すべてのフォローアップから削除されます。本当に削除しますか?",
"follow_ups_ending_card_delete_modal_title": "終了カードを削除しますか?",
"follow_ups_hidden_field_error": "非表示フィールドはフォローアップで使用されています。まず、フォローアップから削除してください。",
+ "follow_ups_include_hidden_fields": "非表示フィールドの値を含める",
+ "follow_ups_include_variables": "変数の値を含める",
"follow_ups_item_ending_tag": "終了",
"follow_ups_item_issue_detected_tag": "問題が検出されました",
"follow_ups_item_response_tag": "任意の回答",
"follow_ups_item_send_email_tag": "メールを送信",
- "follow_ups_modal_action_attach_response_data_description": "フォームの回答データをフォローアップに追加する",
+ "follow_ups_modal_action_attach_response_data_description": "アンケート回答で答えられた質問のみを添付します",
"follow_ups_modal_action_attach_response_data_label": "回答データを添付",
"follow_ups_modal_action_body_label": "本文",
"follow_ups_modal_action_body_placeholder": "メールの本文",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "このオプションは質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"optional": "オプション",
"options": "オプション",
+ "options_used_in_logic_bulk_error": "以下のオプションはロジックで使用されています:{questionIndexes}。まず、ロジックから削除してください。",
"override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。",
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
"until_they_submit_a_response": "回答が提出されるまで質問する",
"untitled_block": "無題のブロック",
+ "update_options": "オプションを更新",
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
"upload": "アップロード",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "始める",
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
- "paragraph_1": "Formbricksは、世界で最も急速に成長しているオープンソースのフォームプラットフォーム から構築されたエクスペリエンス管理スイートです。",
+ "paragraph_1": "Formbricksは、世界で最も急成長しているオープンソースのアンケートプラットフォーム をベースに構築されたエクスペリエンス管理スイートです。",
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの魅力的な体験 を作り出します。",
- "paragraph_3": "私たちは、最高のデータプライバシーを約束します。セルフホストして、データを完全に制御 できます。",
+ "paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、データを完全に管理 できます。",
"welcome_to_formbricks": "Formbricksへようこそ!"
},
"invite": {
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index d6cbce9d50..96d22aede5 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -264,6 +264,7 @@
"maximum": "Maximaal",
"member": "Lid",
"members": "Leden",
+ "members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens",
"minimum": "Minimum",
@@ -341,9 +342,10 @@
"quota": "Quotum",
"quotas": "Quota",
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
- "read_docs": "Lees Documenten",
+ "read_docs": "Documentatie lezen",
"recipients": "Ontvangers",
"remove": "Verwijderen",
+ "remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"report_survey": "Verslag enquête",
"request_pricing": "Vraag prijzen aan",
@@ -353,10 +355,10 @@
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"role": "Rol",
- "role_organization": "Rol (organisatie)",
"saas": "SaaS",
"sales": "Verkoop",
"save": "Redden",
+ "save_as_draft": "Opslaan als concept",
"save_changes": "Wijzigingen opslaan",
"saving": "Besparing",
"search": "Zoekopdracht",
@@ -411,7 +413,8 @@
"team_access": "Teamtoegang",
"team_id": "Team-ID",
"team_name": "Teamnaam",
- "teams": "Toegangscontrole",
+ "team_role": "Teamrol",
+ "teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
+ "hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hoi",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "nodigde je uit om je bij Formbricks aan te sluiten. Om de uitnodiging te accepteren, klikt u op de onderstaande link:",
"invite_member_email_subject": "Je bent uitgenodigd om samen te werken aan Formbricks!",
"new_email_verification_text": "Om uw nieuwe e-mailadres te verifiëren, klikt u op de onderstaande knop:",
+ "number_variable": "Numerieke variabele",
"password_changed_email_heading": "Wachtwoord gewijzigd",
"password_changed_email_text": "Uw wachtwoord is succesvol gewijzigd.",
"password_reset_notify_email_subject": "Uw Formbricks-wachtwoord is gewijzigd",
"privacy_policy": "Privacybeleid",
"reject": "Afwijzen",
"render_email_response_value_file_upload_response_link_not_included": "De link naar het geüploade bestand is om redenen van gegevensprivacy niet opgenomen",
+ "response_data": "Responsgegevens",
"response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅",
"response_finished_email_subject_with_email": "{personEmail} heeft zojuist uw {surveyName} enquête voltooid ✅",
"schedule_your_meeting": "Plan uw vergadering",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Schakel meldingen voor dit formulier uit",
"survey_response_finished_email_view_more_responses": "Bekijk nog {responseCount} reacties",
"survey_response_finished_email_view_survey_summary": "Bekijk de samenvatting van het onderzoek",
+ "text_variable": "Tekstvariabele",
"verification_email_click_on_this_link": "U kunt ook op deze link klikken:",
"verification_email_heading": "Bijna daar!",
"verification_email_hey": "Hé 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Beheer teams",
"no_teams_found": "Geen teams gevonden",
- "only_organization_owners_and_managers_can_manage_teams": "Alleen eigenaren en managers van organisaties kunnen teams beheren.",
"permission": "Toestemming",
"team_name": "Teamnaam",
"team_settings_description": "Bekijk welke teams toegang hebben tot dit project."
@@ -1179,13 +1185,17 @@
"manage_team": "Beheer team",
"manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
+ "member": "Lid",
"member_role_description": "Leden kunnen in geselecteerde projecten werken.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.",
+ "organization_role": "Organisatierol",
"owner_role_description": "Eigenaars hebben volledige controle over de organisatie.",
"please_fill_all_member_fields": "Vul alle velden in om een nieuw lid toe te voegen.",
"please_fill_all_project_fields": "Vul alle velden in om een nieuw project toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
+ "select_member": "Selecteer lid",
+ "select_project": "Selecteer project",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",
@@ -1273,6 +1283,10 @@
"bold": "Vetgedrukt",
"brand_color": "Merk kleur",
"brightness": "Helderheid",
+ "bulk_edit": "Bulkbewerking",
+ "bulk_edit_description": "Bewerk alle opties hieronder, één per regel. Lege regels worden overgeslagen en duplicaten verwijderd.",
+ "bulk_edit_options": "Opties bulkbewerken",
+ "bulk_edit_options_for": "Opties bulkbewerken voor {language}",
"button_external": "Externe link inschakelen",
"button_external_description": "Voeg een knop toe die een externe URL in een nieuw tabblad opent",
"button_label": "Knoplabel",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "Deze eindkaart wordt gebruikt bij vervolgacties. Als u het verwijdert, wordt het uit alle vervolgacties verwijderd. Weet je zeker dat je het wilt verwijderen?",
"follow_ups_ending_card_delete_modal_title": "Eindkaart verwijderen?",
"follow_ups_hidden_field_error": "Verborgen veld wordt gebruikt in een follow-up. Verwijder het eerst uit de follow-up.",
+ "follow_ups_include_hidden_fields": "Inclusief waarden van verborgen velden",
+ "follow_ups_include_variables": "Inclusief variabele waarden",
"follow_ups_item_ending_tag": "Einde(n)",
"follow_ups_item_issue_detected_tag": "Probleem gedetecteerd",
"follow_ups_item_response_tag": "Enige reactie",
"follow_ups_item_send_email_tag": "E-mail verzenden",
- "follow_ups_modal_action_attach_response_data_description": "Voeg de gegevens van de enquêtereactie toe aan de follow-up",
+ "follow_ups_modal_action_attach_response_data_description": "Voegt alleen de vragen toe die zijn beantwoord in de enquêterespons",
"follow_ups_modal_action_attach_response_data_label": "Reactiegegevens bijvoegen",
"follow_ups_modal_action_body_label": "Lichaam",
"follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Deze optie wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"optional": "Optioneel",
"options": "Opties",
+ "options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.",
"override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.",
"overwrite_global_waiting_time": "Stel aangepaste wachttijd in",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Er zijn niet-opgeslagen wijzigingen in uw enquête. Wilt u ze bewaren voordat u vertrekt?",
"until_they_submit_a_response": "Vraag totdat ze een reactie indienen",
"untitled_block": "Naamloos blok",
+ "update_options": "Opties bijwerken",
"upgrade_notice_description": "Creëer meertalige enquêtes en ontgrendel nog veel meer functies",
"upgrade_notice_title": "Ontgrendel meertalige enquêtes met een hoger plan",
"upload": "Uploaden",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Ga aan de slag",
"made_with_love_in_kiel": "Gemaakt met 🤍 in Duitsland",
- "paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het snelst groeiende open source enquêteplatform wereldwijd.",
+ "paragraph_1": "Formbricks is een Experience Management Suite gebouwd op het snelst groeiende open-source enquêteplatform wereldwijd.",
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om onweerstaanbare ervaringen te creëren voor klanten, gebruikers en medewerkers.",
- "paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om volledige controle over uw gegevens te behouden.",
+ "paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om volledige controle over je gegevens te behouden.",
"welcome_to_formbricks": "Welkom bij Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index e24772ed32..55a6facff5 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -264,6 +264,7 @@
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
+ "members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
@@ -341,9 +342,10 @@
"quota": "Cota",
"quotas": "Cotas",
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
- "read_docs": "Ler Documentação",
+ "read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "remover",
+ "remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
@@ -353,10 +355,10 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Rolê",
- "role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "vendas",
"save": "Salvar",
+ "save_as_draft": "Salvar como rascunho",
"save_changes": "Salvar alterações",
"saving": "Salvando",
"search": "Buscar",
@@ -411,7 +413,8 @@
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
- "teams": "Controle de Acesso",
+ "team_role": "Função na equipe",
+ "teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
+ "hidden_field": "Campo oculto",
"imprint": "Impressum",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
+ "number_variable": "Variável numérica",
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
+ "response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
"schedule_your_meeting": "Agendar sua reunião",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo da pesquisa",
+ "text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Você também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Oi 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Gerenciar Equipes",
"no_teams_found": "Nenhuma equipe encontrada",
- "only_organization_owners_and_managers_can_manage_teams": "Apenas proprietários e gerentes da organização podem gerenciar equipes.",
"permission": "Permissão",
"team_name": "Nome da equipe",
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
@@ -1179,13 +1185,17 @@
"manage_team": "Gerenciar equipe",
"manage_team_disabled": "Apenas proprietários da organização, gerentes e administradores da equipe podem gerenciar equipes.",
"manager_role_description": "Os gerentes podem acessar todos os projetos e adicionar e remover membros.",
+ "member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Para dar acesso a novos membros a um projeto, por favor, adicione-os a uma equipe abaixo. Com equipes, você pode gerenciar quem tem acesso a qual projeto.",
+ "organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controle total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
+ "select_member": "Selecionar membro",
+ "select_project": "Selecionar projeto",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",
@@ -1273,6 +1283,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
+ "bulk_edit": "Edição em massa",
+ "bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicatas removidas.",
+ "bulk_edit_options": "Editar opções em massa",
+ "bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Habilitar link externo",
"button_external_description": "Adicionar um botão que abre uma URL externa em uma nova aba",
"button_label": "Rótulo do Botão",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "Este final é usado em acompanhamentos. Excluí-lo o removerá de todos os acompanhamentos. Tem certeza de que deseja excluí-lo?",
"follow_ups_ending_card_delete_modal_title": "Excluir cartão de final?",
"follow_ups_hidden_field_error": "O campo oculto está sendo usado em um acompanhamento. Por favor, remova-o do acompanhamento primeiro.",
+ "follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
+ "follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Final(is)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar e-mail",
- "follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento",
+ "follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta da pesquisa",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
+ "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
"until_they_submit_a_response": "Perguntar até que enviem uma resposta",
"untitled_block": "Bloco sem título",
+ "update_options": "Atualizar opções",
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
"upload": "Enviar",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
- "paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na plataforma de pesquisa open source que mais cresce no mundo.",
+ "paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída sobre a plataforma de pesquisa de código aberto de crescimento mais rápido do mundo.",
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
- "paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter controle total sobre seus dados . Sempre",
+ "paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Hospede você mesmo para manter controle total sobre seus dados .",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 625d65318d..7e68688b63 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -264,6 +264,7 @@
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
+ "members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
@@ -341,9 +342,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
- "read_docs": "Ler Documentos",
+ "read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "Remover",
+ "remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
@@ -353,10 +355,10 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Função",
- "role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "Vendas",
"save": "Guardar",
+ "save_as_draft": "Guardar como rascunho",
"save_changes": "Guardar alterações",
"saving": "Guardando",
"search": "Procurar",
@@ -411,7 +413,8 @@
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
- "teams": "Controlo de Acesso",
+ "team_role": "Função na equipa",
+ "teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
+ "hidden_field": "Campo oculto",
"imprint": "Impressão",
"invite_accepted_email_heading": "Olá",
"invite_accepted_email_subject": "Tem um novo membro na organização!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
+ "number_variable": "Variável numérica",
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
+ "response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅",
"schedule_your_meeting": "Agende a sua reunião",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo do inquérito",
+ "text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Olá 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Gerir equipas",
"no_teams_found": "Nenhuma equipa encontrada",
- "only_organization_owners_and_managers_can_manage_teams": "Apenas os proprietários e gestores da organização podem gerir equipas.",
"permission": "Permissão",
"team_name": "Nome da Equipa",
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
@@ -1179,13 +1185,17 @@
"manage_team": "Gerir equipa",
"manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.",
"manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.",
+ "member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Adicione os membros que deseja a uma Equipa abaixo. Nesta secção, pode gerir quem tem acesso a cada projeto.",
+ "organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controlo total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Ler",
"read_write": "Ler e Escrever",
+ "select_member": "Selecionar membro",
+ "select_project": "Selecionar projeto",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",
@@ -1273,6 +1283,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
+ "bulk_edit": "Edição em massa",
+ "bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicados removidos.",
+ "bulk_edit_options": "Editar opções em massa",
+ "bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Ativar link externo",
"button_external_description": "Adicionar um botão que abre um URL externo num novo separador",
"button_label": "Rótulo do botão",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "Este cartão de encerramento é utilizado em seguimentos. Eliminá-lo irá removê-lo de todos os seguimentos. Tem a certeza de que deseja eliminá-lo?",
"follow_ups_ending_card_delete_modal_title": "Eliminar cartão de encerramento?",
"follow_ups_hidden_field_error": "O campo oculto é usado num seguimento. Por favor, remova-o do seguimento primeiro.",
+ "follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
+ "follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Encerramento(s)",
"follow_ups_item_issue_detected_tag": "Problema detetado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar email",
- "follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta do inquérito ao acompanhamento",
+ "follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta ao inquérito",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do email",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
+ "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
"until_they_submit_a_response": "Perguntar até que submetam uma resposta",
"untitled_block": "Bloco sem título",
+ "update_options": "Atualizar opções",
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
"upload": "Carregar",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 na Alemanha",
- "paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na plataforma de inquéritos de código aberto de crescimento mais rápido do mundo.",
+ "paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na plataforma de inquéritos open-source de crescimento mais rápido a nível mundial.",
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para criar experiências irresistíveis para clientes, utilizadores e funcionários.",
- "paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter controlo total sobre os seus dados .",
+ "paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter controlo total sobre os seus dados .",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index b0c9eedeb1..364d99717c 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -264,6 +264,7 @@
"maximum": "Maximum",
"member": "Membru",
"members": "Membri",
+ "members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
@@ -344,6 +345,7 @@
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"remove": "Șterge",
+ "remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
@@ -353,10 +355,10 @@
"responses": "Răspunsuri",
"restart": "Repornește",
"role": "Rolul",
- "role_organization": "Rol (Organizație)",
"saas": "SaaS",
"sales": "Vânzări",
"save": "Salvează",
+ "save_as_draft": "Salvați ca schiță",
"save_changes": "Salvează modificările",
"saving": "Salvare",
"search": "Căutare",
@@ -411,7 +413,8 @@
"team_access": "Acces echipă",
"team_id": "ID echipă",
"team_name": "Nume echipă",
- "teams": "Control acces",
+ "team_role": "Rol în echipă",
+ "teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
+ "hidden_field": "Câmp ascuns",
"imprint": "Amprentă",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Ai un nou membru în organizație!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!",
"new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:",
+ "number_variable": "Variabilă numerică",
"password_changed_email_heading": "Parola modificată",
"password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.",
"password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată",
"privacy_policy": "Politica de confidențialitate",
"reject": "Respinge",
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
+ "response_data": "Datele răspunsului",
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
"response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅",
"schedule_your_meeting": "Programați întâlnirea",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Dezactivează notificările pentru acest formular",
"survey_response_finished_email_view_more_responses": "Vizualizați {responseCount} mai multe răspunsuri",
"survey_response_finished_email_view_survey_summary": "Vizualizați sumarul sondajului",
+ "text_variable": "Variabilă text",
"verification_email_click_on_this_link": "De asemenea, puteți face clic pe acest link:",
"verification_email_heading": "Aproape gata!",
"verification_email_hey": "Salut 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Gestionați echipele",
"no_teams_found": "Nicio echipă găsită",
- "only_organization_owners_and_managers_can_manage_teams": "Doar proprietarii de organizație și managerii pot gestiona echipele.",
"permission": "Permisiune",
"team_name": "Nume echipă",
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
@@ -1179,13 +1185,17 @@
"manage_team": "Gestionați echipa",
"manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.",
"manager_role_description": "Managerii pot accesa toate proiectele și pot adăuga sau elimina membri.",
+ "member": "Membru",
"member_role_description": "Membrii pot lucra în proiectele selectate.",
"member_role_info_message": "Pentru a oferi membrilor noi acces la un proiect, vă rugăm să-i adăugați la o Echipă mai jos. Cu Echipe puteți gestiona cine are acces la ce proiect.",
+ "organization_role": "Rol în organizație",
"owner_role_description": "Proprietarii au control total asupra organizației.",
"please_fill_all_member_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou membru.",
"please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.",
"read": "Citește",
"read_write": "Citire & Scriere",
+ "select_member": "Selectează membrul",
+ "select_project": "Selectează proiectul",
"team_admin": "Administrator Echipe",
"team_created_successfully": "Echipă creată cu succes",
"team_deleted_successfully": "Echipă ștearsă cu succes.",
@@ -1273,6 +1283,10 @@
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
+ "bulk_edit": "Editare în bloc",
+ "bulk_edit_description": "Editați toate opțiunile de mai jos, câte una pe linie. Liniile goale vor fi omise, iar duplicatele vor fi eliminate.",
+ "bulk_edit_options": "Opțiuni de editare în bloc",
+ "bulk_edit_options_for": "Editare în bloc a opțiunilor pentru {language}",
"button_external": "Activează link extern",
"button_external_description": "Adaugă un buton care deschide un URL extern într-o filă nouă",
"button_label": "Etichetă buton",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în follow-up-uri ulterioare. Ștergerea sa o va elimina din toate follow-up-uri ulterioare. Ești sigur că vrei să o ștergi?",
"follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?",
"follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.",
+ "follow_ups_include_hidden_fields": "Include valorile câmpurilor ascunse",
+ "follow_ups_include_variables": "Include valorile variabilelor",
"follow_ups_item_ending_tag": "Finalizare",
"follow_ups_item_issue_detected_tag": "Problemă detectată",
"follow_ups_item_response_tag": "Orice răspuns",
"follow_ups_item_send_email_tag": "Trimite email",
- "follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la follow-up",
+ "follow_ups_modal_action_attach_response_data_description": "Atașează doar întrebările la care s-a răspuns în răspunsul sondajului",
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
"follow_ups_modal_action_body_label": "Corp",
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Această opțiune este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"optional": "Opțional",
"options": "Opțiuni",
+ "options_used_in_logic_bulk_error": "Următoarele opțiuni sunt folosite în logică: {questionIndexes}. Vă rugăm să le eliminați din logică mai întâi.",
"override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.",
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
"until_they_submit_a_response": "Întreabă până când trimit un răspuns",
"untitled_block": "Bloc fără titlu",
+ "update_options": "Actualizați opțiunile",
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
"upload": "Încărcați",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Începeți",
"made_with_love_in_kiel": "Creat cu 🤍 în Germania",
- "paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza platformei de sondaje open source care crește cel mai rapid din lume.",
+ "paragraph_1": "Formbricks este o suită de management al experienței construită pe cea mai rapidă platformă open-source de sondaje din lume.",
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a crea experiențe irezistibile pentru clienți, utilizatori și angajați.",
- "paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă control deplin asupra datelor dumneavoastră .",
+ "paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra controlul deplin asupra datelor tale .",
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index c119f48e8d..95cd8fd01b 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -264,6 +264,7 @@
"maximum": "Maximum",
"member": "Medlem",
"members": "Medlemmar",
+ "members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -344,6 +345,7 @@
"read_docs": "Läs dokumentation",
"recipients": "Mottagare",
"remove": "Ta bort",
+ "remove_from_team": "Ta bort från teamet",
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
"report_survey": "Rapportera enkät",
"request_pricing": "Begär prissättning",
@@ -353,10 +355,10 @@
"responses": "Svar",
"restart": "Starta om",
"role": "Roll",
- "role_organization": "Roll (Organisation)",
"saas": "SaaS",
"sales": "Försäljning",
"save": "Spara",
+ "save_as_draft": "Spara som utkast",
"save_changes": "Spara ändringar",
"saving": "Sparar",
"search": "Sök",
@@ -411,6 +413,7 @@
"team_access": "Teamåtkomst",
"team_id": "Team-ID",
"team_name": "Teamnamn",
+ "team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"teams_not_found": "Team hittades inte",
"text": "Text",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i 24 timmar.",
"forgot_password_email_subject": "Återställ ditt Formbricks-lösenord",
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
+ "hidden_field": "Dolt fält",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hej",
"invite_accepted_email_subject": "Du har fått en ny organisationsmedlem!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "bjöd in dig att gå med dem på Formbricks. För att acceptera inbjudan, vänligen klicka på länken nedan:",
"invite_member_email_subject": "Du är inbjuden att samarbeta på Formbricks!",
"new_email_verification_text": "För att verifiera din nya e-postadress, vänligen klicka på knappen nedan:",
+ "number_variable": "Nummervariabel",
"password_changed_email_heading": "Lösenord ändrat",
"password_changed_email_text": "Ditt lösenord har ändrats.",
"password_reset_notify_email_subject": "Ditt Formbricks-lösenord har ändrats",
"privacy_policy": "Integritetspolicy",
"reject": "Avvisa",
"render_email_response_value_file_upload_response_link_not_included": "Länk till uppladdad fil ingår inte av dataskyddsskäl",
+ "response_data": "Svarsdata",
"response_finished_email_subject": "Ett svar för {surveyName} har slutförts ✅",
"response_finished_email_subject_with_email": "{personEmail} har precis slutfört din {surveyName}-enkät ✅",
"schedule_your_meeting": "Boka ditt möte",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Stäng av aviseringar för detta formulär",
"survey_response_finished_email_view_more_responses": "Visa {responseCount} fler svar",
"survey_response_finished_email_view_survey_summary": "Visa enkätsammanfattning",
+ "text_variable": "Textvariabel",
"verification_email_click_on_this_link": "Du kan också klicka på denna länk:",
"verification_email_heading": "Nästan där!",
"verification_email_hey": "Hej 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "Hantera team",
"no_teams_found": "Inga team hittades",
- "only_organization_owners_and_managers_can_manage_teams": "Endast organisationsägare och administratörer kan hantera team.",
"permission": "Behörighet",
"team_name": "Teamnamn",
"team_settings_description": "Se vilka team som kan komma åt detta projekt."
@@ -1179,13 +1185,17 @@
"manage_team": "Hantera team",
"manage_team_disabled": "Endast organisationsägare, administratörer och teamadministratörer kan hantera team.",
"manager_role_description": "Administratörer kan komma åt alla projekt och lägga till och ta bort medlemmar.",
+ "member": "Medlem",
"member_role_description": "Medlemmar kan arbeta i valda projekt.",
"member_role_info_message": "För att ge nya medlemmar åtkomst till ett projekt, vänligen lägg till dem i ett team nedan. Med team kan du hantera vem som har åtkomst till vilket projekt.",
+ "organization_role": "Organisationsroll",
"owner_role_description": "Ägare har full kontroll över organisationen.",
"please_fill_all_member_fields": "Vänligen fyll i alla fält för att lägga till en ny medlem.",
"please_fill_all_project_fields": "Vänligen fyll i alla fält för att lägga till ett nytt projekt.",
"read": "Läs",
"read_write": "Läs och skriv",
+ "select_member": "Välj medlem",
+ "select_project": "Välj projekt",
"team_admin": "Teamadministratör",
"team_created_successfully": "Team skapat.",
"team_deleted_successfully": "Team borttaget.",
@@ -1273,6 +1283,10 @@
"bold": "Fet",
"brand_color": "Varumärkesfärg",
"brightness": "Ljusstyrka",
+ "bulk_edit": "Massredigera",
+ "bulk_edit_description": "Redigera alla alternativ nedan, ett per rad. Tomma rader kommer att hoppas över och dubbletter tas bort.",
+ "bulk_edit_options": "Massredigera alternativ",
+ "bulk_edit_options_for": "Massredigera alternativ för {language}",
"button_external": "Aktivera extern länk",
"button_external_description": "Lägg till en knapp som öppnar en extern URL i en ny flik",
"button_label": "Knappetikett",
@@ -1392,6 +1406,8 @@
"follow_ups_ending_card_delete_modal_text": "Detta avslutningskort används i uppföljningar. Att ta bort det kommer att ta bort det från alla uppföljningar. Är du säker på att du vill ta bort det?",
"follow_ups_ending_card_delete_modal_title": "Ta bort avslutningskort?",
"follow_ups_hidden_field_error": "Dolt fält används i en uppföljning. Vänligen ta bort det från uppföljningen först.",
+ "follow_ups_include_hidden_fields": "Inkludera värden för dolda fält",
+ "follow_ups_include_variables": "Inkludera värden för variabler",
"follow_ups_item_ending_tag": "Avslutning(ar)",
"follow_ups_item_issue_detected_tag": "Problem upptäckt",
"follow_ups_item_response_tag": "Alla svar",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "Detta alternativ används i logiken för fråga {questionIndex}. Vänligen ta bort det från logiken först.",
"optional": "Valfritt",
"options": "Alternativ",
+ "options_used_in_logic_bulk_error": "Följande alternativ används i logiken: {questionIndexes}. Vänligen ta bort dem från logiken först.",
"override_theme_with_individual_styles_for_this_survey": "Åsidosätt temat med individuella stilar för denna enkät.",
"overwrite_global_waiting_time": "Ställ in anpassad väntetid",
"overwrite_global_waiting_time_description": "Åsidosätt projektkonfigurationen endast för denna enkät.",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "Du har osparade ändringar i din enkät. Vill du spara dem innan du lämnar?",
"until_they_submit_a_response": "Fråga tills de skickar in ett svar",
"untitled_block": "Namnlöst block",
+ "update_options": "Uppdatera alternativ",
"upgrade_notice_description": "Skapa flerspråkiga enkäter och lås upp många fler funktioner",
"upgrade_notice_title": "Lås upp flerspråkiga enkäter med en högre plan",
"upload": "Ladda upp",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "Kom igång",
"made_with_love_in_kiel": "Gjort med 🤍 i Tyskland",
- "paragraph_1": "Formbricks är en Experience Management Suite byggd av den snabbast växande öppenkällkods enkätplattformen i världen.",
+ "paragraph_1": "Formbricks är en Experience Management Suite byggd på den snabbast växande open source-enkätplattformen i världen.",
"paragraph_2": "Kör riktade enkäter på webbplatser, i appar eller var som helst online. Samla värdefulla insikter för att skapa oemotståndliga upplevelser för kunder, användare och anställda.",
- "paragraph_3": "Vi är engagerade i högsta grad av dataintegritet. Självhosta för att behålla full kontroll över dina data .",
+ "paragraph_3": "Vi är engagerade i högsta möjliga datasekretess. Självhosta för att behålla full kontroll över dina data .",
"welcome_to_formbricks": "Välkommen till Formbricks!"
},
"invite": {
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index 39ed167124..1f20edc9fa 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -264,6 +264,7 @@
"maximum": "最大值",
"member": "成员",
"members": "成员",
+ "members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
@@ -341,9 +342,10 @@
"quota": "配额",
"quotas": "配额",
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
- "read_docs": "阅读 文档",
+ "read_docs": "阅读文档",
"recipients": "收件人",
"remove": "移除",
+ "remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
"report_survey": "报告调查",
"request_pricing": "请求 定价",
@@ -353,10 +355,10 @@
"responses": "反馈",
"restart": "重新启动",
"role": "角色",
- "role_organization": "角色 (组织)",
"saas": "SaaS",
"sales": "销售",
"save": "保存",
+ "save_as_draft": "保存为草稿",
"save_changes": "保存 更改",
"saving": "保存",
"search": "搜索",
@@ -411,7 +413,8 @@
"team_access": "团队 访问",
"team_id": "团队 ID",
"team_name": "团队 名称",
- "teams": "访问控制",
+ "team_role": "团队角色",
+ "teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
+ "hidden_field": "隐藏字段",
"imprint": "印记",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "你 有 一个 新 成员 进入 组织 了!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "邀请您加入他们在 Formbricks 。要接受邀请,请点击下面的链接:",
"invite_member_email_subject": "您 被 邀请 来 协作 于 Formbricks!",
"new_email_verification_text": "要 验证 您 的 新 邮箱 地址 ,请 点击 下方 的 按钮 :",
+ "number_variable": "数字变量",
"password_changed_email_heading": "密码 已更改",
"password_changed_email_text": "您的 密码已成功更改",
"password_reset_notify_email_subject": "您的 Formbricks 密码已更改",
"privacy_policy": "隐私政策",
"reject": "拒绝",
"render_email_response_value_file_upload_response_link_not_included": "未包括上传文件的链接 数据隐私原因",
+ "response_data": "响应数据",
"response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 刚刚完成了你的 {surveyName} 调查 ✅",
"schedule_your_meeting": "安排你的会议",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "关闭 此表单 的通知",
"survey_response_finished_email_view_more_responses": "查看 {responseCount} 更多 响应",
"survey_response_finished_email_view_survey_summary": "查看 问卷 摘要",
+ "text_variable": "文本变量",
"verification_email_click_on_this_link": "您 也 可以 点击 此 链接:",
"verification_email_heading": "马上就好!",
"verification_email_hey": "嗨 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "管理 团队",
"no_teams_found": "未找到 团队",
- "only_organization_owners_and_managers_can_manage_teams": "只有 组织 拥有者 和 经理 可以 管理 团队。",
"permission": "权限",
"team_name": "团队名称",
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
@@ -1179,13 +1185,17 @@
"manage_team": "管理团队",
"manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。",
"manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。",
+ "member": "成员",
"member_role_description": "成员 可以 在 选定 项目 中 工作。",
"member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队 你 可以 管理 谁 可以 访问 哪个 项目 。",
+ "organization_role": "组织角色",
"owner_role_description": "所有者拥有对组织的完全控制权。",
"please_fill_all_member_fields": "请 填写 所有 字段 以 添加 新 成员。",
"please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。",
"read": "阅读",
"read_write": "读 & 写",
+ "select_member": "选择成员",
+ "select_project": "选择项目",
"team_admin": "团队管理员",
"team_created_successfully": "团队 创建 成功",
"team_deleted_successfully": "团队 删除 成功",
@@ -1273,6 +1283,10 @@
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
+ "bulk_edit": "批量编辑",
+ "bulk_edit_description": "编辑以下所有选项,每行一个。空行将被跳过,重复项将被移除。",
+ "bulk_edit_options": "批量编辑选项",
+ "bulk_edit_options_for": "为 {language} 批量编辑选项",
"button_external": "启用外部链接",
"button_external_description": "添加一个按钮,在新标签页中打开外部URL",
"button_label": "按钮标签",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "此结束卡片 用于 后续跟踪. 删除 它 将会 从 所有 后续跟踪 中 移除. 确定 要 删除 它 吗?",
"follow_ups_ending_card_delete_modal_title": "删除 结尾卡片?",
"follow_ups_hidden_field_error": "隐藏 字段 用于 后续 。请 先 从 后续 中 移除 它 。",
+ "follow_ups_include_hidden_fields": "包括隐藏字段值",
+ "follow_ups_include_variables": "包括变量值",
"follow_ups_item_ending_tag": "结尾",
"follow_ups_item_issue_detected_tag": "问题 检测",
"follow_ups_item_response_tag": "任何 响应",
"follow_ups_item_send_email_tag": "发送 邮件",
- "follow_ups_modal_action_attach_response_data_description": "添加 调查 响应 数据 到 跟进",
+ "follow_ups_modal_action_attach_response_data_description": "仅附加调查响应中已回答的问题",
"follow_ups_modal_action_attach_response_data_label": "附加响应数据",
"follow_ups_modal_action_body_label": "正文",
"follow_ups_modal_action_body_placeholder": "电子邮件正文",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "\"这个 选项 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"optional": "可选",
"options": "选项",
+ "options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。",
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
"overwrite_global_waiting_time": "设置自定义等待时间",
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
"until_they_submit_a_response": "持续显示直到提交回应",
"untitled_block": "未命名区块",
+ "update_options": "更新选项",
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
"upload": "上传",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "开始使用",
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
- "paragraph_1": "Formbricks 是一个体验管理套件, 基于全球增长最快的开源调查平台 构建。",
+ "paragraph_1": "Formbricks 是一款体验管理套件,基于全球增长最快的开源调研平台 构建。",
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工打造 无法抗拒 的体验 。",
- "paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持对您的数据的完全控制 。",
+ "paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,全面掌控您的数据 。",
"welcome_to_formbricks": "欢迎来到 Formbricks !"
},
"invite": {
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 64d74ca1bf..e6ec95760e 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -264,6 +264,7 @@
"maximum": "最大值",
"member": "成員",
"members": "成員",
+ "members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
@@ -344,6 +345,7 @@
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
+ "remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
@@ -353,10 +355,10 @@
"responses": "回應",
"restart": "重新開始",
"role": "角色",
- "role_organization": "角色(組織)",
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
+ "save_as_draft": "儲存為草稿",
"save_changes": "儲存變更",
"saving": "儲存",
"search": "搜尋",
@@ -411,7 +413,8 @@
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
- "teams": "存取控制",
+ "team_role": "團隊角色",
+ "teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
@@ -474,6 +477,7 @@
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
+ "hidden_field": "隱藏欄位",
"imprint": "版本訊息",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "您有一位新的組織成員!",
@@ -485,12 +489,14 @@
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:",
"invite_member_email_subject": "您被邀請協作 Formbricks!",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
+ "number_variable": "數字變數",
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
"privacy_policy": "隱私權政策",
"reject": "拒絕",
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
+ "response_data": "回應資料",
"response_finished_email_subject": "{surveyName} 的回應已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅",
"schedule_your_meeting": "安排你的會議",
@@ -502,6 +508,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "關閉此表單的通知",
"survey_response_finished_email_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
"survey_response_finished_email_view_survey_summary": "檢視問卷摘要",
+ "text_variable": "文字變數",
"verification_email_click_on_this_link": "您也可以點擊此連結:",
"verification_email_heading": "快完成了!",
"verification_email_hey": "嗨 👋",
@@ -907,7 +914,6 @@
"teams": {
"manage_teams": "管理團隊",
"no_teams_found": "找不到團隊",
- "only_organization_owners_and_managers_can_manage_teams": "只有組織擁有者和管理員才能管理團隊。",
"permission": "權限",
"team_name": "團隊名稱",
"team_settings_description": "查看哪些團隊可以存取此專案。"
@@ -1179,13 +1185,17 @@
"manage_team": "管理團隊",
"manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。",
"manager_role_description": "管理員可以存取所有專案,並新增和移除成員。",
+ "member": "成員",
"member_role_description": "成員可以在選定的專案中工作。",
"member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。",
+ "organization_role": "組織角色",
"owner_role_description": "擁有者對組織具有完全控制權。",
"please_fill_all_member_fields": "請填寫所有欄位以新增新成員。",
"please_fill_all_project_fields": "請填寫所有欄位以新增新專案。",
"read": "讀取",
"read_write": "讀取和寫入",
+ "select_member": "選擇成員",
+ "select_project": "選擇專案",
"team_admin": "團隊管理員",
"team_created_successfully": "團隊已成功建立。",
"team_deleted_successfully": "團隊已成功刪除。",
@@ -1273,6 +1283,10 @@
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
+ "bulk_edit": "批次編輯",
+ "bulk_edit_description": "在下方逐行編輯所有選項。空白行將被略過,重複項目將被移除。",
+ "bulk_edit_options": "批次編輯選項",
+ "bulk_edit_options_for": "為 {language} 批次編輯選項",
"button_external": "啟用外部連結",
"button_external_description": "新增一個按鈕,在新分頁中開啟外部網址",
"button_label": "按鈕標籤",
@@ -1392,11 +1406,13 @@
"follow_ups_ending_card_delete_modal_text": "此結尾卡片用於後續追蹤中。刪除它將會從所有後續追蹤中移除。您確定要刪除它嗎?",
"follow_ups_ending_card_delete_modal_title": "刪除結尾卡片?",
"follow_ups_hidden_field_error": "隱藏欄位在後續追蹤中使用。請先從後續追蹤中移除。",
+ "follow_ups_include_hidden_fields": "包含隱藏欄位的值",
+ "follow_ups_include_variables": "包含變數的值",
"follow_ups_item_ending_tag": "結尾",
"follow_ups_item_issue_detected_tag": "偵測到問題",
"follow_ups_item_response_tag": "任何回應",
"follow_ups_item_send_email_tag": "發送電子郵件",
- "follow_ups_modal_action_attach_response_data_description": "將調查回應的數據添加到後續",
+ "follow_ups_modal_action_attach_response_data_description": "僅附加在調查回應中回答過的問題",
"follow_ups_modal_action_attach_response_data_label": "附加 response data",
"follow_ups_modal_action_body_label": "內文",
"follow_ups_modal_action_body_placeholder": "電子郵件內文",
@@ -1519,6 +1535,7 @@
"option_used_in_logic_error": "此選項用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"optional": "選填",
"options": "選項",
+ "options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。",
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
"overwrite_global_waiting_time": "設定自訂等待時間",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
@@ -1668,6 +1685,7 @@
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
"until_they_submit_a_response": "持續詢問直到提交回應",
"untitled_block": "未命名區塊",
+ "update_options": "更新選項",
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
"upload": "上傳",
@@ -2051,9 +2069,9 @@
"intro": {
"get_started": "開始使用",
"made_with_love_in_kiel": "用 🤍 在德國製造",
- "paragraph_1": "Formbricks 是一套體驗管理套件,建立於全球成長最快的開源問卷平台 之上。",
+ "paragraph_1": "Formbricks 是一套體驗管理工具,建構於全球成長最快的開源問卷平台 之上。",
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工打造無法抗拒的體驗 。",
- "paragraph_3": "我們致力於最高程度的資料隱私權。自行託管以完全掌控您的資料 。",
+ "paragraph_3": "我們致力於最高等級的資料隱私。自我託管,讓您完全掌控您的資料 。",
"welcome_to_formbricks": "歡迎使用 Formbricks!"
},
"invite": {
diff --git a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts
index 026c1e4094..d8821e31fe 100644
--- a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts
+++ b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts
@@ -3,7 +3,7 @@ import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
-import { createTeamMembership } from "../team";
+import { createTeamMembership, getTeamProjectIds } from "../team";
// Setup all mocks
const setupMocks = () => {
@@ -31,6 +31,7 @@ const setupMocks = () => {
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
+ warn: vi.fn(),
},
}));
@@ -55,7 +56,7 @@ describe("Team Management", () => {
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
- vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
@@ -90,7 +91,7 @@ describe("Team Management", () => {
role: "member" as OrganizationRole,
};
- vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
@@ -110,11 +111,68 @@ describe("Team Management", () => {
describe("error handling", () => {
test("throws error when database operation fails", async () => {
- vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
});
+
+ describe("when team does not exist", () => {
+ test("skips membership creation and continues to next team", async () => {
+ const inviteWithMultipleTeams: CreateMembershipInvite = {
+ ...MOCK_INVITE,
+ teamIds: ["non-existent-team", MOCK_IDS.teamId],
+ };
+
+ vi.mocked(prisma.team.findUnique)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(MOCK_TEAM as unknown as any);
+ vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
+
+ await createTeamMembership(inviteWithMultipleTeams, MOCK_IDS.userId);
+
+ expect(prisma.team.findUnique).toHaveBeenCalledTimes(2);
+ expect(prisma.teamUser.create).toHaveBeenCalledTimes(1);
+ expect(prisma.teamUser.create).toHaveBeenCalledWith({
+ data: {
+ teamId: MOCK_IDS.teamId,
+ userId: MOCK_IDS.userId,
+ role: "admin",
+ },
+ });
+ });
+ });
+ });
+
+ describe("getTeamProjectIds", () => {
+ test("returns team with projectTeams when team exists", async () => {
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
+
+ const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
+
+ expect(result).toEqual(MOCK_TEAM);
+ expect(prisma.team.findUnique).toHaveBeenCalledWith({
+ where: {
+ id: MOCK_IDS.teamId,
+ organizationId: MOCK_IDS.organizationId,
+ },
+ select: {
+ projectTeams: {
+ select: {
+ projectId: true,
+ },
+ },
+ },
+ });
+ });
+
+ test("returns null when team does not exist", async () => {
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
+
+ const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
+
+ expect(result).toBeNull();
+ });
});
});
diff --git a/apps/web/modules/auth/signup/lib/team.ts b/apps/web/modules/auth/signup/lib/team.ts
index 1fd81fe294..7f1324a9a4 100644
--- a/apps/web/modules/auth/signup/lib/team.ts
+++ b/apps/web/modules/auth/signup/lib/team.ts
@@ -18,15 +18,18 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
for (const teamId of teamIds) {
const team = await getTeamProjectIds(teamId, invite.organizationId);
- if (team) {
- await prisma.teamUser.create({
- data: {
- teamId,
- userId,
- role: isOwnerOrManager ? "admin" : "contributor",
- },
- });
+ if (!team) {
+ logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance");
+ continue;
}
+
+ await prisma.teamUser.create({
+ data: {
+ teamId,
+ userId,
+ role: isOwnerOrManager ? "admin" : "contributor",
+ },
+ });
}
} catch (error) {
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
@@ -39,7 +42,10 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
};
export const getTeamProjectIds = reactCache(
- async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => {
+ async (
+ teamId: string,
+ organizationId: string
+ ): Promise<{ projectTeams: { projectId: string }[] } | null> => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
@@ -55,7 +61,7 @@ export const getTeamProjectIds = reactCache(
});
if (!team) {
- throw new Error("Team not found");
+ return null;
}
return team;
diff --git a/apps/web/modules/auth/verify/components/sign-in.tsx b/apps/web/modules/auth/verify/components/sign-in.tsx
index 931e86bb20..82268930a3 100644
--- a/apps/web/modules/auth/verify/components/sign-in.tsx
+++ b/apps/web/modules/auth/verify/components/sign-in.tsx
@@ -3,15 +3,15 @@
import { signIn } from "next-auth/react";
import { useEffect } from "react";
-export const SignIn = ({ token }) => {
+export const SignIn = ({ token, webAppUrl }) => {
useEffect(() => {
if (token) {
signIn("token", {
token: token,
- callbackUrl: `/`,
+ callbackUrl: webAppUrl,
});
}
- }, [token]);
+ }, [token, webAppUrl]);
return <>>;
};
diff --git a/apps/web/modules/auth/verify/page.tsx b/apps/web/modules/auth/verify/page.tsx
index 0c4881e0bc..5c988d2be8 100644
--- a/apps/web/modules/auth/verify/page.tsx
+++ b/apps/web/modules/auth/verify/page.tsx
@@ -1,3 +1,4 @@
+import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { SignIn } from "@/modules/auth/verify/components/sign-in";
@@ -9,7 +10,7 @@ export const VerifyPage = async ({ searchParams }) => {
return token ? (
{t("auth.verify.verifying")}
-
+
) : (
{t("auth.verify.no_token_provided")}
diff --git a/apps/web/modules/ee/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/lib/contacts.test.ts
index 0aa8fd0bd1..9486e74a76 100644
--- a/apps/web/modules/ee/contacts/lib/contacts.test.ts
+++ b/apps/web/modules/ee/contacts/lib/contacts.test.ts
@@ -458,21 +458,15 @@ describe("Contacts Lib", () => {
attributes: [{ attributeKey: { key: "email", id: "key-1" }, value: "john@example.com" }],
};
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([existingContact as any])
- .mockResolvedValueOnce([{ key: "email", id: "key-1" } as any])
- .mockResolvedValueOnce([
- { key: "userId", id: "key-2" },
- { key: "email", id: "key-1" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([{ key: "email", id: "key-1" }] as any)
.mockResolvedValueOnce([
- { key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
+ { key: "name", id: "key-3" },
] as any);
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
@@ -489,25 +483,15 @@ describe("Contacts Lib", () => {
],
};
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([existingContact as any])
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ .mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap);
@@ -525,25 +509,15 @@ describe("Contacts Lib", () => {
],
};
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([existingContact as any])
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ .mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
@@ -582,23 +556,16 @@ describe("Contacts Lib", () => {
test("creates missing attribute keys", async () => {
const attributeMap = { email: "email", userId: "userId" };
- vi.mocked(prisma.contact.findMany)
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([
- { key: "email", id: "key-1" },
- { key: "userId", id: "key-2" },
- ] as any);
-
+ vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
+ { key: "name", id: "key-3" },
] as any);
-
- vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
+ vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 3 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "new-1",
environmentId: mockEnvironmentId,
diff --git a/apps/web/modules/ee/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/lib/contacts.ts
index f1813da0f7..6fd1578017 100644
--- a/apps/web/modules/ee/contacts/lib/contacts.ts
+++ b/apps/web/modules/ee/contacts/lib/contacts.ts
@@ -200,6 +200,50 @@ export const deleteContact = async (contactId: string): Promise
}
};
+// Shared include clause for contact queries
+const contactAttributesInclude = {
+ attributes: {
+ select: {
+ attributeKey: { select: { key: true } },
+ value: true,
+ },
+ },
+} satisfies Prisma.ContactInclude;
+
+// Helper to create attribute objects for Prisma create operations
+const createAttributeConnections = (record: Record, environmentId: string) =>
+ Object.entries(record).map(([key, value]) => ({
+ attributeKey: {
+ connect: { key_environmentId: { key, environmentId } },
+ },
+ value,
+ }));
+
+// Helper to handle userId conflicts when updating/overwriting contacts
+const resolveUserIdConflict = (
+ mappedRecord: Record,
+ existingContact: { id: string; attributes: { attributeKey: { key: string }; value: string }[] },
+ existingUserIds: { value: string; contactId: string }[]
+): Record => {
+ const existingUserId = existingUserIds.find(
+ (attr) => attr.value === mappedRecord.userId && attr.contactId !== existingContact.id
+ );
+
+ if (!existingUserId) {
+ return { ...mappedRecord };
+ }
+
+ const { userId: _userId, ...rest } = mappedRecord;
+ const existingContactUserId = existingContact.attributes.find(
+ (attr) => attr.attributeKey.key === "userId"
+ )?.value;
+
+ return {
+ ...rest,
+ ...(existingContactUserId && { userId: existingContactUserId }),
+ };
+};
+
export const createContactsFromCSV = async (
csvData: Record[],
environmentId: string,
@@ -287,22 +331,36 @@ export const createContactsFromCSV = async (
});
const attributeKeyMap = new Map();
+ // Map from lowercase key to actual DB key (for case-insensitive lookup)
+ const lowercaseToActualKeyMap = new Map();
+
existingAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
+ lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
- // Identify missing attribute keys (normalize keys to lowercase)
+ // Collect all unique CSV keys
const csvKeys = new Set();
csvData.forEach((record) => {
- Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase()));
+ Object.keys(record).forEach((key) => csvKeys.add(key));
});
- const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
+ // Identify missing attribute keys (case-insensitive check)
+ const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
- // Create missing attribute keys
+ // Create missing attribute keys (use original CSV casing for new keys)
if (missingKeys.length > 0) {
+ // Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
+ const uniqueMissingKeys = new Map();
+ missingKeys.forEach((key) => {
+ const lowerKey = key.toLowerCase();
+ if (!uniqueMissingKeys.has(lowerKey)) {
+ uniqueMissingKeys.set(lowerKey, key);
+ }
+ });
+
await prisma.contactAttributeKey.createMany({
- data: missingKeys.map((key) => ({
+ data: Array.from(uniqueMissingKeys.values()).map((key) => ({
key,
name: key,
environmentId,
@@ -310,10 +368,10 @@ export const createContactsFromCSV = async (
skipDuplicates: true,
});
- // Fetch and update the attributeKeyMap with new keys
+ // Fetch and update the maps with new keys
const newAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
- key: { in: missingKeys },
+ key: { in: Array.from(uniqueMissingKeys.values()) },
environmentId,
},
select: { key: true, id: true },
@@ -321,6 +379,7 @@ export const createContactsFromCSV = async (
newAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
+ lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
}
@@ -328,18 +387,23 @@ export const createContactsFromCSV = async (
// Process contacts in parallel
const contactPromises = csvData.map(async (record) => {
- // Normalize record keys to lowercase
- const normalizedRecord: Record = {};
+ // Map CSV keys to actual DB keys (case-insensitive matching, preserving DB key casing)
+ const mappedRecord: Record = {};
Object.entries(record).forEach(([key, value]) => {
- normalizedRecord[key.toLowerCase()] = value;
+ const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
+ if (!actualKey) {
+ // This should never happen since we create missing keys above
+ throw new ValidationError(`Attribute key "${key}" not found in attribute key map`);
+ }
+ mappedRecord[actualKey] = value;
});
// Skip records without email
- if (!normalizedRecord.email) {
+ if (!mappedRecord.email) {
throw new ValidationError("Email is required for all contacts");
}
- const existingContact = emailToContactMap.get(normalizedRecord.email);
+ const existingContact = emailToContactMap.get(mappedRecord.email);
if (existingContact) {
// Handle duplicates based on duplicateContactsAction
@@ -348,25 +412,7 @@ export const createContactsFromCSV = async (
return null;
case "update": {
- // if the record has a userId, check if it already exists
- const existingUserId = existingUserIds.find(
- (attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
- );
- let recordToProcess = { ...normalizedRecord };
- if (existingUserId) {
- const { userid, ...rest } = recordToProcess;
-
- const existingContactUserId = existingContact.attributes.find(
- (attr) => attr.attributeKey.key === "userId"
- )?.value;
-
- recordToProcess = {
- ...rest,
- ...(existingContactUserId && {
- userId: existingContactUserId,
- }),
- };
- }
+ const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
where: {
@@ -383,7 +429,7 @@ export const createContactsFromCSV = async (
}));
// Update contact with upserted attributes
- const updatedContact = prisma.contact.update({
+ return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
@@ -391,98 +437,40 @@ export const createContactsFromCSV = async (
upsert: attributesToUpsert,
},
},
- include: {
- attributes: {
- select: {
- attributeKey: { select: { key: true } },
- value: true,
- },
- },
- },
+ include: contactAttributesInclude,
});
-
- return updatedContact;
}
case "overwrite": {
- // if the record has a userId, check if it already exists
- const existingUserId = existingUserIds.find(
- (attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
- );
- let recordToProcess = { ...normalizedRecord };
- if (existingUserId) {
- const { userid, ...rest } = recordToProcess;
- const existingContactUserId = existingContact.attributes.find(
- (attr) => attr.attributeKey.key === "userId"
- )?.value;
-
- recordToProcess = {
- ...rest,
- ...(existingContactUserId && {
- userId: existingContactUserId,
- }),
- };
- }
+ const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
// Overwrite by deleting existing attributes and creating new ones
await prisma.contactAttribute.deleteMany({
where: { contactId: existingContact.id },
});
- const newAttributes = Object.entries(recordToProcess).map(([key, value]) => ({
- attributeKey: {
- connect: { key_environmentId: { key, environmentId } },
- },
- value,
- }));
-
- const updatedContact = prisma.contact.update({
+ return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
- create: newAttributes,
- },
- },
- include: {
- attributes: {
- select: {
- attributeKey: { select: { key: true } },
- value: true,
- },
+ create: createAttributeConnections(recordToProcess, environmentId),
},
},
+ include: contactAttributesInclude,
});
-
- return updatedContact;
}
}
} else {
- // Create new contact
- const newAttributes = Object.entries(record).map(([key, value]) => ({
- attributeKey: {
- connect: { key_environmentId: { key, environmentId } },
- },
- value,
- }));
-
- const newContact = prisma.contact.create({
+ // Create new contact - use mappedRecord with proper DB key casing
+ return prisma.contact.create({
data: {
environmentId,
attributes: {
- create: newAttributes,
- },
- },
- include: {
- attributes: {
- select: {
- attributeKey: { select: { key: true } },
- value: true,
- },
+ create: createAttributeConnections(mappedRecord, environmentId),
},
},
+ include: contactAttributesInclude,
});
-
- return newContact;
}
});
diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts
index 393f2d5f76..cdd14b2034 100644
--- a/apps/web/modules/ee/license-check/lib/license.ts
+++ b/apps/web/modules/ee/license-check/lib/license.ts
@@ -7,6 +7,7 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
+import { E2E_TESTING } from "@/lib/constants";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceId } from "@/lib/instance";
@@ -262,7 +263,9 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise controller.abort(), CONFIG.API.TIMEOUT_MS);
+ const payload: Record = {
+ licenseKey: env.ENTERPRISE_LICENSE_KEY,
+ usage: { responseCount },
+ };
+
+ if (instanceId) {
+ payload.instanceId = instanceId;
+ }
+
const res = await fetch(CONFIG.API.ENDPOINT, {
- body: JSON.stringify({
- licenseKey: env.ENTERPRISE_LICENSE_KEY,
- usage: { responseCount },
- instanceId,
- }),
+ body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
diff --git a/apps/web/modules/ee/role-management/components/add-member-role.tsx b/apps/web/modules/ee/role-management/components/add-member-role.tsx
index 2b5ed799d1..00400f3b5d 100644
--- a/apps/web/modules/ee/role-management/components/add-member-role.tsx
+++ b/apps/web/modules/ee/role-management/components/add-member-role.tsx
@@ -60,7 +60,7 @@ export function AddMemberRole({
name="role"
render={({ field: { onChange, value } }) => (
-
{t("common.role_organization")}
+
{t("environments.settings.teams.organization_role")}
({
prisma: {
projectTeam: { findMany: vi.fn() },
- teamUser: { findUnique: vi.fn() },
+ teamUser: { findUnique: vi.fn(), findMany: vi.fn() },
},
}));
@@ -19,6 +19,7 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockUserId = "user-1";
const mockProjectId = "project-1";
const mockTeamId = "team-1";
+const mockOrganizationId = "org-1";
describe("roles lib", () => {
beforeEach(() => {
@@ -90,7 +91,7 @@ describe("roles lib", () => {
});
test("returns role if teamUser exists", async () => {
- vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
+ vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" } as unknown as any);
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBe("member");
});
@@ -110,4 +111,47 @@ describe("roles lib", () => {
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
});
});
+
+ describe("getTeamsWhereUserIsAdmin", () => {
+ test("returns empty array if user is not admin of any team", async () => {
+ vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([]);
+ const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
+ expect(result).toEqual([]);
+ expect(validateInputs).toHaveBeenCalledWith(
+ [mockUserId, expect.anything()],
+ [mockOrganizationId, expect.anything()]
+ );
+ });
+
+ test("returns array of team IDs where user is admin", async () => {
+ vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([
+ { teamId: "team-1" },
+ { teamId: "team-2" },
+ { teamId: "team-3" },
+ ] as unknown as any);
+ const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
+ expect(result).toEqual(["team-1", "team-2", "team-3"]);
+ });
+
+ test("returns single team ID when user is admin of one team", async () => {
+ vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([{ teamId: "team-1" }] as unknown as any);
+ const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
+ expect(result).toEqual(["team-1"]);
+ });
+
+ test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
+ const error = new Prisma.PrismaClientKnownRequestError("fail", {
+ code: "P2002",
+ clientVersion: "1.0.0",
+ });
+ vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
+ await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(DatabaseError);
+ });
+
+ test("throws error on generic error", async () => {
+ const error = new Error("fail");
+ vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
+ await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(error);
+ });
+ });
});
diff --git a/apps/web/modules/ee/teams/lib/roles.ts b/apps/web/modules/ee/teams/lib/roles.ts
index 9ca0cb0d7d..21b3b6c246 100644
--- a/apps/web/modules/ee/teams/lib/roles.ts
+++ b/apps/web/modules/ee/teams/lib/roles.ts
@@ -83,3 +83,31 @@ export const getTeamRoleByTeamIdUserId = reactCache(
}
}
);
+
+export const getTeamsWhereUserIsAdmin = reactCache(
+ async (userId: string, organizationId: string): Promise => {
+ validateInputs([userId, ZId], [organizationId, ZId]);
+ try {
+ const adminTeams = await prisma.teamUser.findMany({
+ where: {
+ userId,
+ role: "admin",
+ team: {
+ organizationId,
+ },
+ },
+ select: {
+ teamId: true,
+ },
+ });
+
+ return adminTeams.map((at) => at.teamId);
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
+ throw new DatabaseError(error.message);
+ }
+
+ throw error;
+ }
+ }
+);
diff --git a/apps/web/modules/ee/teams/project-teams/components/access-table.tsx b/apps/web/modules/ee/teams/project-teams/components/access-table.tsx
index 41e5d70113..00d9c1f8d3 100644
--- a/apps/web/modules/ee/teams/project-teams/components/access-table.tsx
+++ b/apps/web/modules/ee/teams/project-teams/components/access-table.tsx
@@ -43,7 +43,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
-
+
{TeamPermissionMapping[team.permission]}
diff --git a/apps/web/modules/ee/teams/project-teams/components/access-view.tsx b/apps/web/modules/ee/teams/project-teams/components/access-view.tsx
index 656ed88ddb..1e94a4f989 100644
--- a/apps/web/modules/ee/teams/project-teams/components/access-view.tsx
+++ b/apps/web/modules/ee/teams/project-teams/components/access-view.tsx
@@ -9,10 +9,9 @@ import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
interface AccessViewProps {
teams: TProjectTeam[];
environmentId: string;
- isOwnerOrManager: boolean;
}
-export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessViewProps) => {
+export const AccessView = ({ teams, environmentId }: AccessViewProps) => {
const { t } = useTranslation();
return (
<>
@@ -20,7 +19,7 @@ export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessVie
title={t("common.team_access")}
description={t("environments.project.teams.team_settings_description")}>
-
+
diff --git a/apps/web/modules/ee/teams/project-teams/components/manage-team.tsx b/apps/web/modules/ee/teams/project-teams/components/manage-team.tsx
index 08cffbb844..e3aa60feee 100644
--- a/apps/web/modules/ee/teams/project-teams/components/manage-team.tsx
+++ b/apps/web/modules/ee/teams/project-teams/components/manage-team.tsx
@@ -3,14 +3,12 @@
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
-import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface ManageTeamProps {
environmentId: string;
- isOwnerOrManager: boolean;
}
-export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps) => {
+export const ManageTeam = ({ environmentId }: ManageTeamProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -19,20 +17,9 @@ export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps)
router.push(`/environments/${environmentId}/settings/teams`);
};
- if (isOwnerOrManager) {
- return (
-
- {t("environments.project.teams.manage_teams")}
-
- );
- }
-
return (
-
-
- {t("environments.project.teams.manage_teams")}
-
-
+
+ {t("environments.project.teams.manage_teams")}
+
);
};
diff --git a/apps/web/modules/ee/teams/project-teams/page.tsx b/apps/web/modules/ee/teams/project-teams/page.tsx
index e95f788df9..2b01d628c7 100644
--- a/apps/web/modules/ee/teams/project-teams/page.tsx
+++ b/apps/web/modules/ee/teams/project-teams/page.tsx
@@ -10,7 +10,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
const t = await getTranslate();
const params = await props.params;
- const { project, isOwner, isManager } = await getEnvironmentAuth(params.environmentId);
+ const { project } = await getEnvironmentAuth(params.environmentId);
const teams = await getTeamsByProjectId(project.id);
@@ -18,14 +18,12 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
throw new Error(t("common.teams_not_found"));
}
- const isOwnerOrManager = isOwner || isManager;
-
return (
-
+
);
};
diff --git a/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx
index 8294c36b49..7322c8a779 100644
--- a/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx
+++ b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx
@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
-import { PlusIcon, Trash2Icon } from "lucide-react";
+import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -80,6 +80,16 @@ export const TeamSettingsModal = ({
const router = useRouter();
+ // Track initial member IDs to distinguish existing members from newly added ones
+ const initialMemberIds = useMemo(() => {
+ return new Set(team.members.map((member) => member.userId));
+ }, [team.members]);
+
+ // Track initial project IDs to distinguish existing projects from newly added ones
+ const initialProjectIds = useMemo(() => {
+ return new Set(team.projects.map((project) => project.projectId));
+ }, [team.projects]);
+
const initialMembers = useMemo(() => {
const members = team.members.map((member) => ({
userId: member.userId,
@@ -259,34 +269,44 @@ export const TeamSettingsModal = ({
(
-
- {
- field.onChange(val);
- handleMemberSelectionChange(index, val);
- }}
- disabled={!isOwnerOrManager && !isTeamAdminMember}
- value={member.userId}>
-
-
-
-
- {memberOpts.map((option) => (
-
- {option.label}
-
- ))}
-
-
- {error?.message && (
- {error.message}
- )}
-
- )}
+ render={({ field, fieldState: { error } }) => {
+ // Disable user select for existing members (can only remove or change role)
+ const isExistingMember =
+ member.userId && initialMemberIds.has(member.userId);
+ const isSelectDisabled =
+ isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
+
+ return (
+
+ {
+ field.onChange(val);
+ handleMemberSelectionChange(index, val);
+ }}
+ disabled={isSelectDisabled}
+ value={member.userId}>
+
+
+
+
+ {memberOpts.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ {error?.message && (
+ {error.message}
+ )}
+
+ );
+ }}
/>
1 && (
- handleRemoveMember(index)}>
-
-
+
+ handleRemoveMember(index)}>
+
+
+
)}
);
@@ -360,7 +382,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_members_added")
}>
(
-
-
-
-
-
-
- {projectOpts.map((option) => (
-
- {option.label}
-
- ))}
-
-
- {error?.message && (
- {error.message}
- )}
-
- )}
+ render={({ field, fieldState: { error } }) => {
+ // Disable project select for existing projects (can only remove or change permission)
+ const isExistingProject =
+ project.projectId && initialProjectIds.has(project.projectId);
+ const isSelectDisabled = isExistingProject || !isOwnerOrManager;
+
+ return (
+
+
+
+
+
+
+ {projectOpts.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ {error?.message && (
+ {error.message}
+ )}
+
+ );
+ }}
/>
{Array.isArray(response) &&
- response.map(
- (item, index) =>
- item && (
-
- #{index + 1}
- {item}
-
- )
- )}
+ response.filter(Boolean).map((item, index) => (
+
+ #{index + 1}
+ {item}
+
+ ))}
);
diff --git a/apps/web/modules/email/emails/survey/response-finished-email.tsx b/apps/web/modules/email/emails/survey/response-finished-email.tsx
index 3c9adac87f..73688ad89d 100644
--- a/apps/web/modules/email/emails/survey/response-finished-email.tsx
+++ b/apps/web/modules/email/emails/survey/response-finished-email.tsx
@@ -52,9 +52,17 @@ export async function ResponseFinishedEmail({
);
})}
- {survey.variables.map((variable) => {
- const variableResponse = response.variables[variable.id];
- if (variableResponse && ["number", "string"].includes(typeof variable)) {
+ {survey.variables
+ .filter((variable) => {
+ const variableResponse = response.variables[variable.id];
+ if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
+ return false;
+ }
+
+ return variableResponse !== undefined;
+ })
+ .map((variable) => {
+ const variableResponse = response.variables[variable.id];
return (
@@ -72,12 +80,14 @@ export async function ResponseFinishedEmail({
);
- }
- return null;
- })}
- {survey.hiddenFields.fieldIds?.map((hiddenFieldId) => {
- const hiddenFieldResponse = response.data[hiddenFieldId];
- if (hiddenFieldResponse && typeof hiddenFieldResponse === "string") {
+ })}
+ {survey.hiddenFields.fieldIds
+ ?.filter((hiddenFieldId) => {
+ const hiddenFieldResponse = response.data[hiddenFieldId];
+ return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
+ })
+ .map((hiddenFieldId) => {
+ const hiddenFieldResponse = response.data[hiddenFieldId] as string;
return (
@@ -90,9 +100,7 @@ export async function ResponseFinishedEmail({
);
- }
- return null;
- })}
+ })}
{
+ const isOrgOwnerOrManager = inviterRole === "owner" || inviterRole === "manager";
+ const isTeamAdmin = inviterAdminTeams.length > 0;
+
+ if (!isOrgOwnerOrManager && !isTeamAdmin) {
+ throw new AuthenticationError("Only organization owners, managers, or team admins can invite members");
+ }
+
+ // Team admins have restrictions
+ if (isTeamAdmin && !isOrgOwnerOrManager) {
+ if (inviteRole !== "member") {
+ throw new OperationNotAllowedError("Team admins can only invite users as members");
+ }
+
+ const invalidTeams = inviteTeamIds.filter((id) => !inviterAdminTeams.includes(id));
+ if (invalidTeams.length > 0) {
+ throw new OperationNotAllowedError("Team admins can only add users to teams where they are admin");
+ }
+
+ if (inviteTeamIds.length === 0) {
+ throw new ValidationError("Team admins must add invited users to at least one team");
+ }
+ }
+};
+
const ZInviteUserAction = z.object({
organizationId: ZId,
email: z.string(),
- name: z.string(),
+ name: z.string().trim().min(1, "Name is required"),
role: ZOrganizationRole,
- teamIds: z.array(z.string()),
+ teamIds: z.array(ZId),
});
export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserAction).action(
withAuditLogging(
"created",
"invite",
- async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => {
+ async ({
+ ctx,
+ parsedInput,
+ }: {
+ ctx: AuthenticatedActionClientCtx;
+ parsedInput: z.infer;
+ }) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
@@ -224,16 +261,41 @@ export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserActi
throw new AuthenticationError("User not a member of this organization");
}
- await checkAuthorizationUpdated({
- userId: ctx.user.id,
- organizationId: parsedInput.organizationId,
- access: [
- {
- type: "organization",
- roles: ["owner", "manager"],
- },
- ],
- });
+ const isOrgOwnerOrManager =
+ currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
+
+ // Fetch user's admin teams (empty array if owner/manager to skip unnecessary query)
+ const userAdminTeams = isOrgOwnerOrManager
+ ? []
+ : await getTeamsWhereUserIsAdmin(ctx.user.id, parsedInput.organizationId);
+
+ const isTeamAdmin = userAdminTeams.length > 0;
+
+ if (!isOrgOwnerOrManager && !isTeamAdmin) {
+ throw new AuthenticationError("Not authorized to invite members");
+ }
+
+ if (isOrgOwnerOrManager) {
+ // Standard org-level auth check
+ await checkAuthorizationUpdated({
+ userId: ctx.user.id,
+ organizationId: parsedInput.organizationId,
+ access: [
+ {
+ type: "organization",
+ roles: ["owner", "manager"],
+ },
+ ],
+ });
+ }
+
+ // Validate team admin restrictions
+ validateTeamAdminInvitePermissions(
+ currentUserMembership.role,
+ userAdminTeams,
+ parsedInput.role,
+ parsedInput.teamIds
+ );
if (currentUserMembership.role === "manager" && parsedInput.role !== "member") {
throw new OperationNotAllowedError("Managers can only invite users as members");
diff --git a/apps/web/modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx b/apps/web/modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx
index 1b34c8e027..d919af059a 100644
--- a/apps/web/modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx
+++ b/apps/web/modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx
@@ -37,6 +37,8 @@ interface OrganizationActionsProps {
isMultiOrgEnabled: boolean;
isUserManagementDisabledFromUi: boolean;
isStorageConfigured: boolean;
+ isTeamAdmin: boolean;
+ userAdminTeamIds?: string[];
}
export const OrganizationActions = ({
@@ -52,16 +54,20 @@ export const OrganizationActions = ({
isMultiOrgEnabled,
isUserManagementDisabledFromUi,
isStorageConfigured,
+ isTeamAdmin,
+ userAdminTeamIds,
}: OrganizationActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
- const [isLeaveOrganizationModalOpen, setLeaveOrganizationModalOpen] = useState(false);
- const [isInviteMemberModalOpen, setInviteMemberModalOpen] = useState(false);
+ const [isLeaveOrganizationModalOpen, setIsLeaveOrganizationModalOpen] = useState(false);
+ const [isInviteMemberModalOpen, setIsInviteMemberModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
+ const canInvite = isOwnerOrManager || (isAccessControlAllowed && isTeamAdmin);
+
const handleLeaveOrganization = async () => {
setLoading(true);
try {
@@ -134,18 +140,18 @@ export const OrganizationActions = ({
<>
{role !== "owner" && isMultiOrgEnabled && (
- setLeaveOrganizationModalOpen(true)}>
+ setIsLeaveOrganizationModalOpen(true)}>
{t("environments.settings.general.leave_organization")}
)}
- {!isInviteDisabled && isOwnerOrManager && !isUserManagementDisabledFromUi && (
+ {!isInviteDisabled && canInvite && !isUserManagementDisabledFromUi && (
{
- setInviteMemberModalOpen(true);
+ setIsInviteMemberModalOpen(true);
}}>
{t("environments.settings.teams.invite_member")}
@@ -153,7 +159,7 @@ export const OrganizationActions = ({
-
+
{t("environments.settings.general.leave_organization_title")}
@@ -177,7 +186,7 @@ export const OrganizationActions = ({
)}
- setLeaveOrganizationModalOpen(false)}>
+ setIsLeaveOrganizationModalOpen(false)}>
{t("common.cancel")}
{
const ZFormSchema = z.object({
name: ZUserName,
email: z.string().min(1, { message: "Email is required" }).email({ message: "Invalid email" }),
role: ZOrganizationRole,
- teamIds: z.array(z.string()),
+ teamIds: showTeamAdminRestrictions
+ ? z.array(ZId).min(1, { message: "Team admins must select at least one team" })
+ : z.array(ZId),
});
const router = useRouter();
type TFormData = z.infer;
const { t } = useTranslation();
+
+ // Determine default role based on permissions
+ let defaultRole: TOrganizationRole = "owner";
+ if (showTeamAdminRestrictions || isAccessControlAllowed) {
+ defaultRole = "member";
+ }
+
const form = useForm({
resolver: zodResolver(ZFormSchema),
defaultValues: {
- role: isAccessControlAllowed ? "member" : "owner",
+ role: defaultRole,
teamIds: [],
},
});
@@ -104,43 +116,61 @@ export const IndividualInviteTab = ({
{errors.email && {errors.email.message}
}
-
- {watch("role") === "member" && (
-
- {t("environments.settings.teams.member_role_info_message")}
-
+ {showTeamAdminRestrictions ? (
+
+ {t("environments.settings.teams.organization_role")}
+
+
+ ) : (
+ <>
+
+ {watch("role") === "member" && (
+
+
+ {t("environments.settings.teams.member_role_info_message")}
+
+
+ )}
+ >
)}
{isAccessControlAllowed && (
- (
-
- {t("common.add_to_team")}
-
- field.onChange(val)}
- />
- {!teamOptions.length && (
-
- {t("environments.settings.teams.create_first_team_message")}
-
- )}
-
-
- )}
- />
+ <>
+ (
+
+ {t("common.add_to_team")}
+
+ field.onChange(val)}
+ />
+ {!teamOptions.length && (
+
+ {t("environments.settings.teams.create_first_team_message")}
+
+ )}
+
+ {errors.teamIds?.message}
+
+ )}
+ />
+
+ {t("common.team_role")}
+
+
+ >
)}
{!isAccessControlAllowed && (
diff --git a/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx b/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx
index 04c97b4d9b..3ecc3330ad 100644
--- a/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx
+++ b/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx
@@ -26,6 +26,9 @@ interface InviteMemberModalProps {
environmentId: string;
membershipRole?: TOrganizationRole;
isStorageConfigured: boolean;
+ isOwnerOrManager: boolean;
+ isTeamAdmin: boolean;
+ userAdminTeamIds?: string[];
}
export const InviteMemberModal = ({
@@ -38,11 +41,21 @@ export const InviteMemberModal = ({
environmentId,
membershipRole,
isStorageConfigured,
+ isOwnerOrManager,
+ isTeamAdmin,
+ userAdminTeamIds,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
const { t } = useTranslation();
+ const showTeamAdminRestrictions = !isOwnerOrManager && isTeamAdmin;
+
+ const filteredTeams =
+ showTeamAdminRestrictions && userAdminTeamIds
+ ? teams.filter((t) => userAdminTeamIds.includes(t.id))
+ : teams;
+
const tabs = {
individual: (
),
bulk: (
@@ -75,16 +89,18 @@ export const InviteMemberModal = ({
- setType(inviteType)}
- defaultSelected={type}
- />
- {tabs[type]}
+ {!showTeamAdminRestrictions && (
+ setType(inviteType)}
+ defaultSelected={type}
+ />
+ )}
+ {showTeamAdminRestrictions ? tabs.individual : tabs[type]}
diff --git a/apps/web/modules/organization/settings/teams/components/members-view.tsx b/apps/web/modules/organization/settings/teams/components/members-view.tsx
index e745271966..cbae9e3e1c 100644
--- a/apps/web/modules/organization/settings/teams/components/members-view.tsx
+++ b/apps/web/modules/organization/settings/teams/components/members-view.tsx
@@ -5,6 +5,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
+import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { EditMemberships } from "@/modules/organization/settings/teams/components/edit-memberships";
@@ -45,6 +46,10 @@ export const MembersView = async ({
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
+ // Fetch admin teams if they're a team admin
+ const userAdminTeamIds = await getTeamsWhereUserIsAdmin(currentUserId, organization.id);
+ const isTeamAdminUser = userAdminTeamIds.length > 0;
+
let teams: TOrganizationTeam[] = [];
if (isAccessControlAllowed) {
@@ -69,6 +74,8 @@ export const MembersView = async ({
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
+ isTeamAdmin={isTeamAdminUser}
+ userAdminTeamIds={userAdminTeamIds}
/>
)}
diff --git a/apps/web/modules/organization/settings/teams/page.tsx b/apps/web/modules/organization/settings/teams/page.tsx
index 5bc6b6dee4..520767faae 100644
--- a/apps/web/modules/organization/settings/teams/page.tsx
+++ b/apps/web/modules/organization/settings/teams/page.tsx
@@ -3,6 +3,7 @@ import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constan
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
+import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
@@ -16,11 +17,21 @@ export const TeamsPage = async (props: { params: Promise<{ environmentId: string
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
- const hasUserManagementAccess = getUserManagementAccess(
+
+ // Check if user has standard user management access (owner/manager)
+ const hasStandardUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
+ // Also check if user is a team admin (they get limited user management for invites)
+ const userAdminTeamIds = await getTeamsWhereUserIsAdmin(session.user.id, organization.id);
+ const isTeamAdminUser = userAdminTeamIds.length > 0;
+
+ // Allow user management UI if they're owner/manager OR team admin (when access control is enabled)
+ const hasUserManagementAccess =
+ hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
+
return (
diff --git a/apps/web/modules/projects/settings/look/components/theme-styling.tsx b/apps/web/modules/projects/settings/look/components/theme-styling.tsx
index 92fac8e706..ab5a5cd66d 100644
--- a/apps/web/modules/projects/settings/look/components/theme-styling.tsx
+++ b/apps/web/modules/projects/settings/look/components/theme-styling.tsx
@@ -38,6 +38,7 @@ interface ThemeStylingProps {
isUnsplashConfigured: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
+ publicDomain: string;
}
export const ThemeStyling = ({
@@ -47,6 +48,7 @@ export const ThemeStyling = ({
isUnsplashConfigured,
isReadOnly,
isStorageConfigured = true,
+ publicDomain,
}: ThemeStylingProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -199,6 +201,7 @@ export const ThemeStyling = ({
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
+ publicDomain={publicDomain}
/>
diff --git a/apps/web/modules/projects/settings/look/page.tsx b/apps/web/modules/projects/settings/look/page.tsx
index 0824f7ae1e..fec06b55c0 100644
--- a/apps/web/modules/projects/settings/look/page.tsx
+++ b/apps/web/modules/projects/settings/look/page.tsx
@@ -1,6 +1,7 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
+import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
@@ -27,6 +28,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
+ const publicDomain = getPublicDomain();
return (
@@ -49,6 +51,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
+ publicDomain={publicDomain}
/>
{
+ // Cast to TSurvey - ZSurveyDraft validates structure, full validation happens on publish
+ const survey = parsedInput as TSurvey;
+
+ const organizationId = await getOrganizationIdFromSurveyId(survey.id);
+ await checkAuthorizationUpdated({
+ userId: ctx.user.id,
+ organizationId,
+ access: [
+ {
+ type: "organization",
+ roles: ["owner", "manager"],
+ },
+ {
+ type: "projectTeam",
+ projectId: await getProjectIdFromSurveyId(survey.id),
+ minPermission: "readWrite",
+ },
+ ],
+ });
+
+ if (survey.recaptcha?.enabled) {
+ await checkSpamProtectionPermission(organizationId);
+ }
+
+ if (survey.followUps?.length) {
+ await checkSurveyFollowUpsPermission(organizationId);
+ }
+
+ if (survey.languages?.length) {
+ await checkMultiLanguagePermission(organizationId);
+ }
+
+ ctx.auditLoggingCtx.organizationId = organizationId;
+ ctx.auditLoggingCtx.surveyId = survey.id;
+ const oldObject = await getSurvey(survey.id);
+
+ await checkExternalUrlsPermission(organizationId, survey, oldObject);
+
+ // Use the draft version that skips validation
+ const result = await updateSurveyDraft(survey);
+
+ ctx.auditLoggingCtx.oldObject = oldObject;
+ ctx.auditLoggingCtx.newObject = result;
+
+ revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
+
+ return result;
+ }
+ )
+);
+
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
diff --git a/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx b/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx
new file mode 100644
index 0000000000..1625167910
--- /dev/null
+++ b/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { createId } from "@paralleldrive/cuid2";
+import { type JSX, useEffect, useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
+import { TI18nString } from "@formbricks/types/i18n";
+import { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
+import { TSurvey } from "@formbricks/types/surveys/types";
+import { TUserLocale } from "@formbricks/types/user";
+import { createI18nString } from "@/lib/i18n/utils";
+import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
+import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
+import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
+import { Button } from "@/modules/ui/components/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
+
+interface BulkEditOptionsModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ regularChoices: TSurveyMultipleChoiceElement["choices"];
+ onSave: (updatedChoices: TSurveyMultipleChoiceElement["choices"]) => void;
+ element: TSurveyMultipleChoiceElement;
+ localSurvey: TSurvey;
+ selectedLanguageCode: string;
+ surveyLanguageCodes: string[];
+ locale: TUserLocale;
+}
+
+const parseUniqueLines = (content: string): string[] => {
+ return [
+ ...new Set(
+ content
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ ),
+ ];
+};
+
+const updateChoiceLabel = (
+ choice: TSurveyMultipleChoiceElement["choices"][number],
+ newLabel: string,
+ selectedLangCode: string,
+ allLangCodes: string[]
+): TSurveyMultipleChoiceElement["choices"][number] => {
+ const label = Object.fromEntries([
+ ...allLangCodes.map((code) => [code, choice.label[code] ?? ""]),
+ [selectedLangCode, newLabel],
+ ]) as TI18nString;
+
+ return { ...choice, label };
+};
+
+export const BulkEditOptionsModal = ({
+ isOpen,
+ onClose,
+ regularChoices,
+ onSave,
+ element,
+ localSurvey,
+ selectedLanguageCode,
+ surveyLanguageCodes,
+ locale,
+}: BulkEditOptionsModalProps): JSX.Element => {
+ const { t } = useTranslation();
+ const [textareaValue, setTextareaValue] = useState("");
+ const [validationError, setValidationError] = useState(null);
+
+ const selectedLanguageName = useMemo(() => {
+ if (localSurvey.languages.length <= 1) return null;
+ const code =
+ selectedLanguageCode === "default"
+ ? localSurvey.languages.find((lang) => lang.default)?.language.code
+ : selectedLanguageCode;
+ return code ? getLanguageLabel(code, locale) : null;
+ }, [localSurvey.languages, selectedLanguageCode, locale]);
+
+ useEffect(() => {
+ if (isOpen) {
+ setTextareaValue(regularChoices.map((c) => c.label[selectedLanguageCode] || "").join("\n"));
+ setValidationError(null);
+ }
+ }, [isOpen, regularChoices, selectedLanguageCode]);
+
+ const validateRemovedOptions = (newLabels: string[]): string | null => {
+ const originalLabels = regularChoices.map((c) => c.label[selectedLanguageCode] || "");
+ const missingLabels = originalLabels.filter((label) => label && !newLabels.includes(label));
+
+ if (missingLabels.length === 0) return null;
+
+ // Find which choices have missing labels and check if they're used in logic
+ const choicesWithMissingLabels = missingLabels
+ .map((label) => regularChoices.find((c) => c.label[selectedLanguageCode] === label))
+ .filter((c): c is TSurveyMultipleChoiceElement["choices"][number] => c !== undefined);
+
+ // Get all elements to find which block has the logic
+ const allElements = getElementsFromBlocks(localSurvey.blocks);
+
+ // Build detailed error info: option label -> block name where it's used
+ const problematicOptions: { optionLabel: string; blockName: string }[] = [];
+
+ for (const choice of choicesWithMissingLabels) {
+ const elementIndex = findOptionUsedInLogic(localSurvey, element.id, choice.id);
+ if (elementIndex !== -1) {
+ const elementWithLogic = allElements[elementIndex];
+ // Find which block contains this element
+ const { block } = findElementLocation(localSurvey, elementWithLogic.id);
+ if (block) {
+ const optionLabel = choice.label[selectedLanguageCode] || "";
+ problematicOptions.push({ optionLabel, blockName: block.name });
+ }
+ }
+ }
+
+ if (problematicOptions.length === 0) return null;
+
+ // Format: "Option '3' is used in logic at 'Block Name'"
+ const details = problematicOptions.map((opt) => `"${opt.optionLabel}" → ${opt.blockName}`).join(", ");
+
+ return t("environments.surveys.edit.options_used_in_logic_bulk_error", {
+ questionIndexes: details,
+ });
+ };
+
+ const handleSave = () => {
+ const newLabels = parseUniqueLines(textareaValue);
+ const error = validateRemovedOptions(newLabels);
+
+ if (error) {
+ setValidationError(error);
+ return;
+ }
+
+ const updatedChoices = newLabels.map((label, idx) =>
+ idx < regularChoices.length
+ ? updateChoiceLabel(regularChoices[idx], label, selectedLanguageCode, surveyLanguageCodes)
+ : { id: createId(), label: createI18nString(label, surveyLanguageCodes) }
+ );
+
+ onSave(updatedChoices);
+ onClose();
+ toast.success(t("environments.surveys.edit.changes_saved"));
+ };
+
+ return (
+
+
+
+
+ {selectedLanguageName
+ ? t("environments.surveys.edit.bulk_edit_options_for", { language: selectedLanguageName })
+ : t("environments.surveys.edit.bulk_edit_options")}
+
+ {t("environments.surveys.edit.bulk_edit_description")}
+
+
+
+
+
+
+ {t("common.cancel")}
+
+ {t("environments.surveys.edit.update_options")}
+
+
+
+ );
+};
diff --git a/apps/web/modules/survey/editor/components/edit-welcome-card.tsx b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx
index 9d772ae1d1..7fc26f99cb 100644
--- a/apps/web/modules/survey/editor/components/edit-welcome-card.tsx
+++ b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx
@@ -22,6 +22,7 @@ interface EditWelcomeCardProps {
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
+ isExternalUrlsAllowed?: boolean;
}
export const EditWelcomeCard = ({
@@ -34,6 +35,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
+ isExternalUrlsAllowed,
}: EditWelcomeCardProps) => {
const { t } = useTranslation();
@@ -135,6 +137,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
@@ -150,6 +153,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
@@ -170,6 +174,7 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
diff --git a/apps/web/modules/survey/editor/components/elements-view.tsx b/apps/web/modules/survey/editor/components/elements-view.tsx
index 13f346ef2b..fced0901ce 100644
--- a/apps/web/modules/survey/editor/components/elements-view.tsx
+++ b/apps/web/modules/survey/editor/components/elements-view.tsx
@@ -808,6 +808,7 @@ export const ElementsView = ({
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
)}
diff --git a/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx b/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx
index b11fe0d839..671ca0e3b3 100644
--- a/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx
+++ b/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx
@@ -8,12 +8,14 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
+import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
+import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -49,6 +51,7 @@ export const MultipleChoiceElementForm = ({
const lastChoiceRef = useRef(null);
const [isNew, setIsNew] = useState(true);
const [isInvalidValue, setisInvalidValue] = useState(null);
+ const [isBulkEditOpen, setIsBulkEditOpen] = useState(false);
const elementRef = useRef(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -90,11 +93,31 @@ export const MultipleChoiceElementForm = ({
[element.choices]
);
+ // Get the display name for the selected language (for multi-language surveys)
+ const bulkEditButtonLabel = useMemo(() => {
+ if (localSurvey.languages.length <= 1) {
+ return t("environments.surveys.edit.bulk_edit");
+ }
+
+ const languageCode =
+ selectedLanguageCode === "default"
+ ? localSurvey.languages.find((lang) => lang.default)?.language.code
+ : selectedLanguageCode;
+
+ const languageName = languageCode ? getLanguageLabel(languageCode, locale) : "";
+ return `${t("environments.surveys.edit.bulk_edit")} (${languageName})`;
+ }, [localSurvey.languages, selectedLanguageCode, locale, t]);
+
const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceElement["choices"]) => {
+ const regularChoicesFromInput = choices.filter((c) => c.id !== "other" && c.id !== "none");
const otherChoice = choices.find((c) => c.id === "other");
const noneChoice = choices.find((c) => c.id === "none");
// [regularChoices, otherChoice, noneChoice]
- return [...regularChoices, ...(otherChoice ? [otherChoice] : []), ...(noneChoice ? [noneChoice] : [])];
+ return [
+ ...regularChoicesFromInput,
+ ...(otherChoice ? [otherChoice] : []),
+ ...(noneChoice ? [noneChoice] : []),
+ ];
};
const addChoice = (choiceIdx?: number) => {
@@ -283,7 +306,7 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, { choices: newChoices });
}}>
-
+
{element.choices?.map((choice, choiceIdx) => (
+
+
+
{specialChoices.map((specialChoice) => {
@@ -323,6 +349,9 @@ export const MultipleChoiceElementForm = ({
);
})}
+ setIsBulkEditOpen(true)}>
+ {bulkEditButtonLabel}
+
+
setIsBulkEditOpen(false)}
+ regularChoices={regularChoices}
+ onSave={(updatedChoices) => {
+ const newChoices = ensureSpecialChoicesOrder([
+ ...updatedChoices,
+ ...element.choices.filter((c) => c.id === "other" || c.id === "none"),
+ ]);
+ updateElement(elementIdx, { choices: newChoices });
+ }}
+ element={element}
+ localSurvey={localSurvey}
+ selectedLanguageCode={selectedLanguageCode}
+ surveyLanguageCodes={surveyLanguageCodes}
+ locale={locale}
+ />
);
};
diff --git a/apps/web/modules/survey/editor/components/survey-editor.tsx b/apps/web/modules/survey/editor/components/survey-editor.tsx
index 0d96f8611c..249b4e74ce 100644
--- a/apps/web/modules/survey/editor/components/survey-editor.tsx
+++ b/apps/web/modules/survey/editor/components/survey-editor.tsx
@@ -50,6 +50,7 @@ interface SurveyEditorProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
+ publicDomain: string;
}
export const SurveyEditor = ({
@@ -79,6 +80,7 @@ export const SurveyEditor = ({
isStorageConfigured,
quotas,
isExternalUrlsAllowed,
+ publicDomain,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState("elements");
const [activeElementId, setActiveElementId] = useState(null);
@@ -272,6 +274,7 @@ export const SurveyEditor = ({
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
isSpamProtectionAllowed={isSpamProtectionAllowed}
+ publicDomain={publicDomain}
/>
diff --git a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
index be911ea18d..02caec3a75 100644
--- a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
+++ b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
@@ -19,11 +19,12 @@ import {
} from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
+import { TSurveyDraft } from "@/modules/survey/editor/types/survey";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
-import { updateSurveyAction } from "../actions";
+import { updateSurveyAction, updateSurveyDraftAction } from "../actions";
import { isSurveyValid } from "../lib/validation";
interface SurveyMenuBarProps {
@@ -227,6 +228,38 @@ export const SurveyMenuBar = ({
return true;
};
+ // Add new handler after handleSurveySave
+ const handleSurveySaveDraft = async (): Promise => {
+ setIsSurveySaving(true);
+
+ try {
+ const segment = await handleSegmentUpdate();
+ clearSurveyLocalStorage();
+ const updatedSurveyResponse = await updateSurveyDraftAction({
+ ...localSurvey,
+ segment,
+ } as unknown as TSurveyDraft);
+
+ setIsSurveySaving(false);
+ if (updatedSurveyResponse?.data) {
+ setLocalSurvey(updatedSurveyResponse.data);
+ toast.success(t("environments.surveys.edit.changes_saved"));
+ isSuccessfullySavedRef.current = true;
+ router.refresh();
+ } else {
+ const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
+ toast.error(errorMessage);
+ return false;
+ }
+ return true;
+ } catch (e) {
+ console.error(e);
+ setIsSurveySaving(false);
+ toast.error(t("environments.surveys.edit.error_saving_changes"));
+ return false;
+ }
+ };
+
const handleSurveySave = async (): Promise => {
setIsSurveySaving(true);
@@ -398,12 +431,11 @@ export const SurveyMenuBar = ({
variant="secondary"
size="sm"
loading={isSurveySaving}
- onClick={() => handleSurveySave()}
+ onClick={() => (localSurvey.status === "draft" ? handleSurveySaveDraft() : handleSurveySave())}
type="submit">
- {t("common.save")}
+ {localSurvey.status === "draft" ? t("common.save_as_draft") : t("common.save")}
)}
-
{localSurvey.status !== "draft" && (
({
@@ -26,6 +27,10 @@ vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
+vi.mock("@/lib/survey/service", () => ({
+ updateSurveyInternal: vi.fn(),
+}));
+
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
@@ -692,4 +697,89 @@ describe("Survey Editor Library Tests", () => {
).toThrow(InvalidInputError);
});
});
+
+ describe("updateSurveyDraft", () => {
+ const mockSurvey = {
+ id: "survey123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Draft Survey",
+ type: "app",
+ environmentId: "env123",
+ createdBy: "user123",
+ status: "draft",
+ displayOption: "displayOnce",
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: { default: "Question 1" },
+ required: false,
+ inputType: "text",
+ charLimit: { enabled: false },
+ },
+ ],
+ welcomeCard: {
+ enabled: false,
+ timeToFinish: true,
+ showResponseCount: false,
+ },
+ triggers: [],
+ endings: [],
+ hiddenFields: { enabled: false },
+ delay: 0,
+ autoComplete: null,
+ projectOverwrites: null,
+ styling: null,
+ showLanguageSwitch: false,
+ segment: null,
+ surveyClosedMessage: null,
+ singleUse: null,
+ isVerifyEmailEnabled: false,
+ recaptcha: null,
+ isSingleResponsePerEmailEnabled: false,
+ isBackButtonHidden: false,
+ pin: null,
+ displayPercentage: null,
+ languages: [],
+ variables: [],
+ followUps: [],
+ } as unknown as TSurvey;
+
+ beforeEach(() => {
+ vi.mocked(updateSurveyInternal).mockResolvedValue(mockSurvey);
+ });
+
+ test("should call updateSurveyInternal with skipValidation=true", async () => {
+ await updateSurveyDraft(mockSurvey);
+
+ expect(updateSurveyInternal).toHaveBeenCalledWith(mockSurvey, true);
+ expect(updateSurveyInternal).toHaveBeenCalledTimes(1);
+ });
+
+ test("should return the survey from updateSurveyInternal", async () => {
+ const result = await updateSurveyDraft(mockSurvey);
+
+ expect(result).toEqual(mockSurvey);
+ });
+
+ test("should propagate errors from updateSurveyInternal", async () => {
+ const error = new Error("Internal update failed");
+ vi.mocked(updateSurveyInternal).mockRejectedValueOnce(error);
+
+ await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow("Internal update failed");
+ });
+
+ test("should propagate ResourceNotFoundError from updateSurveyInternal", async () => {
+ vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey123"));
+
+ await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("should propagate DatabaseError from updateSurveyInternal", async () => {
+ vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new DatabaseError("Database connection failed"));
+
+ await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(DatabaseError);
+ });
+ });
});
diff --git a/apps/web/modules/survey/editor/lib/survey.ts b/apps/web/modules/survey/editor/lib/survey.ts
index f5d516f99d..73f9addd37 100644
--- a/apps/web/modules/survey/editor/lib/survey.ts
+++ b/apps/web/modules/survey/editor/lib/survey.ts
@@ -4,12 +4,18 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
+import { updateSurveyInternal } from "@/lib/survey/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
+export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise => {
+ // Use internal version with skipValidation=true to allow incomplete drafts
+ return updateSurveyInternal(updatedSurvey, true);
+};
+
export const updateSurvey = async (updatedSurvey: TSurvey): Promise => {
try {
const surveyId = updatedSurvey.id;
diff --git a/apps/web/modules/survey/editor/page.tsx b/apps/web/modules/survey/editor/page.tsx
index b37dcd3bba..eeaf5e3d36 100644
--- a/apps/web/modules/survey/editor/page.tsx
+++ b/apps/web/modules/survey/editor/page.tsx
@@ -6,6 +6,7 @@ import {
SURVEY_BG_COLORS,
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
+import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -105,6 +106,7 @@ export const SurveyEditorPage = async (props) => {
}
const isCxMode = searchParams.mode === "cx";
+ const publicDomain = getPublicDomain();
return (
{
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
+ publicDomain={publicDomain}
/>
);
};
diff --git a/apps/web/modules/survey/editor/types/survey-follow-up.ts b/apps/web/modules/survey/editor/types/survey-follow-up.ts
index 3609e0add7..35d40a79b5 100644
--- a/apps/web/modules/survey/editor/types/survey-follow-up.ts
+++ b/apps/web/modules/survey/editor/types/survey-follow-up.ts
@@ -9,6 +9,8 @@ export const ZCreateSurveyFollowUpFormSchema = z.object({
subject: z.string().trim().min(1, "Subject is required"),
body: z.string().trim().min(1, "Body is required"),
attachResponseData: z.boolean(),
+ includeVariables: z.boolean(),
+ includeHiddenFields: z.boolean(),
});
export type TCreateSurveyFollowUpForm = z.infer;
diff --git a/apps/web/modules/survey/editor/types/survey.ts b/apps/web/modules/survey/editor/types/survey.ts
new file mode 100644
index 0000000000..e270faf832
--- /dev/null
+++ b/apps/web/modules/survey/editor/types/survey.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+import { ZId } from "@formbricks/types/common";
+import { ZSurveyType } from "@formbricks/types/surveys/types";
+
+/**
+ * Lenient schema for draft survey updates.
+ * Validates essential fields for security/functionality but allows incomplete survey data.
+ * Full validation (ZSurvey) is enforced when publishing.
+ */
+export const ZSurveyDraft = z
+ .object({
+ // Essential fields - strictly validated
+ id: ZId,
+ status: z.literal("draft"),
+ environmentId: ZId,
+ type: ZSurveyType,
+ name: z.string().min(1, "Survey name is required"),
+
+ // Required fields for database operations - loosely validated
+ blocks: z.array(z.record(z.unknown())).optional(),
+ triggers: z.array(z.record(z.unknown())).optional(),
+ endings: z.array(z.record(z.unknown())).optional(),
+ segment: z.record(z.unknown()).nullable().optional(),
+ })
+ .passthrough(); // Allow all other fields without validation
+
+export type TSurveyDraft = z.infer;
diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
index faebbce549..e3aecc0795 100644
--- a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
+++ b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
@@ -1,34 +1,21 @@
-import {
- Body,
- Column,
- Container,
- Hr,
- Html,
- Img,
- Link,
- Row,
- Section,
- Tailwind,
- Text,
-} from "@react-email/components";
+import { Column, Hr, Row, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
-import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
+import { EmailTemplate } from "@/modules/email/components/email-template";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
-const fbLogoUrl = FB_LOGO_URL;
-const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
-
interface FollowUpEmailProps {
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
+ readonly includeVariables: boolean;
+ readonly includeHiddenFields: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
@@ -42,91 +29,92 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise
-
-
-
- {isDefaultLogo ? (
-
-
-
- ) : (
-
- )}
-
-
-
+
+ <>
+
- {elements.length > 0 ? : null}
+ {elements.length > 0 ? (
+ <>
+
+ {t("emails.response_data")}
+ >
+ ) : null}
- {elements.map((e) => {
- if (!e.response) return;
+ {elements.map((e) => {
+ if (!e.response) return;
+ return (
+
+
+ {e.element}
+ {renderEmailResponseValue(e.response, e.type, t, true)}
+
+
+ );
+ })}
+
+ {props.attachResponseData &&
+ props.includeVariables &&
+ props.survey.variables
+ .filter((variable) => {
+ const variableResponse = props.response.variables[variable.id];
+ if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
+ return false;
+ }
+
+ return variableResponse !== undefined;
+ })
+ .map((variable) => {
+ const variableResponse = props.response.variables[variable.id];
return (
-
-
- {e.element}
- {renderEmailResponseValue(e.response, e.type, t, true)}
+
+
+
+ {variable.type === "number"
+ ? `${t("emails.number_variable")}: ${variable.name}`
+ : `${t("emails.text_variable")}: ${variable.name}`}
+
+
+ {variableResponse}
+
);
})}
-
- {/* If the logo is not set, we are not using white labeling */}
- {isDefaultLogo ? (
-
-
- {t("emails.email_template_text_1")}
-
- {IMPRINT_ADDRESS && (
- {IMPRINT_ADDRESS}
- )}
-
- {IMPRINT_URL && (
-
- {t("emails.imprint")}
-
- )}
- {IMPRINT_URL && PRIVACY_URL && " • "}
- {PRIVACY_URL && (
-
- {t("emails.privacy_policy")}
-
- )}
-
-
- ) : null}
-
-
-