mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
3 Commits
feat/datab
...
docs/custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f633e5130 | ||
|
|
e595b724a6 | ||
|
|
cfde804eb0 |
@@ -1,352 +0,0 @@
|
||||
# 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 (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
/>
|
||||
|
||||
{/* Question-specific controls */}
|
||||
{/* TODO: Add your question-specific UI here */}
|
||||
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<StylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
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<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
const withCSSVariables: Decorator<StoryProps> = (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<string, string | undefined> = {
|
||||
"--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 (
|
||||
<div style={cssVarStyle} className="w-[600px]">
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
@@ -9,12 +9,8 @@
|
||||
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
|
||||
|
||||
@@ -193,9 +189,8 @@ 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:
|
||||
|
||||
# Chatwoot
|
||||
# CHATWOOT_BASE_URL=
|
||||
# CHATWOOT_WEBSITE_TOKEN=
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
# Enable Prometheus metrics
|
||||
# PROMETHEUS_ENABLED=
|
||||
|
||||
45
.github/workflows/e2e.yml
vendored
45
.github/workflows/e2e.yml
vendored
@@ -3,9 +3,13 @@ name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
AZURE_CLIENT_ID:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
|
||||
AZURE_TENANT_ID:
|
||||
required: false
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
@@ -17,6 +21,7 @@ env:
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
@@ -109,7 +114,7 @@ jobs:
|
||||
- name: Start MinIO Server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Start MinIO server in background
|
||||
docker run -d \
|
||||
--name minio-server \
|
||||
@@ -119,7 +124,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
|
||||
@@ -202,30 +207,32 @@ jobs:
|
||||
- name: Install Playwright
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Determine Playwright execution mode
|
||||
shell: bash
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||
- name: Set Azure Secret Variables
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
|
||||
echo "PW_MODE=service" >> "$GITHUB_ENV"
|
||||
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 "PW_MODE=local" >> "$GITHUB_ENV"
|
||||
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Run E2E Tests (Playwright Service)
|
||||
if: env.PW_MODE == 'service'
|
||||
- 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'
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||
CI: true
|
||||
run: pnpm test-e2e:azure
|
||||
run: |
|
||||
pnpm test-e2e:azure
|
||||
|
||||
- name: Run E2E Tests (Local)
|
||||
if: env.PW_MODE == 'local'
|
||||
if: env.AZURE_ENABLED == 'false'
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
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.
|
||||
@@ -16,7 +13,7 @@ function getAbsolutePath(value: string): any {
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
@@ -28,25 +25,5 @@ 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;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import "../../../packages/survey-ui/src/styles/globals.css";
|
||||
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)
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@@ -9,23 +22,9 @@ const preview: Preview = {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
expanded: true,
|
||||
},
|
||||
backgrounds: {
|
||||
default: "light",
|
||||
},
|
||||
},
|
||||
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)
|
||||
),
|
||||
],
|
||||
decorators: [withLingodotDev],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -11,24 +11,22 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/survey-ui": "workspace:*",
|
||||
"eslint-plugin-react-refresh": "0.4.24"
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.0.8",
|
||||
"vite": "7.2.4",
|
||||
"@storybook/addon-docs": "10.0.8"
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.4.1",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/storybook/postcss.config.js
Normal file
6
apps/storybook/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
||||
import base from "../web/tailwind.config";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
...surveyUi.theme?.extend,
|
||||
},
|
||||
},
|
||||
...base,
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
|
||||
};
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
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(), tailwindcss()],
|
||||
plugins: [react()],
|
||||
define: {
|
||||
"process.env": {},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
|
||||
"@": path.resolve(__dirname, "../web"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,10 +37,6 @@ 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
|
||||
|
||||
@@ -77,8 +73,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||
corepack enable
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
@@ -138,13 +134,12 @@ EXPOSE 3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# 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 uploads
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
# Prepare volume for SAML preloaded connection
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -44,7 +44,6 @@ interface ProjectSettingsProps {
|
||||
organizationTeams: TOrganizationTeam[];
|
||||
isAccessControlAllowed: boolean;
|
||||
userProjectsCount: number;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ProjectSettings = ({
|
||||
@@ -56,7 +55,6 @@ export const ProjectSettings = ({
|
||||
organizationTeams,
|
||||
isAccessControlAllowed = false,
|
||||
userProjectsCount,
|
||||
publicDomain,
|
||||
}: ProjectSettingsProps) => {
|
||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
|
||||
@@ -233,7 +231,6 @@ export const ProjectSettings = ({
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
<div className="z-0 h-3/4 w-3/4">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || "my Product", t)}
|
||||
styling={{ brandColor: { light: brandColor } }}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -48,8 +47,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
@@ -65,7 +62,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
organizationTeams={organizationTeams}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
userProjectsCount={projects.length}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -16,7 +15,6 @@ interface EnvironmentLayoutProps {
|
||||
|
||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||
const t = await getTranslate();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
// Destructure all data from props (NO database queries)
|
||||
const {
|
||||
@@ -74,7 +72,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -46,7 +46,6 @@ interface NavigationProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
@@ -57,7 +56,6 @@ export const MainNavigation = ({
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
publicDomain,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -288,16 +286,15 @@ export const MainNavigation = ({
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: loginUrl,
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: loginUrl,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
label: t("common.teams"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
label: t("common.teams"),
|
||||
href: `/environments/${environmentId}/settings/teams`,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
|
||||
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
@@ -19,15 +18,7 @@ const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
{IS_CHATWOOT_CONFIGURED && (
|
||||
<ChatwootWidget
|
||||
userEmail={user?.email}
|
||||
userName={user?.name}
|
||||
userId={user?.id}
|
||||
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
|
||||
chatwootBaseUrl={CHATWOOT_BASE_URL}
|
||||
/>
|
||||
)}
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
<IntercomClientWrapper />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IntegrationType } from "@prisma/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { type CacheKey, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { env } from "@/lib/env";
|
||||
import { getInstanceInfo } from "@/lib/instance";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -129,12 +129,15 @@ export const sendTelemetryEvents = async () => {
|
||||
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
|
||||
*/
|
||||
const sendTelemetry = async (lastSent: number) => {
|
||||
// Get the instance info (hashed oldest organization ID and creation date).
|
||||
// Get the oldest organization to generate a stable, anonymized instance ID.
|
||||
// Using the oldest org ensures the ID doesn't change over time.
|
||||
const instanceInfo = await getInstanceInfo();
|
||||
if (!instanceInfo) return; // No organization exists, nothing to report
|
||||
const oldestOrg = await prisma.organization.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
const { instanceId, createdAt: instanceCreatedAt } = instanceInfo;
|
||||
if (!oldestOrg) return; // No organization exists, nothing to report
|
||||
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
|
||||
|
||||
// Optimize database queries to reduce connection pool usage:
|
||||
// Instead of 15 parallel queries (which could exhaust the connection pool),
|
||||
@@ -245,7 +248,7 @@ const sendTelemetry = async (lastSent: number) => {
|
||||
version: packageJson.version, // Formbricks version for compatibility tracking
|
||||
},
|
||||
temporal: {
|
||||
instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created
|
||||
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
|
||||
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,22 +51,6 @@ export const POST = async (request: Request) => {
|
||||
throw new ResourceNotFoundError("Organization", "Organization not found");
|
||||
}
|
||||
|
||||
// Fetch survey for webhook payload
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
|
||||
|
||||
return responses.notFoundResponse("Survey", surveyId, true);
|
||||
}
|
||||
|
||||
if (survey.environmentId !== environmentId) {
|
||||
logger.error(
|
||||
{ url: request.url, surveyId, environmentId, surveyEnvironmentId: survey.environmentId },
|
||||
`Survey ${surveyId} does not belong to environment ${environmentId}`
|
||||
);
|
||||
return responses.badRequestResponse("Survey not found in this environment");
|
||||
}
|
||||
|
||||
// Fetch webhooks
|
||||
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
@@ -97,16 +81,7 @@ export const POST = async (request: Request) => {
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
},
|
||||
data: response,
|
||||
}),
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
@@ -114,12 +89,18 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
const [integrations, responseCount] = await Promise.all([
|
||||
// Fetch integrations, survey, and responseCount in parallel
|
||||
const [integrations, survey, responseCount] = await Promise.all([
|
||||
getIntegrations(environmentId),
|
||||
getSurvey(surveyId),
|
||||
getResponseCountBySurveyId(surveyId),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
|
||||
return new Response("Survey not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (integrations.length > 0) {
|
||||
await handleIntegrations(integrations, inputValidation.data, survey);
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"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;
|
||||
};
|
||||
67
apps/web/app/intercom/IntercomClient.tsx
Normal file
67
apps/web/app/intercom/IntercomClient.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"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;
|
||||
};
|
||||
26
apps/web/app/intercom/IntercomClientWrapper.tsx
Normal file
26
apps/web/app/intercom/IntercomClientWrapper.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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 (
|
||||
<IntercomClient
|
||||
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
|
||||
user={user}
|
||||
intercomAppId={INTERCOM_APP_ID}
|
||||
intercomUserHash={intercomUserHash}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -17,8 +17,7 @@
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"sv-SE"
|
||||
"es-ES"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
@@ -234,7 +234,6 @@ 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
|
||||
@@ -311,10 +310,9 @@ checksums:
|
||||
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
|
||||
common/quotas: e6afead11b5b8ae627885ce2b84a548f
|
||||
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
|
||||
common/read_docs: d06513c266fdd9056e0500eab838ebac
|
||||
common/read_docs: 426ba960bfedf186a878b7467867f9d2
|
||||
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
|
||||
@@ -324,10 +322,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
|
||||
@@ -382,8 +380,7 @@ checksums:
|
||||
common/team_access: 45c6232c71b760eaa33b932dabab4c1c
|
||||
common/team_id: 134e32d6f7184577a46b2fd83e85e532
|
||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||
common/teams: b63448c05270497973ac4407047dae02
|
||||
common/teams: a2fbdec69342366a2b6033d119aa279a
|
||||
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
|
||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||
@@ -398,7 +395,6 @@ checksums:
|
||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||
common/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
|
||||
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
|
||||
common/url: ca97457614226960d41dd18c3c29c86b
|
||||
common/user: 61073457a5c3901084b557d065f876be
|
||||
@@ -444,7 +440,6 @@ 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,14 +451,12 @@ 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
|
||||
@@ -475,7 +468,6 @@ 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
|
||||
@@ -847,6 +839,7 @@ 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
|
||||
@@ -1092,17 +1085,13 @@ 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
|
||||
@@ -1181,15 +1170,12 @@ checksums:
|
||||
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
|
||||
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
|
||||
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
|
||||
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
|
||||
environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731
|
||||
environments/surveys/edit/block_deleted: c682259eb138ad84f8b4441abfd9b572
|
||||
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
|
||||
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
|
||||
@@ -1201,7 +1187,7 @@ checksums:
|
||||
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
||||
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
||||
environments/surveys/edit/card_border_color: 8d7c7f4cbd99f154ce892dfa258eb504
|
||||
environments/surveys/edit/card_styling: 47137a7e809b060ca94418202a8fd3c5
|
||||
environments/surveys/edit/card_styling: 01e88d58219539fb831e79f0bb3ce88e
|
||||
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
|
||||
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
|
||||
environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486
|
||||
@@ -1257,7 +1243,6 @@ checksums:
|
||||
environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe
|
||||
environments/surveys/edit/cta_button_label: ec070ffba38eae24751bb3a4c1e14c81
|
||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
|
||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
|
||||
@@ -1309,13 +1294,11 @@ 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: 901a493d60331420da61d0e76bf07eae
|
||||
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: d23abb5a7e610b1ec3273c60d36a81e7
|
||||
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
|
||||
@@ -1360,9 +1343,9 @@ checksums:
|
||||
environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
|
||||
environments/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
|
||||
environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8
|
||||
environments/surveys/edit/hide_logo_from_survey: 9d44321539cc2b397376a35bb8b3d1cd
|
||||
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
|
||||
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
|
||||
environments/surveys/edit/hide_the_logo_in_this_specific_survey: 29d4c6c714886e57bc29ad292d0f5a00
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
@@ -1409,7 +1392,6 @@ checksums:
|
||||
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
|
||||
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
|
||||
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
|
||||
environments/surveys/edit/logo_settings: 9f54ca6684e989cc38bf177425b6d366
|
||||
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
|
||||
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
|
||||
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
|
||||
@@ -1438,12 +1420,10 @@ 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
|
||||
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
|
||||
environments/surveys/edit/overwrite_survey_logo: a89cb566dfcc1559446abd8b830c84ed
|
||||
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
|
||||
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
|
||||
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
||||
@@ -1586,7 +1566,6 @@ 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
|
||||
@@ -1910,9 +1889,9 @@ checksums:
|
||||
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
|
||||
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
|
||||
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
|
||||
setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
|
||||
setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
|
||||
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
|
||||
setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
|
||||
setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
|
||||
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
|
||||
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
|
||||
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
|
||||
@@ -176,7 +176,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
@@ -215,9 +214,9 @@ export const BILLING_LIMITS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
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 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 TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||
|
||||
@@ -39,12 +39,11 @@ export const env = createEnv({
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
IMPRINT_ADDRESS: z.string().optional(),
|
||||
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
|
||||
CHATWOOT_BASE_URL: z.string().url().optional(),
|
||||
INTERCOM_SECRET_KEY: z.string().optional(),
|
||||
INTERCOM_APP_ID: z.string().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(),
|
||||
@@ -163,16 +162,15 @@ export const env = createEnv({
|
||||
IMPRINT_URL: process.env.IMPRINT_URL,
|
||||
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
|
||||
INVITE_DISABLED: process.env.INVITE_DISABLED,
|
||||
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
|
||||
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
|
||||
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
|
||||
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,
|
||||
|
||||
@@ -140,7 +140,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
"sv-SE": "Engelska (USA)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -157,7 +156,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
"sv-SE": "Tyska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -174,7 +172,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
"sv-SE": "Portugisiska (Brasilien)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -191,7 +188,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
"sv-SE": "Franska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -203,12 +199,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Chinois (Traditionnel)",
|
||||
"zh-Hant-TW": "繁體中文",
|
||||
"pt-PT": "Chinês (Tradicional)",
|
||||
"ro-RO": "Chineza (Tradițională)",
|
||||
"ro-RO": "Chineză (Tradicională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
"sv-SE": "Kinesiska (traditionell)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -225,7 +220,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
"sv-SE": "Portugisiska (Portugal)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,7 +236,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
"sv-SE": "Rumänska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -259,7 +252,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
"sv-SE": "Japanska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -271,12 +263,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Chinois (Simplifié)",
|
||||
"zh-Hant-TW": "簡體中文",
|
||||
"pt-PT": "Chinês (Simplificado)",
|
||||
"ro-RO": "Chineza (Simplificată)",
|
||||
"ro-RO": "Chineză (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
"sv-SE": "Kinesiska (förenklad)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,12 +279,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeza",
|
||||
"ro-RO": "Olandeză",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
"sv-SE": "Nederländska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -310,24 +300,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
"sv-SE": "Spanska",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"de-DE": "Schwedisch",
|
||||
"pt-BR": "Sueco",
|
||||
"fr-FR": "Suédois",
|
||||
"zh-Hant-TW": "瑞典語",
|
||||
"pt-PT": "Sueco",
|
||||
"ro-RO": "Suedeză",
|
||||
"ja-JP": "スウェーデン語",
|
||||
"zh-Hans-CN": "瑞典语",
|
||||
"nl-NL": "Zweeds",
|
||||
"es-ES": "Sueco",
|
||||
"sv-SE": "Svenska",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export type TInstanceInfo = {
|
||||
instanceId: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns instance info including the anonymized instance ID and creation date.
|
||||
*
|
||||
* The instance ID is a SHA-256 hash of the oldest organization's ID, ensuring
|
||||
* it remains stable over time. Used for telemetry and license checks.
|
||||
*
|
||||
* @returns Instance info with hashed ID and creation date, or `null` if no organizations exist
|
||||
*/
|
||||
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
|
||||
try {
|
||||
const oldestOrg = await prisma.organization.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (!oldestOrg) return null;
|
||||
|
||||
return {
|
||||
instanceId: createHash("sha256").update(oldestOrg.id).digest("hex"),
|
||||
createdAt: oldestOrg.createdAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Convenience function that returns just the instance ID.
|
||||
*
|
||||
* @returns Hashed instance ID, or `null` if no organizations exist
|
||||
*/
|
||||
export const getInstanceId = async (): Promise<string | null> => {
|
||||
const info = await getInstanceInfo();
|
||||
return info?.instanceId ?? null;
|
||||
};
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
handleTriggerUpdates,
|
||||
loadNewSegmentInSurvey,
|
||||
updateSurvey,
|
||||
updateSurveyInternal,
|
||||
} from "./service";
|
||||
|
||||
// Mock organization service
|
||||
@@ -949,74 +948,3 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,13 +284,8 @@ export const getSurveyCount = reactCache(async (environmentId: string): Promise<
|
||||
}
|
||||
});
|
||||
|
||||
export const updateSurveyInternal = async (
|
||||
updatedSurvey: TSurvey,
|
||||
skipValidation = false
|
||||
): Promise<TSurvey> => {
|
||||
if (!skipValidation) {
|
||||
validateInputs([updatedSurvey, ZSurvey]);
|
||||
}
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
validateInputs([updatedSurvey, ZSurvey]);
|
||||
|
||||
try {
|
||||
const surveyId = updatedSurvey.id;
|
||||
@@ -306,12 +301,10 @@ export const updateSurveyInternal = async (
|
||||
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
|
||||
updatedSurvey;
|
||||
|
||||
if (!skipValidation) {
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
}
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
|
||||
// Add blocks media validation
|
||||
if (!skipValidation && updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
|
||||
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
|
||||
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
|
||||
if (!blocksValidation.ok) {
|
||||
throw new InvalidInputError(blocksValidation.error.message);
|
||||
@@ -375,7 +368,7 @@ export const updateSurveyInternal = async (
|
||||
if (type === "app") {
|
||||
// parse the segment filters:
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!skipValidation && !parsedFilters.success) {
|
||||
if (!parsedFilters.success) {
|
||||
throw new InvalidInputError("Invalid user segment filters");
|
||||
}
|
||||
|
||||
@@ -575,15 +568,6 @@ export const updateSurveyInternal = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
return updateSurveyInternal(updatedSurvey);
|
||||
};
|
||||
|
||||
// Draft update without validation
|
||||
export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
return updateSurveyInternal(updatedSurvey, true);
|
||||
};
|
||||
|
||||
export const createSurvey = async (
|
||||
environmentId: string,
|
||||
surveyBody: TSurveyCreateInput
|
||||
|
||||
@@ -69,12 +69,6 @@ describe("Time Utilities", () => {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "de-DE")).toBe("vor etwa 1 Stunde");
|
||||
});
|
||||
|
||||
test("should format time since in Swedish", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSinceDate", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -93,8 +93,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return fr;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "pt-PT":
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as nextHeaders from "next/headers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { findMatchingLocale } from "./locale";
|
||||
|
||||
// Mock the Next.js headers function
|
||||
@@ -85,25 +84,4 @@ describe("locale", () => {
|
||||
expect(result).toBe(germanLocale);
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Swedish locale (sv-SE) is available and selectable", async () => {
|
||||
// Verify sv-SE is in AVAILABLE_LOCALES
|
||||
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
||||
|
||||
// Verify Swedish has a language entry with proper labels
|
||||
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
||||
expect(swedishLanguage).toBeDefined();
|
||||
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
||||
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
|
||||
|
||||
// Verify the locale can be matched from Accept-Language header
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue("sv-SE,en-US"),
|
||||
} as any);
|
||||
|
||||
const result = await findMatchingLocale();
|
||||
|
||||
expect(result).toBe("sv-SE");
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"maximum": "Maximal",
|
||||
"member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"metadata": "Metadaten",
|
||||
"minimum": "Minimum",
|
||||
@@ -341,7 +340,6 @@
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Teamzugriff",
|
||||
"team_id": "Team-ID",
|
||||
"team_name": "Teamname",
|
||||
"team_role": "Team-Rolle",
|
||||
"teams": "Teams",
|
||||
"teams": "Zugriffskontrolle",
|
||||
"teams_not_found": "Teams nicht gefunden",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "Aktualisiert",
|
||||
"updated_at": "Aktualisiert am",
|
||||
"upload": "Hochladen",
|
||||
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
|
||||
"url": "URL",
|
||||
"user": "Benutzer",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergrundgestaltung",
|
||||
"background_styling": "Hintergründe",
|
||||
"block_duplicated": "Block dupliziert.",
|
||||
"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",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||
"card_background_color": "Hintergrundfarbe der Karte",
|
||||
"card_border_color": "Farbe des Kartenrandes",
|
||||
"card_styling": "Kartengestaltung",
|
||||
"card_styling": "Kartenstil",
|
||||
"casual": "Lässig",
|
||||
"caution_edit_duplicate": "Duplizieren & bearbeiten",
|
||||
"caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "CSS-Selektor",
|
||||
"cta_button_label": "\"CTA\"-Schaltflächen-Beschriftung",
|
||||
"custom_hostname": "Benutzerdefinierter Hostname",
|
||||
"customize_survey_logo": "Umfragelogo anpassen",
|
||||
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
|
||||
"date_format": "Datumsformat",
|
||||
"days_before_showing_this_survey_again": "Tage nachdem eine beliebige Umfrage angezeigt wurde, bevor diese Umfrage erscheinen kann.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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ügt nur die Fragen bei, die in der Umfrageantwort beantwortet wurden",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu",
|
||||
"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",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
|
||||
"hide_block_settings": "Block-Einstellungen ausblenden",
|
||||
"hide_logo": "Logo verstecken",
|
||||
"hide_logo_from_survey": "Logo in dieser Umfrage ausblenden",
|
||||
"hide_progress_bar": "Fortschrittsbalken ausblenden",
|
||||
"hide_question_settings": "Frageeinstellungen ausblenden",
|
||||
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
||||
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "Segment laden",
|
||||
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
||||
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
|
||||
"logo_settings": "Logo-Einstellungen",
|
||||
"long_answer": "Lange Antwort",
|
||||
"long_answer_toggle_description": "Ermöglichen Sie den Befragten, längere Antworten über mehrere Zeilen zu schreiben.",
|
||||
"lower_label": "Unteres Label",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Platzierung überschreiben",
|
||||
"overwrite_survey_logo": "Benutzerdefiniertes Umfragelogo festlegen",
|
||||
"overwrite_the_global_placement_of_the_survey": "Platzierung für diese Umfrage überschreiben",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Wähle einen Hintergrund aus oder lade deinen eigenen hoch.",
|
||||
"picture_idx": "Bild {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2048,7 +2026,7 @@
|
||||
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
|
||||
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der <b>am schnellsten wachsenden Open-Source-Umfrageplattform</b> 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 verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die <b>volle Kontrolle über Ihre Daten</b> zu behalten.",
|
||||
"paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um <b>volle Kontrolle über deine Daten</b> zu behalten.",
|
||||
"welcome_to_formbricks": "Willkommen bei Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"maximum": "Maximum",
|
||||
"member": "Member",
|
||||
"members": "Members",
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership_not_found": "Membership not found",
|
||||
"metadata": "Metadata",
|
||||
"minimum": "Minimum",
|
||||
@@ -338,10 +337,9 @@
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Team Access",
|
||||
"team_id": "Team ID",
|
||||
"team_name": "Team name",
|
||||
"team_role": "Team role",
|
||||
"teams": "Teams",
|
||||
"teams": "Access Control",
|
||||
"teams_not_found": "Teams not found",
|
||||
"text": "Text",
|
||||
"time": "Time",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "Updated",
|
||||
"updated_at": "Updated at",
|
||||
"upload": "Upload",
|
||||
"upload_failed": "Upload failed. Please try again.",
|
||||
"upload_input_description": "Click or drag to upload files.",
|
||||
"url": "URL",
|
||||
"user": "User",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background styling",
|
||||
"background_styling": "Background Styling",
|
||||
"block_duplicated": "Block duplicated.",
|
||||
"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",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
||||
"card_background_color": "Card background color",
|
||||
"card_border_color": "Card border color",
|
||||
"card_styling": "Card styling",
|
||||
"card_styling": "Card Styling",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicate & edit",
|
||||
"caution_edit_published_survey": "Edit a published survey?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "CSS Selector",
|
||||
"cta_button_label": "\"CTA\" button label",
|
||||
"custom_hostname": "Custom hostname",
|
||||
"customize_survey_logo": "Customize the survey logo",
|
||||
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
|
||||
"date_format": "Date format",
|
||||
"days_before_showing_this_survey_again": "days after any survey is shown before this survey can appear.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "Attaches only the questions that were answered in the survey response",
|
||||
"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_label": "Attach response data",
|
||||
"follow_ups_modal_action_body_label": "Body",
|
||||
"follow_ups_modal_action_body_placeholder": "Body of the email",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "Do not display the back button in the survey",
|
||||
"hide_block_settings": "Hide Block settings",
|
||||
"hide_logo": "Hide logo",
|
||||
"hide_logo_from_survey": "Hide logo from this survey",
|
||||
"hide_progress_bar": "Hide progress bar",
|
||||
"hide_question_settings": "Hide Question settings",
|
||||
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "Load segment",
|
||||
"logic_error_warning": "Changing will cause logic errors",
|
||||
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
|
||||
"logo_settings": "Logo settings",
|
||||
"long_answer": "Long answer",
|
||||
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
|
||||
"lower_label": "Lower Label",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Overwrite placement",
|
||||
"overwrite_survey_logo": "Set custom survey logo",
|
||||
"overwrite_the_global_placement_of_the_survey": "Overwrite the global placement of the survey",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Pick a background from our library or upload your own.",
|
||||
"picture_idx": "Picture {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2046,9 +2024,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 on the <b>fastest growing open-source survey platform</b> worldwide.",
|
||||
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
|
||||
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
|
||||
"paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
|
||||
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
|
||||
"welcome_to_formbricks": "Welcome to Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"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",
|
||||
@@ -341,7 +340,6 @@
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Acceso de equipo",
|
||||
"team_id": "ID de equipo",
|
||||
"team_name": "Nombre del equipo",
|
||||
"team_role": "Rol del equipo",
|
||||
"teams": "Equipos",
|
||||
"teams": "Control de acceso",
|
||||
"teams_not_found": "Equipos no encontrados",
|
||||
"text": "Texto",
|
||||
"time": "Hora",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "Actualizado",
|
||||
"updated_at": "Actualizado el",
|
||||
"upload": "Subir",
|
||||
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
|
||||
"upload_input_description": "Haz clic o arrastra para subir archivos.",
|
||||
"url": "URL",
|
||||
"user": "Usuario",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automáticamente la encuesta como completa después de",
|
||||
"back_button_label": "Etiqueta del botón \"Atrás\"",
|
||||
"background_styling": "Estilo del fondo",
|
||||
"background_styling": "Estilo de fondo",
|
||||
"block_duplicated": "Bloque duplicado.",
|
||||
"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",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
|
||||
"card_background_color": "Color de fondo de la tarjeta",
|
||||
"card_border_color": "Color del borde de la tarjeta",
|
||||
"card_styling": "Estilo de la tarjeta",
|
||||
"card_styling": "Estilo de tarjeta",
|
||||
"casual": "Informal",
|
||||
"caution_edit_duplicate": "Duplicar y editar",
|
||||
"caution_edit_published_survey": "¿Editar una encuesta publicada?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "Selector CSS",
|
||||
"cta_button_label": "Etiqueta del botón \"CTA\"",
|
||||
"custom_hostname": "Nombre de host personalizado",
|
||||
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
|
||||
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
|
||||
"date_format": "Formato de fecha",
|
||||
"days_before_showing_this_survey_again": "días después de que se muestre cualquier encuesta antes de que esta encuesta pueda aparecer.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "Adjunta solo las preguntas que fueron respondidas en la respuesta de la encuesta",
|
||||
"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_label": "Adjuntar datos de respuesta",
|
||||
"follow_ups_modal_action_body_label": "Cuerpo",
|
||||
"follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "No mostrar el botón de retroceso en la encuesta",
|
||||
"hide_block_settings": "Ocultar ajustes del bloque",
|
||||
"hide_logo": "Ocultar logotipo",
|
||||
"hide_logo_from_survey": "Ocultar logotipo de esta encuesta",
|
||||
"hide_progress_bar": "Ocultar barra de progreso",
|
||||
"hide_question_settings": "Ocultar ajustes de la pregunta",
|
||||
"hide_the_logo_in_this_specific_survey": "Ocultar el logotipo en esta encuesta específica",
|
||||
"hostname": "Nombre de host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "¿Cuánto estilo quieres darle a tus tarjetas en las encuestas de tipo {surveyTypeDerived}?",
|
||||
"if_you_need_more_please": "Si necesitas más, por favor",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "Cargar segmento",
|
||||
"logic_error_warning": "El cambio causará errores lógicos",
|
||||
"logic_error_warning_text": "Cambiar el tipo de pregunta eliminará las condiciones lógicas de esta pregunta",
|
||||
"logo_settings": "Ajustes del logotipo",
|
||||
"long_answer": "Respuesta larga",
|
||||
"long_answer_toggle_description": "Permitir a los encuestados escribir respuestas más largas y de varias líneas.",
|
||||
"lower_label": "Etiqueta inferior",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Sobrescribir ubicación",
|
||||
"overwrite_survey_logo": "Establecer logotipo personalizado para la encuesta",
|
||||
"overwrite_the_global_placement_of_the_survey": "Sobrescribir la ubicación global de la encuesta",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Elige un fondo de nuestra biblioteca o sube el tuyo propio.",
|
||||
"picture_idx": "Imagen {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2046,9 +2024,9 @@
|
||||
"intro": {
|
||||
"get_started": "Comenzar",
|
||||
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
|
||||
"paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> a nivel mundial.",
|
||||
"paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> en todo el mundo.",
|
||||
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para <b>crear experiencias irresistibles</b> para clientes, usuarios y empleados.",
|
||||
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el <b>control total sobre tus datos</b>.",
|
||||
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener <b>control total sobre tus datos</b>.",
|
||||
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"maximum": "Max",
|
||||
"member": "Membre",
|
||||
"members": "Membres",
|
||||
"members_and_teams": "Membres & Équipes",
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"metadata": "Métadonnées",
|
||||
"minimum": "Min",
|
||||
@@ -338,10 +337,9 @@
|
||||
"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 la documentation",
|
||||
"read_docs": "Lire les documents",
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Accès",
|
||||
"team_id": "Identifiant de l'équipe",
|
||||
"team_name": "Nom de l'équipe",
|
||||
"team_role": "Rôle dans l'équipe",
|
||||
"teams": "Équipes",
|
||||
"teams": "Contrôle d'accès",
|
||||
"teams_not_found": "Équipes non trouvées",
|
||||
"text": "Texte",
|
||||
"time": "Temps",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "Mise à jour",
|
||||
"updated_at": "Mis à jour à",
|
||||
"upload": "Télécharger",
|
||||
"upload_failed": "Échec du téléchargement. Veuillez réessayer.",
|
||||
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
|
||||
"url": "URL",
|
||||
"user": "Utilisateur",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style d'arrière-plan",
|
||||
"background_styling": "Style de fond",
|
||||
"block_duplicated": "Bloc dupliqué.",
|
||||
"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",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "Sélecteur CSS",
|
||||
"cta_button_label": "Libellé du bouton « CTA »",
|
||||
"custom_hostname": "Nom d'hôte personnalisé",
|
||||
"customize_survey_logo": "Personnaliser le logo de l'enquête",
|
||||
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
|
||||
"date_format": "Format de date",
|
||||
"days_before_showing_this_survey_again": "jours après qu'une enquête soit affichée avant que cette enquête puisse apparaître.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "Joint uniquement les questions auxquelles on a répondu dans la réponse au sondage",
|
||||
"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_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",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
|
||||
"hide_block_settings": "Masquer les paramètres du bloc",
|
||||
"hide_logo": "Cacher le logo",
|
||||
"hide_logo_from_survey": "Masquer le logo de cette enquête",
|
||||
"hide_progress_bar": "Cacher la barre de progression",
|
||||
"hide_question_settings": "Masquer les paramètres de la question",
|
||||
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
|
||||
"hostname": "Nom d'hôte",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "Segment de chargement",
|
||||
"logic_error_warning": "Changer causera des erreurs logiques",
|
||||
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
|
||||
"logo_settings": "Paramètres du logo",
|
||||
"long_answer": "Longue réponse",
|
||||
"long_answer_toggle_description": "Permettre aux répondants d'écrire des réponses plus longues et sur plusieurs lignes.",
|
||||
"lower_label": "Étiquette inférieure",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Surcharge de placement",
|
||||
"overwrite_survey_logo": "Définir un logo d'enquête personnalisé",
|
||||
"overwrite_the_global_placement_of_the_survey": "Surcharger le placement global de l'enquête",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Choisissez un arrière-plan dans notre bibliothèque ou téléchargez le vôtre.",
|
||||
"picture_idx": "Image {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2046,9 +2024,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 <b>plateforme de sondage open-source à la croissance la plus rapide</b> au monde.",
|
||||
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme d'enquête open source à la croissance la plus rapide</b> 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 <b>créer des expériences irrésistibles</b> pour les clients, les utilisateurs et les employés.",
|
||||
"paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total de vos données</b>.",
|
||||
"paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total sur vos données</b>. Toujours.",
|
||||
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"maximum": "最大",
|
||||
"member": "メンバー",
|
||||
"members": "メンバー",
|
||||
"members_and_teams": "メンバー&チーム",
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"metadata": "メタデータ",
|
||||
"minimum": "最小",
|
||||
@@ -341,7 +340,6 @@
|
||||
"read_docs": "ドキュメントを読む",
|
||||
"recipients": "受信者",
|
||||
"remove": "削除",
|
||||
"remove_from_team": "チームから削除",
|
||||
"reorder_and_hide_columns": "列の並び替えと非表示",
|
||||
"report_survey": "フォームを報告",
|
||||
"request_pricing": "料金を問い合わせる",
|
||||
@@ -351,10 +349,10 @@
|
||||
"responses": "回答",
|
||||
"restart": "再開",
|
||||
"role": "役割",
|
||||
"role_organization": "役割(組織)",
|
||||
"saas": "SaaS",
|
||||
"sales": "セールス",
|
||||
"save": "保存",
|
||||
"save_as_draft": "下書きとして保存",
|
||||
"save_changes": "変更を保存",
|
||||
"saving": "保存中",
|
||||
"search": "検索",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "チームアクセス",
|
||||
"team_id": "チームID",
|
||||
"team_name": "チーム名",
|
||||
"team_role": "チームの役割",
|
||||
"teams": "チーム",
|
||||
"teams": "アクセス制御",
|
||||
"teams_not_found": "チームが見つかりません",
|
||||
"text": "テキスト",
|
||||
"time": "時間",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "更新済み",
|
||||
"updated_at": "更新日時",
|
||||
"upload": "アップロード",
|
||||
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
|
||||
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
|
||||
"url": "URL",
|
||||
"user": "ユーザー",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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": "ミーティングを予約",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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": "こんにちは 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"teams": {
|
||||
"manage_teams": "チームを管理",
|
||||
"no_teams_found": "チームが見つかりません",
|
||||
"only_organization_owners_and_managers_can_manage_teams": "組織のオーナーまたは管理者のみがチームを管理できます。",
|
||||
"permission": "権限",
|
||||
"team_name": "チーム名",
|
||||
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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": "チームを正常に削除しました。",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
|
||||
"back_button_label": "「戻る」ボタンのラベル",
|
||||
"background_styling": "背景のスタイル設定",
|
||||
"background_styling": "背景のスタイル",
|
||||
"block_duplicated": "ブロックが複製されました。",
|
||||
"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": "ボタンのラベル",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
|
||||
"card_background_color": "カードの背景色",
|
||||
"card_border_color": "カードの枠線の色",
|
||||
"card_styling": "カードのスタイル設定",
|
||||
"card_styling": "カードのスタイル",
|
||||
"casual": "カジュアル",
|
||||
"caution_edit_duplicate": "複製して編集",
|
||||
"caution_edit_published_survey": "公開済みのフォームを編集しますか?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "CSSセレクター",
|
||||
"cta_button_label": "\"CTA\"ボタンのラベル",
|
||||
"custom_hostname": "カスタムホスト名",
|
||||
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
|
||||
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
|
||||
"date_format": "日付形式",
|
||||
"days_before_showing_this_survey_again": "任意のフォームが表示された後、このフォームが再表示されるまでの日数。",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "メールの本文",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
|
||||
"hide_block_settings": "ブロック設定を非表示",
|
||||
"hide_logo": "ロゴを非表示",
|
||||
"hide_logo_from_survey": "このアンケートからロゴを非表示にする",
|
||||
"hide_progress_bar": "プログレスバーを非表示",
|
||||
"hide_question_settings": "質問設定を非表示",
|
||||
"hide_the_logo_in_this_specific_survey": "この特定のフォームでロゴを非表示にする",
|
||||
"hostname": "ホスト名",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "{surveyTypeDerived} フォームのカードをどれくらいユニークにしますか",
|
||||
"if_you_need_more_please": "さらに必要な場合は、",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "セグメントを読み込み",
|
||||
"logic_error_warning": "変更するとロジックエラーが発生します",
|
||||
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
|
||||
"logo_settings": "ロゴ設定",
|
||||
"long_answer": "長文回答",
|
||||
"long_answer_toggle_description": "回答者が長文の複数行の回答を書けるようにします。",
|
||||
"lower_label": "下限ラベル",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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": "このフォームのみプロジェクト設定を上書きします。",
|
||||
"overwrite_placement": "配置を上書き",
|
||||
"overwrite_survey_logo": "カスタムアンケートロゴを設定する",
|
||||
"overwrite_the_global_placement_of_the_survey": "フォームのグローバルな配置を上書き",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "ライブラリから背景を選択するか、独自にアップロードしてください。",
|
||||
"picture_idx": "写真 {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
|
||||
"until_they_submit_a_response": "回答が提出されるまで質問する",
|
||||
"untitled_block": "無題のブロック",
|
||||
"update_options": "オプションを更新",
|
||||
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
|
||||
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
|
||||
"upload": "アップロード",
|
||||
@@ -2046,9 +2024,9 @@
|
||||
"intro": {
|
||||
"get_started": "始める",
|
||||
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
|
||||
"paragraph_1": "Formbricksは、世界で<b>最も急成長しているオープンソースのアンケートプラットフォーム</b>をベースに構築されたエクスペリエンス管理スイートです。",
|
||||
"paragraph_1": "Formbricksは、世界で<b>最も急速に成長しているオープンソースのフォームプラットフォーム</b>から構築されたエクスペリエンス管理スイートです。",
|
||||
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの<b>魅力的な体験</b>を作り出します。",
|
||||
"paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、<b>データを完全に管理</b>できます。",
|
||||
"paragraph_3": "私たちは、最高のデータプライバシーを約束します。セルフホストして、<b>データを完全に制御</b>できます。",
|
||||
"welcome_to_formbricks": "Formbricksへようこそ!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"maximum": "Maximaal",
|
||||
"member": "Lid",
|
||||
"members": "Leden",
|
||||
"members_and_teams": "Leden & teams",
|
||||
"membership_not_found": "Lidmaatschap niet gevonden",
|
||||
"metadata": "Metagegevens",
|
||||
"minimum": "Minimum",
|
||||
@@ -338,10 +337,9 @@
|
||||
"quota": "Quotum",
|
||||
"quotas": "Quota",
|
||||
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
|
||||
"read_docs": "Documentatie lezen",
|
||||
"read_docs": "Lees Documenten",
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Teamtoegang",
|
||||
"team_id": "Team-ID",
|
||||
"team_name": "Teamnaam",
|
||||
"team_role": "Teamrol",
|
||||
"teams": "Teams",
|
||||
"teams": "Toegangscontrole",
|
||||
"teams_not_found": "Teams niet gevonden",
|
||||
"text": "Tekst",
|
||||
"time": "Tijd",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "Bijgewerkt",
|
||||
"updated_at": "Bijgewerkt op",
|
||||
"upload": "Uploaden",
|
||||
"upload_failed": "Upload mislukt. Probeer het opnieuw.",
|
||||
"upload_input_description": "Klik of sleep om bestanden te uploaden.",
|
||||
"url": "URL",
|
||||
"user": "Gebruiker",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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é 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Markeer de enquête daarna automatisch als voltooid",
|
||||
"back_button_label": "Knoplabel 'Terug'",
|
||||
"background_styling": "Achtergrondstijl",
|
||||
"background_styling": "Achtergrondstyling",
|
||||
"block_duplicated": "Blok gedupliceerd.",
|
||||
"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",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
|
||||
"card_background_color": "Achtergrondkleur van de kaart",
|
||||
"card_border_color": "Randkleur kaart",
|
||||
"card_styling": "Kaartstijl",
|
||||
"card_styling": "Kaartstyling",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Dupliceren en bewerken",
|
||||
"caution_edit_published_survey": "Een gepubliceerde enquête bewerken?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "CSS-kiezer",
|
||||
"cta_button_label": "\"CTA\" knoplabel",
|
||||
"custom_hostname": "Aangepaste hostnaam",
|
||||
"customize_survey_logo": "Pas het enquêtelogo aan",
|
||||
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
|
||||
"date_format": "Datumformaat",
|
||||
"days_before_showing_this_survey_again": "dagen nadat een enquête is getoond voordat deze enquête kan verschijnen.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "Voegt alleen de vragen toe die zijn beantwoord in de enquêterespons",
|
||||
"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_label": "Reactiegegevens bijvoegen",
|
||||
"follow_ups_modal_action_body_label": "Lichaam",
|
||||
"follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "Geef de terugknop niet weer in de enquête",
|
||||
"hide_block_settings": "Blokinstellingen verbergen",
|
||||
"hide_logo": "Logo verbergen",
|
||||
"hide_logo_from_survey": "Verberg logo van deze enquête",
|
||||
"hide_progress_bar": "Voortgangsbalk verbergen",
|
||||
"hide_question_settings": "Vraaginstellingen verbergen",
|
||||
"hide_the_logo_in_this_specific_survey": "Verberg het logo in deze specifieke enquête",
|
||||
"hostname": "Hostnaam",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hoe funky wil je je kaarten hebben in {surveyTypeDerived} Enquêtes",
|
||||
"if_you_need_more_please": "Als u meer nodig heeft, alstublieft",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "Laadsegment",
|
||||
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
|
||||
"logic_error_warning_text": "Als u het vraagtype wijzigt, worden de logische voorwaarden van deze vraag verwijderd",
|
||||
"logo_settings": "Logo-instellingen",
|
||||
"long_answer": "Lang antwoord",
|
||||
"long_answer_toggle_description": "Sta respondenten toe om langere antwoorden met meerdere regels te schrijven.",
|
||||
"lower_label": "Lager etiket",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Plaatsing overschrijven",
|
||||
"overwrite_survey_logo": "Stel aangepast enquêtelogo in",
|
||||
"overwrite_the_global_placement_of_the_survey": "Overschrijf de globale plaatsing van de enquête",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Kies een achtergrond uit onze bibliotheek of upload je eigen achtergrond.",
|
||||
"picture_idx": "Afbeelding {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2046,9 +2024,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 gebouwd op het <b>snelst groeiende open-source enquêteplatform</b> wereldwijd.",
|
||||
"paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het <b>snelst groeiende open source enquêteplatform</b> wereldwijd.",
|
||||
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om <b>onweerstaanbare ervaringen te creëren</b> voor klanten, gebruikers en medewerkers.",
|
||||
"paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om <b>volledige controle over je gegevens</b> te behouden.",
|
||||
"paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om <b>volledige controle over uw gegevens</b> te behouden.",
|
||||
"welcome_to_formbricks": "Welkom bij Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"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",
|
||||
@@ -338,10 +337,9 @@
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Acesso da equipe",
|
||||
"team_id": "ID da Equipe",
|
||||
"team_name": "Nome da equipe",
|
||||
"team_role": "Função na equipe",
|
||||
"teams": "Equipes",
|
||||
"teams": "Controle de Acesso",
|
||||
"teams_not_found": "Equipes não encontradas",
|
||||
"text": "Texto",
|
||||
"time": "tempo",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "atualizado",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload": "Enviar",
|
||||
"upload_failed": "Falha no upload. Tente novamente.",
|
||||
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
|
||||
"url": "URL",
|
||||
"user": "Usuário",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo do plano de fundo",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"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",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
"card_border_color": "Cor da borda do cartão",
|
||||
"card_styling": "Estilo do cartão",
|
||||
"card_styling": "Estilização de Cartão",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicar e editar",
|
||||
"caution_edit_published_survey": "Editar uma pesquisa publicada?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "Seletor CSS",
|
||||
"cta_button_label": "Rótulo do botão \"CTA\"",
|
||||
"custom_hostname": "Hostname personalizado",
|
||||
"customize_survey_logo": "Personalizar o logo da pesquisa",
|
||||
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
|
||||
"date_format": "Formato de data",
|
||||
"days_before_showing_this_survey_again": "dias após qualquer pesquisa ser mostrada antes que esta pesquisa possa aparecer.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "Anexa apenas as perguntas que foram respondidas na resposta da pesquisa",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento",
|
||||
"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",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
|
||||
"hide_block_settings": "Ocultar configurações do bloco",
|
||||
"hide_logo": "Esconder logo",
|
||||
"hide_logo_from_survey": "Esconder logo desta pesquisa",
|
||||
"hide_progress_bar": "Esconder barra de progresso",
|
||||
"hide_question_settings": "Ocultar configurações da pergunta",
|
||||
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
|
||||
"hostname": "nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "segmento de carga",
|
||||
"logic_error_warning": "Mudar vai causar erros de lógica",
|
||||
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
|
||||
"logo_settings": "Configurações do logo",
|
||||
"long_answer": "resposta longa",
|
||||
"long_answer_toggle_description": "Permitir que os respondentes escrevam respostas mais longas e com várias linhas.",
|
||||
"lower_label": "Etiqueta Inferior",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Substituir posicionamento",
|
||||
"overwrite_survey_logo": "Definir logo personalizado para a pesquisa",
|
||||
"overwrite_the_global_placement_of_the_survey": "Substituir a posição global da pesquisa",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou faça upload do seu próprio.",
|
||||
"picture_idx": "Imagem {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2046,9 +2024,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 sobre a <b>plataforma de pesquisa de código aberto de crescimento mais rápido</b> do mundo.",
|
||||
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no 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 grau de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
|
||||
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"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",
|
||||
@@ -338,10 +337,9 @@
|
||||
"quota": "Quota",
|
||||
"quotas": "Quotas",
|
||||
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"read_docs": "Ler Documentos",
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Acesso da Equipa",
|
||||
"team_id": "ID da Equipa",
|
||||
"team_name": "Nome da equipa",
|
||||
"team_role": "Função na equipa",
|
||||
"teams": "Equipas",
|
||||
"teams": "Controlo de Acesso",
|
||||
"teams_not_found": "Equipas não encontradas",
|
||||
"text": "Texto",
|
||||
"time": "Tempo",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "Atualizado",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload": "Carregar",
|
||||
"upload_failed": "Falha no carregamento. Por favor, tente novamente.",
|
||||
"upload_input_description": "Clique ou arraste para carregar ficheiros.",
|
||||
"url": "URL",
|
||||
"user": "Utilizador",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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á 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de fundo",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"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",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
"card_border_color": "Cor da borda do cartão",
|
||||
"card_styling": "Estilo de cartão",
|
||||
"card_styling": "Estilo do cartão",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicar e editar",
|
||||
"caution_edit_published_survey": "Editar um inquérito publicado?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "Seletor CSS",
|
||||
"cta_button_label": "Etiqueta do botão \"CTA\"",
|
||||
"custom_hostname": "Nome do host personalizado",
|
||||
"customize_survey_logo": "Personalizar o logótipo do inquérito",
|
||||
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
|
||||
"date_format": "Formato da data",
|
||||
"days_before_showing_this_survey_again": "dias após qualquer inquérito ser mostrado antes que este inquérito possa aparecer.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "Anexa apenas as perguntas que foram respondidas na resposta ao inquérito",
|
||||
"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_label": "Anexar dados de resposta",
|
||||
"follow_ups_modal_action_body_label": "Corpo",
|
||||
"follow_ups_modal_action_body_placeholder": "Corpo do email",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
|
||||
"hide_block_settings": "Ocultar definições do bloco",
|
||||
"hide_logo": "Esconder logótipo",
|
||||
"hide_logo_from_survey": "Ocultar logótipo deste inquérito",
|
||||
"hide_progress_bar": "Ocultar barra de progresso",
|
||||
"hide_question_settings": "Ocultar definições da pergunta",
|
||||
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
|
||||
"hostname": "Nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "Carregar segmento",
|
||||
"logic_error_warning": "A alteração causará erros de lógica",
|
||||
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
|
||||
"logo_settings": "Definições do logótipo",
|
||||
"long_answer": "Resposta longa",
|
||||
"long_answer_toggle_description": "Permitir que os inquiridos escrevam respostas mais longas e com várias linhas.",
|
||||
"lower_label": "Etiqueta Inferior",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Substituir colocação",
|
||||
"overwrite_survey_logo": "Definir logótipo de inquérito personalizado",
|
||||
"overwrite_the_global_placement_of_the_survey": "Substituir a colocação global do inquérito",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou carregue o seu próprio.",
|
||||
"picture_idx": "Imagem {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2046,9 +2024,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 <b>plataforma de inquéritos open-source de crescimento mais rápido</b> a nível mundial.",
|
||||
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos de código aberto de crescimento mais rápido</b> do mundo.",
|
||||
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para <b>criar experiências irresistíveis</b> para clientes, utilizadores e funcionários.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter <b>controlo total sobre os seus dados</b>.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter <b>controlo total sobre os seus dados</b>.",
|
||||
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"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",
|
||||
@@ -341,7 +340,6 @@
|
||||
"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",
|
||||
@@ -351,10 +349,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",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "Acces echipă",
|
||||
"team_id": "ID echipă",
|
||||
"team_name": "Nume echipă",
|
||||
"team_role": "Rol în echipă",
|
||||
"teams": "Echipe",
|
||||
"teams": "Control acces",
|
||||
"teams_not_found": "Echipele nu au fost găsite",
|
||||
"text": "Text",
|
||||
"time": "Timp",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "Actualizat",
|
||||
"updated_at": "Actualizat la",
|
||||
"upload": "Încărcați",
|
||||
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.",
|
||||
"upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
|
||||
"url": "URL",
|
||||
"user": "Utilizator",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"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."
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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.",
|
||||
@@ -1271,10 +1260,6 @@
|
||||
"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",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "Selector CSS",
|
||||
"cta_button_label": "Eticheta butonului \"CTA\"",
|
||||
"custom_hostname": "Gazdă personalizată",
|
||||
"customize_survey_logo": "Personalizează logo-ul chestionarului",
|
||||
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
|
||||
"date_format": "Format dată",
|
||||
"days_before_showing_this_survey_again": "zile după afișarea oricărui sondaj înainte ca acest sondaj să poată apărea din nou.",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "Atașează doar întrebările la care s-a răspuns în răspunsul sondajului",
|
||||
"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_label": "Atașează datele răspunsului",
|
||||
"follow_ups_modal_action_body_label": "Corp",
|
||||
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
|
||||
"hide_block_settings": "Ascunde setările blocului",
|
||||
"hide_logo": "Ascunde logo",
|
||||
"hide_logo_from_survey": "Ascunde logo-ul din acest chestionar",
|
||||
"hide_progress_bar": "Ascunde bara de progres",
|
||||
"hide_question_settings": "Ascunde setările întrebării",
|
||||
"hide_the_logo_in_this_specific_survey": "Ascunde logo-ul în acest chestionar specific",
|
||||
"hostname": "Nume gazdă",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "Încarcă segment",
|
||||
"logic_error_warning": "Schimbarea va provoca erori de logică",
|
||||
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
|
||||
"logo_settings": "Setări logo",
|
||||
"long_answer": "Răspuns lung",
|
||||
"long_answer_toggle_description": "Permite respondenților să scrie răspunsuri mai lungi, pe mai multe rânduri.",
|
||||
"lower_label": "Etichetă inferioară",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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.",
|
||||
"overwrite_placement": "Suprascriere amplasare",
|
||||
"overwrite_survey_logo": "Setează un logo personalizat pentru chestionar",
|
||||
"overwrite_the_global_placement_of_the_survey": "Suprascrie amplasarea globală a sondajului",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Alege un fundal din biblioteca noastră sau încarcă unul propriu.",
|
||||
"picture_idx": "Poză {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"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",
|
||||
@@ -2046,9 +2024,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ței construită pe <b>cea mai rapidă platformă open-source de sondaje</b> din lume.",
|
||||
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> 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 <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
|
||||
"paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra <b>controlul deplin asupra datelor tale</b>.",
|
||||
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
|
||||
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -261,7 +261,6 @@
|
||||
"maximum": "最大值",
|
||||
"member": "成员",
|
||||
"members": "成员",
|
||||
"members_and_teams": "成员和团队",
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"metadata": "元数据",
|
||||
"minimum": "最低",
|
||||
@@ -338,10 +337,9 @@
|
||||
"quota": "配额",
|
||||
"quotas": "配额",
|
||||
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
|
||||
"read_docs": "阅读文档",
|
||||
"read_docs": "阅读 文档",
|
||||
"recipients": "收件人",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "从团队中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隐藏列",
|
||||
"report_survey": "报告调查",
|
||||
"request_pricing": "请求 定价",
|
||||
@@ -351,10 +349,10 @@
|
||||
"responses": "反馈",
|
||||
"restart": "重新启动",
|
||||
"role": "角色",
|
||||
"role_organization": "角色 (组织)",
|
||||
"saas": "SaaS",
|
||||
"sales": "销售",
|
||||
"save": "保存",
|
||||
"save_as_draft": "保存为草稿",
|
||||
"save_changes": "保存 更改",
|
||||
"saving": "保存",
|
||||
"search": "搜索",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "团队 访问",
|
||||
"team_id": "团队 ID",
|
||||
"team_name": "团队 名称",
|
||||
"team_role": "团队角色",
|
||||
"teams": "团队",
|
||||
"teams": "访问控制",
|
||||
"teams_not_found": "未找到 团队",
|
||||
"text": "文本",
|
||||
"time": "时间",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新 于",
|
||||
"upload": "上传",
|
||||
"upload_failed": "上传失败,请重试。",
|
||||
"upload_input_description": "点击 或 拖动 上传 文件",
|
||||
"url": "URL",
|
||||
"user": "用户",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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": "安排你的会议",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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": "嗨 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"teams": {
|
||||
"manage_teams": "管理 团队",
|
||||
"no_teams_found": "未找到 团队",
|
||||
"only_organization_owners_and_managers_can_manage_teams": "只有 组织 拥有者 和 经理 可以 管理 团队。",
|
||||
"permission": "权限",
|
||||
"team_name": "团队名称",
|
||||
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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": "团队 删除 成功",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
|
||||
"back_button_label": "\"返回\" 按钮标签",
|
||||
"background_styling": "背景样式",
|
||||
"background_styling": "背景 样式",
|
||||
"block_duplicated": "区块已复制。",
|
||||
"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": "按钮标签",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
|
||||
"card_background_color": "卡片 的 背景 颜色",
|
||||
"card_border_color": "卡片 的 边框 颜色",
|
||||
"card_styling": "卡片样式",
|
||||
"card_styling": "卡 样式",
|
||||
"casual": "休闲",
|
||||
"caution_edit_duplicate": "复制 并 编辑",
|
||||
"caution_edit_published_survey": "编辑 已 发布 的 survey?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "CSS 选择器",
|
||||
"cta_button_label": "“CTA”按钮标签",
|
||||
"custom_hostname": "自 定 义 主 机 名",
|
||||
"customize_survey_logo": "自定义调查 logo",
|
||||
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "在显示此调查之前,需等待的天数。",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "电子邮件正文",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
|
||||
"hide_block_settings": "隐藏区块设置",
|
||||
"hide_logo": "隐藏 徽标",
|
||||
"hide_logo_from_survey": "隐藏此调查中的 logo",
|
||||
"hide_progress_bar": "隐藏 进度 条",
|
||||
"hide_question_settings": "隐藏问题设置",
|
||||
"hide_the_logo_in_this_specific_survey": "隐藏此特定调查中的 logo",
|
||||
"hostname": "主 机 名",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
|
||||
"if_you_need_more_please": "如果你需要更多,请",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "载入 段落",
|
||||
"logic_error_warning": "更改 将 导致 逻辑 错误",
|
||||
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
|
||||
"logo_settings": "Logo 设置",
|
||||
"long_answer": "长答案",
|
||||
"long_answer_toggle_description": "允许受访者填写较长的多行答案。",
|
||||
"lower_label": "下限标签",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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": "仅为此调查覆盖项目配置。",
|
||||
"overwrite_placement": "覆盖 放置",
|
||||
"overwrite_survey_logo": "设置自定义调查 logo",
|
||||
"overwrite_the_global_placement_of_the_survey": "覆盖 全局 调查 放置",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "从我们的库中选择一种 背景 或 上传您自己的。",
|
||||
"picture_idx": "图片 {idx}",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
|
||||
"until_they_submit_a_response": "持续显示直到提交回应",
|
||||
"untitled_block": "未命名区块",
|
||||
"update_options": "更新选项",
|
||||
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
|
||||
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
|
||||
"upload": "上传",
|
||||
@@ -2046,9 +2024,9 @@
|
||||
"intro": {
|
||||
"get_started": "开始使用",
|
||||
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
|
||||
"paragraph_1": "Formbricks 是一款体验管理套件,基于全球<b>增长最快的开源调研平台</b>构建。",
|
||||
"paragraph_1": "Formbricks 是一个体验管理套件, 基于全球<b>增长最快的开源调查平台</b>构建。",
|
||||
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工<b>打造 无法抗拒 的体验</b>。",
|
||||
"paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,<b>全面掌控您的数据</b>。",
|
||||
"paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持<b>对您的数据的完全控制</b>。",
|
||||
"welcome_to_formbricks": "欢迎来到 Formbricks !"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"maximum": "最大值",
|
||||
"member": "成員",
|
||||
"members": "成員",
|
||||
"members_and_teams": "成員與團隊",
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"metadata": "元數據",
|
||||
"minimum": "最小值",
|
||||
@@ -341,7 +340,6 @@
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "從團隊中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
"report_survey": "報告問卷",
|
||||
"request_pricing": "請求定價",
|
||||
@@ -351,10 +349,10 @@
|
||||
"responses": "回應",
|
||||
"restart": "重新開始",
|
||||
"role": "角色",
|
||||
"role_organization": "角色(組織)",
|
||||
"saas": "SaaS",
|
||||
"sales": "銷售",
|
||||
"save": "儲存",
|
||||
"save_as_draft": "儲存為草稿",
|
||||
"save_changes": "儲存變更",
|
||||
"saving": "儲存",
|
||||
"search": "搜尋",
|
||||
@@ -409,8 +407,7 @@
|
||||
"team_access": "團隊存取權限",
|
||||
"team_id": "團隊 ID",
|
||||
"team_name": "團隊名稱",
|
||||
"team_role": "團隊角色",
|
||||
"teams": "團隊",
|
||||
"teams": "存取控制",
|
||||
"teams_not_found": "找不到團隊",
|
||||
"text": "文字",
|
||||
"time": "時間",
|
||||
@@ -425,7 +422,6 @@
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新時間",
|
||||
"upload": "上傳",
|
||||
"upload_failed": "上傳失敗。請再試一次。",
|
||||
"upload_input_description": "點擊或拖曳以上傳檔案。",
|
||||
"url": "網址",
|
||||
"user": "使用者",
|
||||
@@ -473,7 +469,6 @@
|
||||
"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,14 +480,12 @@
|
||||
"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": "安排你的會議",
|
||||
@@ -504,7 +497,6 @@
|
||||
"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": "嗨 👋",
|
||||
@@ -910,6 +902,7 @@
|
||||
"teams": {
|
||||
"manage_teams": "管理團隊",
|
||||
"no_teams_found": "找不到團隊",
|
||||
"only_organization_owners_and_managers_can_manage_teams": "只有組織擁有者和管理員才能管理團隊。",
|
||||
"permission": "權限",
|
||||
"team_name": "團隊名稱",
|
||||
"team_settings_description": "查看哪些團隊可以存取此專案。"
|
||||
@@ -1173,17 +1166,13 @@
|
||||
"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": "團隊已成功刪除。",
|
||||
@@ -1266,15 +1255,11 @@
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式",
|
||||
"background_styling": "背景樣式設定",
|
||||
"block_duplicated": "區塊已複製。",
|
||||
"bold": "粗體",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
"bulk_edit": "批次編輯",
|
||||
"bulk_edit_description": "在下方逐行編輯所有選項。空白行將被略過,重複項目將被移除。",
|
||||
"bulk_edit_options": "批次編輯選項",
|
||||
"bulk_edit_options_for": "為 {language} 批次編輯選項",
|
||||
"button_external": "啟用外部連結",
|
||||
"button_external_description": "新增一個按鈕,在新分頁中開啟外部網址",
|
||||
"button_label": "按鈕標籤",
|
||||
@@ -1286,7 +1271,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||
"card_background_color": "卡片背景顏色",
|
||||
"card_border_color": "卡片邊框顏色",
|
||||
"card_styling": "卡片樣式",
|
||||
"card_styling": "卡片樣式設定",
|
||||
"casual": "隨意",
|
||||
"caution_edit_duplicate": "複製 & 編輯",
|
||||
"caution_edit_published_survey": "編輯已發佈的調查?",
|
||||
@@ -1342,7 +1327,6 @@
|
||||
"css_selector": "CSS 選取器",
|
||||
"cta_button_label": "「CTA」按鈕標籤",
|
||||
"custom_hostname": "自訂主機名稱",
|
||||
"customize_survey_logo": "自訂問卷標誌",
|
||||
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "在顯示此問卷之前,需等待其他問卷顯示後的天數。",
|
||||
@@ -1394,13 +1378,11 @@
|
||||
"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": "電子郵件內文",
|
||||
@@ -1445,9 +1427,9 @@
|
||||
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
|
||||
"hide_block_settings": "隱藏區塊設定",
|
||||
"hide_logo": "隱藏標誌",
|
||||
"hide_logo_from_survey": "隱藏此問卷的標誌",
|
||||
"hide_progress_bar": "隱藏進度列",
|
||||
"hide_question_settings": "隱藏問題設定",
|
||||
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
|
||||
"hostname": "主機名稱",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
@@ -1494,7 +1476,6 @@
|
||||
"load_segment": "載入區隔",
|
||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
"logo_settings": "標誌設定",
|
||||
"long_answer": "長回答",
|
||||
"long_answer_toggle_description": "允許受訪者撰寫較長的多行回答。",
|
||||
"lower_label": "下標籤",
|
||||
@@ -1523,12 +1504,10 @@
|
||||
"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": "僅覆蓋此問卷的專案設定。",
|
||||
"overwrite_placement": "覆寫位置",
|
||||
"overwrite_survey_logo": "設定自訂問卷標誌",
|
||||
"overwrite_the_global_placement_of_the_survey": "覆寫問卷的整體位置",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "從我們的媒體庫中選取背景或上傳您自己的背景。",
|
||||
"picture_idx": "圖片 '{'idx'}'",
|
||||
@@ -1673,7 +1652,6 @@
|
||||
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
|
||||
"until_they_submit_a_response": "持續詢問直到提交回應",
|
||||
"untitled_block": "未命名區塊",
|
||||
"update_options": "更新選項",
|
||||
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
|
||||
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
|
||||
"upload": "上傳",
|
||||
@@ -2046,9 +2024,9 @@
|
||||
"intro": {
|
||||
"get_started": "開始使用",
|
||||
"made_with_love_in_kiel": "用 🤍 在德國製造",
|
||||
"paragraph_1": "Formbricks 是一套體驗管理工具,建構於全球<b>成長最快的開源問卷平台</b>之上。",
|
||||
"paragraph_1": "Formbricks 是一套體驗管理套件,建立於全球<b>成長最快的開源問卷平台</b>之上。",
|
||||
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工<b>打造無法抗拒的體驗</b>。",
|
||||
"paragraph_3": "我們致力於最高等級的資料隱私。自我託管,讓您<b>完全掌控您的資料</b>。",
|
||||
"paragraph_3": "我們致力於最高程度的資料隱私權。自行託管以<b>完全掌控您的資料</b>。",
|
||||
"welcome_to_formbricks": "歡迎使用 Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<button
|
||||
key={surveyLanguage.language.code}
|
||||
className="w-full truncate rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
|
||||
className="w-full rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
setLanguage(surveyLanguage.language.code);
|
||||
setShowLanguageSelect(false);
|
||||
|
||||
@@ -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, getTeamProjectIds } from "../team";
|
||||
import { createTeamMembership } from "../team";
|
||||
|
||||
// Setup all mocks
|
||||
const setupMocks = () => {
|
||||
@@ -31,7 +31,6 @@ const setupMocks = () => {
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,7 +55,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 as unknown as any);
|
||||
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
|
||||
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
|
||||
|
||||
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
|
||||
@@ -91,7 +90,7 @@ describe("Team Management", () => {
|
||||
role: "member" as OrganizationRole,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
|
||||
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
|
||||
vi.mocked(prisma.teamUser.create).mockResolvedValue({
|
||||
...MOCK_TEAM_USER,
|
||||
role: "contributor",
|
||||
@@ -111,68 +110,11 @@ describe("Team Management", () => {
|
||||
|
||||
describe("error handling", () => {
|
||||
test("throws error when database operation fails", async () => {
|
||||
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
|
||||
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,18 +18,15 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
|
||||
for (const teamId of teamIds) {
|
||||
const team = await getTeamProjectIds(teamId, invite.organizationId);
|
||||
|
||||
if (!team) {
|
||||
logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance");
|
||||
continue;
|
||||
if (team) {
|
||||
await prisma.teamUser.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId,
|
||||
role: isOwnerOrManager ? "admin" : "contributor",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.teamUser.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId,
|
||||
role: isOwnerOrManager ? "admin" : "contributor",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
|
||||
@@ -42,10 +39,7 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
|
||||
};
|
||||
|
||||
export const getTeamProjectIds = reactCache(
|
||||
async (
|
||||
teamId: string,
|
||||
organizationId: string
|
||||
): Promise<{ projectTeams: { projectId: string }[] } | null> => {
|
||||
async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
@@ -61,7 +55,7 @@ export const getTeamProjectIds = reactCache(
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return null;
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
return team;
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const SignIn = ({ token, webAppUrl }) => {
|
||||
export const SignIn = ({ token }) => {
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
signIn("token", {
|
||||
token: token,
|
||||
callbackUrl: webAppUrl,
|
||||
callbackUrl: `/`,
|
||||
});
|
||||
}
|
||||
}, [token, webAppUrl]);
|
||||
}, [token]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -10,7 +9,7 @@ export const VerifyPage = async ({ searchParams }) => {
|
||||
return token ? (
|
||||
<FormWrapper>
|
||||
<p className="text-center">{t("auth.verify.verifying")}</p>
|
||||
<SignIn token={token} webAppUrl={WEBAPP_URL} />
|
||||
<SignIn token={token} />
|
||||
</FormWrapper>
|
||||
) : (
|
||||
<p className="text-center">{t("auth.verify.no_token_provided")}</p>
|
||||
|
||||
@@ -458,15 +458,21 @@ describe("Contacts Lib", () => {
|
||||
attributes: [{ attributeKey: { key: "email", id: "key-1" }, value: "john@example.com" }],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
|
||||
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.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);
|
||||
|
||||
@@ -483,15 +489,25 @@ describe("Contacts Lib", () => {
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
|
||||
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.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany)
|
||||
.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 });
|
||||
.mockResolvedValueOnce([
|
||||
{ key: "email", id: "key-1" },
|
||||
{ key: "userId", id: "key-2" },
|
||||
] as any);
|
||||
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
|
||||
|
||||
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap);
|
||||
@@ -509,15 +525,25 @@ describe("Contacts Lib", () => {
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
|
||||
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.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany)
|
||||
.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 });
|
||||
.mockResolvedValueOnce([
|
||||
{ key: "email", id: "key-1" },
|
||||
{ key: "userId", id: "key-2" },
|
||||
] as any);
|
||||
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
|
||||
|
||||
@@ -556,16 +582,23 @@ describe("Contacts Lib", () => {
|
||||
|
||||
test("creates missing attribute keys", async () => {
|
||||
const attributeMap = { email: "email", userId: "userId" };
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
|
||||
vi.mocked(prisma.contact.findMany)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{ key: "email", id: "key-1" },
|
||||
{ key: "userId", id: "key-2" },
|
||||
] as any);
|
||||
|
||||
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: 3 });
|
||||
|
||||
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: "new-1",
|
||||
environmentId: mockEnvironmentId,
|
||||
|
||||
@@ -200,50 +200,6 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
|
||||
}
|
||||
};
|
||||
|
||||
// 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<string, string>, 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<string, string>,
|
||||
existingContact: { id: string; attributes: { attributeKey: { key: string }; value: string }[] },
|
||||
existingUserIds: { value: string; contactId: string }[]
|
||||
): Record<string, string> => {
|
||||
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<string, string>[],
|
||||
environmentId: string,
|
||||
@@ -331,36 +287,22 @@ export const createContactsFromCSV = async (
|
||||
});
|
||||
|
||||
const attributeKeyMap = new Map<string, string>();
|
||||
// Map from lowercase key to actual DB key (for case-insensitive lookup)
|
||||
const lowercaseToActualKeyMap = new Map<string, string>();
|
||||
|
||||
existingAttributeKeys.forEach((attrKey) => {
|
||||
attributeKeyMap.set(attrKey.key, attrKey.id);
|
||||
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
|
||||
});
|
||||
|
||||
// Collect all unique CSV keys
|
||||
// Identify missing attribute keys (normalize keys to lowercase)
|
||||
const csvKeys = new Set<string>();
|
||||
csvData.forEach((record) => {
|
||||
Object.keys(record).forEach((key) => csvKeys.add(key));
|
||||
Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase()));
|
||||
});
|
||||
|
||||
// Identify missing attribute keys (case-insensitive check)
|
||||
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
|
||||
const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
|
||||
|
||||
// Create missing attribute keys (use original CSV casing for new keys)
|
||||
// Create missing attribute keys
|
||||
if (missingKeys.length > 0) {
|
||||
// Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
|
||||
const uniqueMissingKeys = new Map<string, string>();
|
||||
missingKeys.forEach((key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (!uniqueMissingKeys.has(lowerKey)) {
|
||||
uniqueMissingKeys.set(lowerKey, key);
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.contactAttributeKey.createMany({
|
||||
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
|
||||
data: missingKeys.map((key) => ({
|
||||
key,
|
||||
name: key,
|
||||
environmentId,
|
||||
@@ -368,10 +310,10 @@ export const createContactsFromCSV = async (
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// Fetch and update the maps with new keys
|
||||
// Fetch and update the attributeKeyMap with new keys
|
||||
const newAttributeKeys = await prisma.contactAttributeKey.findMany({
|
||||
where: {
|
||||
key: { in: Array.from(uniqueMissingKeys.values()) },
|
||||
key: { in: missingKeys },
|
||||
environmentId,
|
||||
},
|
||||
select: { key: true, id: true },
|
||||
@@ -379,7 +321,6 @@ export const createContactsFromCSV = async (
|
||||
|
||||
newAttributeKeys.forEach((attrKey) => {
|
||||
attributeKeyMap.set(attrKey.key, attrKey.id);
|
||||
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,23 +328,18 @@ export const createContactsFromCSV = async (
|
||||
|
||||
// Process contacts in parallel
|
||||
const contactPromises = csvData.map(async (record) => {
|
||||
// Map CSV keys to actual DB keys (case-insensitive matching, preserving DB key casing)
|
||||
const mappedRecord: Record<string, string> = {};
|
||||
// Normalize record keys to lowercase
|
||||
const normalizedRecord: Record<string, string> = {};
|
||||
Object.entries(record).forEach(([key, 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;
|
||||
normalizedRecord[key.toLowerCase()] = value;
|
||||
});
|
||||
|
||||
// Skip records without email
|
||||
if (!mappedRecord.email) {
|
||||
if (!normalizedRecord.email) {
|
||||
throw new ValidationError("Email is required for all contacts");
|
||||
}
|
||||
|
||||
const existingContact = emailToContactMap.get(mappedRecord.email);
|
||||
const existingContact = emailToContactMap.get(normalizedRecord.email);
|
||||
|
||||
if (existingContact) {
|
||||
// Handle duplicates based on duplicateContactsAction
|
||||
@@ -412,7 +348,25 @@ export const createContactsFromCSV = async (
|
||||
return null;
|
||||
|
||||
case "update": {
|
||||
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
|
||||
// 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 attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
|
||||
where: {
|
||||
@@ -429,7 +383,7 @@ export const createContactsFromCSV = async (
|
||||
}));
|
||||
|
||||
// Update contact with upserted attributes
|
||||
return prisma.contact.update({
|
||||
const updatedContact = prisma.contact.update({
|
||||
where: { id: existingContact.id },
|
||||
data: {
|
||||
attributes: {
|
||||
@@ -437,40 +391,98 @@ export const createContactsFromCSV = async (
|
||||
upsert: attributesToUpsert,
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
include: {
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return updatedContact;
|
||||
}
|
||||
|
||||
case "overwrite": {
|
||||
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
|
||||
// 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Overwrite by deleting existing attributes and creating new ones
|
||||
await prisma.contactAttribute.deleteMany({
|
||||
where: { contactId: existingContact.id },
|
||||
});
|
||||
|
||||
return prisma.contact.update({
|
||||
const newAttributes = Object.entries(recordToProcess).map(([key, value]) => ({
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value,
|
||||
}));
|
||||
|
||||
const updatedContact = prisma.contact.update({
|
||||
where: { id: existingContact.id },
|
||||
data: {
|
||||
attributes: {
|
||||
create: createAttributeConnections(recordToProcess, environmentId),
|
||||
create: newAttributes,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
});
|
||||
|
||||
return updatedContact;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new contact - use mappedRecord with proper DB key casing
|
||||
return prisma.contact.create({
|
||||
// Create new contact
|
||||
const newAttributes = Object.entries(record).map(([key, value]) => ({
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value,
|
||||
}));
|
||||
|
||||
const newContact = prisma.contact.create({
|
||||
data: {
|
||||
environmentId,
|
||||
attributes: {
|
||||
create: createAttributeConnections(mappedRecord, environmentId),
|
||||
create: newAttributes,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
});
|
||||
|
||||
return newContact;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getInstanceId, getInstanceInfo } from "@/lib/instance";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
@@ -56,7 +55,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
organization: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -72,11 +70,6 @@ vi.mock("@formbricks/logger", () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/instance", () => ({
|
||||
getInstanceId: vi.fn(),
|
||||
getInstanceInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants as they are used in the original license.ts indirectly
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
@@ -109,15 +102,6 @@ describe("License Core Logic", () => {
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
|
||||
vi.mocked(prisma.response.count).mockResolvedValue(100);
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
id: "test-org-id",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
} as any);
|
||||
vi.mocked(getInstanceId).mockResolvedValue("test-hashed-instance-id");
|
||||
vi.mocked(getInstanceInfo).mockResolvedValue({
|
||||
instanceId: "test-hashed-instance-id",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
// Mock window to be undefined for server-side tests
|
||||
vi.stubGlobal("window", undefined);
|
||||
|
||||
@@ -7,10 +7,8 @@ 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";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
@@ -262,23 +260,14 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
// first millisecond of next year => current year is fully included
|
||||
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
|
||||
|
||||
const [instanceId, responseCount] = await Promise.all([
|
||||
// Skip instance ID during E2E tests to avoid license key conflicts
|
||||
// as the instance ID changes with each test run
|
||||
E2E_TESTING ? null : getInstanceId(),
|
||||
prisma.response.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfYear,
|
||||
lt: startOfNextYear,
|
||||
},
|
||||
const responseCount = await prisma.response.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfYear,
|
||||
lt: startOfNextYear,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// No organization exists, cannot perform license check
|
||||
// (skip this check during E2E tests as we intentionally use null)
|
||||
if (!E2E_TESTING && !instanceId) return null;
|
||||
},
|
||||
});
|
||||
|
||||
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
@@ -286,17 +275,11 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount },
|
||||
};
|
||||
|
||||
if (instanceId) {
|
||||
payload.instanceId = instanceId;
|
||||
}
|
||||
|
||||
const res = await fetch(CONFIG.API.ENDPOINT, {
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount },
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
agent,
|
||||
|
||||
@@ -48,33 +48,29 @@ export function LanguageIndicator({
|
||||
<button
|
||||
aria-expanded={showLanguageDropdown}
|
||||
aria-haspopup="true"
|
||||
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
|
||||
className="relative z-20 flex items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
|
||||
onClick={toggleDropdown}
|
||||
tabIndex={-1}
|
||||
type="button">
|
||||
<span className="max-w-full truncate">
|
||||
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
|
||||
</span>
|
||||
<ChevronDown className="ml-1 h-4 w-4 flex-shrink-0" />
|
||||
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</button>
|
||||
{showLanguageDropdown ? (
|
||||
<div
|
||||
className="absolute right-0 z-30 mt-1 max-h-64 w-48 space-y-2 overflow-auto rounded-md bg-slate-900 p-1 text-xs text-white"
|
||||
className="absolute right-0 z-30 mt-1 max-h-64 space-y-2 overflow-auto rounded-md bg-slate-900 p-1 text-xs text-white"
|
||||
ref={languageDropdownRef}>
|
||||
{surveyLanguages.map(
|
||||
(language) =>
|
||||
language.language.code !== languageToBeDisplayed?.language.code &&
|
||||
language.enabled && (
|
||||
<button
|
||||
className="flex w-full rounded-sm p-1 text-left hover:bg-slate-700"
|
||||
className="block w-full rounded-sm p-1 text-left hover:bg-slate-700"
|
||||
key={language.language.id}
|
||||
onClick={() => {
|
||||
changeLanguage(language);
|
||||
}}
|
||||
type="button">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{getLanguageLabel(language.language.code, locale)}
|
||||
</span>
|
||||
{getLanguageLabel(language.language.code, locale)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AddMemberRole({
|
||||
name="role"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label>{t("environments.settings.teams.organization_role")}</Label>
|
||||
<Label>{t("common.role_organization")}</Label>
|
||||
<Select
|
||||
defaultValue={isAccessControlAllowed ? "member" : "owner"}
|
||||
disabled={!isAccessControlAllowed}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId, getTeamsWhereUserIsAdmin } from "./roles";
|
||||
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
projectTeam: { findMany: vi.fn() },
|
||||
teamUser: { findUnique: vi.fn(), findMany: vi.fn() },
|
||||
teamUser: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -19,7 +19,6 @@ 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(() => {
|
||||
@@ -91,7 +90,7 @@ describe("roles lib", () => {
|
||||
});
|
||||
|
||||
test("returns role if teamUser exists", async () => {
|
||||
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" } as unknown as any);
|
||||
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
|
||||
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
|
||||
expect(result).toBe("member");
|
||||
});
|
||||
@@ -111,47 +110,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,31 +83,3 @@ export const getTeamRoleByTeamIdUserId = reactCache(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getTeamsWhereUserIsAdmin = reactCache(
|
||||
async (userId: string, organizationId: string): Promise<string[]> => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
|
||||
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IdBadge id={team.id} />
|
||||
<IdBadge id={team.id} showCopyIconOnHover={true} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
|
||||
|
||||
@@ -9,9 +9,10 @@ import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
interface AccessViewProps {
|
||||
teams: TProjectTeam[];
|
||||
environmentId: string;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const AccessView = ({ teams, environmentId }: AccessViewProps) => {
|
||||
export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
@@ -19,7 +20,7 @@ export const AccessView = ({ teams, environmentId }: AccessViewProps) => {
|
||||
title={t("common.team_access")}
|
||||
description={t("environments.project.teams.team_settings_description")}>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<ManageTeam environmentId={environmentId} />
|
||||
<ManageTeam environmentId={environmentId} isOwnerOrManager={isOwnerOrManager} />
|
||||
</div>
|
||||
<AccessTable teams={teams} />
|
||||
</SettingsCard>
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
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 }: ManageTeamProps) => {
|
||||
export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -17,9 +19,20 @@ export const ManageTeam = ({ environmentId }: ManageTeamProps) => {
|
||||
router.push(`/environments/${environmentId}/settings/teams`);
|
||||
};
|
||||
|
||||
if (isOwnerOrManager) {
|
||||
return (
|
||||
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
|
||||
{t("environments.project.teams.manage_teams")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
|
||||
{t("environments.project.teams.manage_teams")}
|
||||
</Button>
|
||||
<TooltipRenderer
|
||||
tooltipContent={t("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")}>
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
{t("environments.project.teams.manage_teams")}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { project } = await getEnvironmentAuth(params.environmentId);
|
||||
const { project, isOwner, isManager } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const teams = await getTeamsByProjectId(project.id);
|
||||
|
||||
@@ -18,12 +18,14 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
|
||||
throw new Error(t("common.teams_not_found"));
|
||||
}
|
||||
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
||||
<ProjectConfigNavigation environmentId={params.environmentId} activeId="teams" />
|
||||
</PageHeader>
|
||||
<AccessView environmentId={params.environmentId} teams={teams} />
|
||||
<AccessView environmentId={params.environmentId} teams={teams} isOwnerOrManager={isOwnerOrManager} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
@@ -80,16 +80,6 @@ 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,
|
||||
@@ -269,44 +259,34 @@ export const TeamSettingsModal = ({
|
||||
<FormField
|
||||
control={control}
|
||||
name={`members.${index}.userId`}
|
||||
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 (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
handleMemberSelectionChange(index, val);
|
||||
}}
|
||||
disabled={isSelectDisabled}
|
||||
value={member.userId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("environments.settings.teams.select_member")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memberOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`member-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
handleMemberSelectionChange(index, val);
|
||||
}}
|
||||
disabled={!isOwnerOrManager && !isTeamAdminMember}
|
||||
value={member.userId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select member" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memberOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`member-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
@@ -348,20 +328,18 @@ export const TeamSettingsModal = ({
|
||||
|
||||
{/* Delete Button for Member */}
|
||||
{watchMembers.length > 1 && (
|
||||
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="shrink-0"
|
||||
disabled={
|
||||
!isOwnerOrManager &&
|
||||
(!isTeamAdminMember || member.userId === currentUserId)
|
||||
}
|
||||
onClick={() => handleRemoveMember(index)}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
disabled={
|
||||
!isOwnerOrManager &&
|
||||
(!isTeamAdminMember || member.userId === currentUserId)
|
||||
}
|
||||
onClick={() => handleRemoveMember(index)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -382,7 +360,7 @@ export const TeamSettingsModal = ({
|
||||
: t("environments.settings.teams.all_members_added")
|
||||
}>
|
||||
<Button
|
||||
size="sm"
|
||||
size="default"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddMember}
|
||||
@@ -418,40 +396,31 @@ export const TeamSettingsModal = ({
|
||||
<FormField
|
||||
control={control}
|
||||
name={`projects.${index}.projectId`}
|
||||
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 (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={project.projectId}
|
||||
disabled={isSelectDisabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("environments.settings.teams.select_project")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`project-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={project.projectId}
|
||||
disabled={!isOwnerOrManager}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`project-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
@@ -512,7 +481,7 @@ export const TeamSettingsModal = ({
|
||||
: t("environments.settings.teams.all_projects_added")
|
||||
}>
|
||||
<Button
|
||||
size="sm"
|
||||
size="default"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddProject}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
|
||||
return (
|
||||
<Container>
|
||||
{overrideFileUploadResponse ? (
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap italic">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
|
||||
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -54,17 +54,20 @@ export const renderEmailResponseValue = async (
|
||||
<Container>
|
||||
<Row className="mb-2 text-sm text-slate-700" dir="auto">
|
||||
{Array.isArray(response) &&
|
||||
response.filter(Boolean).map((item, index) => (
|
||||
<Row key={item} className="mb-1 flex items-center">
|
||||
<Column className="w-6 text-slate-400">#{index + 1}</Column>
|
||||
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
|
||||
</Row>
|
||||
))}
|
||||
response.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<Row key={item} className="mb-1 flex items-center">
|
||||
<Column className="w-6 text-slate-400">#{index + 1}</Column>
|
||||
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
|
||||
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,17 +52,9 @@ export async function ResponseFinishedEmail({
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
{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];
|
||||
{survey.variables.map((variable) => {
|
||||
const variableResponse = response.variables[variable.id];
|
||||
if (variableResponse && ["number", "string"].includes(typeof variable)) {
|
||||
return (
|
||||
<Row key={variable.id}>
|
||||
<Column className="w-full text-sm font-medium">
|
||||
@@ -74,33 +66,33 @@ export async function ResponseFinishedEmail({
|
||||
)}
|
||||
{variable.name}
|
||||
</Text>
|
||||
<Text className="mt-0 font-medium break-words whitespace-pre-wrap">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
{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 null;
|
||||
})}
|
||||
{survey.hiddenFields.fieldIds?.map((hiddenFieldId) => {
|
||||
const hiddenFieldResponse = response.data[hiddenFieldId];
|
||||
if (hiddenFieldResponse && typeof hiddenFieldResponse === "string") {
|
||||
return (
|
||||
<Row key={hiddenFieldId}>
|
||||
<Column className="w-full font-medium">
|
||||
<Text className="mb-2 flex items-center gap-2 text-sm">
|
||||
{hiddenFieldId} <EyeOffIcon />
|
||||
</Text>
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<EmailButton
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA`}
|
||||
label={
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OrganizationRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZId, ZUuid } from "@formbricks/types/common";
|
||||
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { createInviteToken } from "@/lib/jwt";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
@@ -16,7 +16,6 @@ import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
|
||||
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
|
||||
import { sendInviteMemberEmail } from "@/modules/email";
|
||||
import {
|
||||
deleteMembership,
|
||||
@@ -196,55 +195,19 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
|
||||
)
|
||||
);
|
||||
|
||||
const validateTeamAdminInvitePermissions = (
|
||||
inviterRole: TOrganizationRole,
|
||||
inviterAdminTeams: string[],
|
||||
inviteRole: TOrganizationRole,
|
||||
inviteTeamIds: string[]
|
||||
): void => {
|
||||
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().trim().min(1, "Name is required"),
|
||||
name: z.string(),
|
||||
role: ZOrganizationRole,
|
||||
teamIds: z.array(ZId),
|
||||
teamIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"invite",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZInviteUserAction>;
|
||||
}) => {
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
if (INVITE_DISABLED) {
|
||||
throw new AuthenticationError("Invite disabled");
|
||||
}
|
||||
@@ -261,41 +224,16 @@ export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserActi
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (currentUserMembership.role === "manager" && parsedInput.role !== "member") {
|
||||
throw new OperationNotAllowedError("Managers can only invite users as members");
|
||||
|
||||
@@ -37,8 +37,6 @@ interface OrganizationActionsProps {
|
||||
isMultiOrgEnabled: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
userAdminTeamIds?: string[];
|
||||
}
|
||||
|
||||
export const OrganizationActions = ({
|
||||
@@ -54,20 +52,16 @@ export const OrganizationActions = ({
|
||||
isMultiOrgEnabled,
|
||||
isUserManagementDisabledFromUi,
|
||||
isStorageConfigured,
|
||||
isTeamAdmin,
|
||||
userAdminTeamIds,
|
||||
}: OrganizationActionsProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [isLeaveOrganizationModalOpen, setIsLeaveOrganizationModalOpen] = useState(false);
|
||||
const [isInviteMemberModalOpen, setIsInviteMemberModalOpen] = useState(false);
|
||||
const [isLeaveOrganizationModalOpen, setLeaveOrganizationModalOpen] = useState(false);
|
||||
const [isInviteMemberModalOpen, setInviteMemberModalOpen] = 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 {
|
||||
@@ -140,18 +134,18 @@ export const OrganizationActions = ({
|
||||
<>
|
||||
<div className="mb-4 flex justify-end space-x-2 text-right">
|
||||
{role !== "owner" && isMultiOrgEnabled && (
|
||||
<Button variant="destructive" size="sm" onClick={() => setIsLeaveOrganizationModalOpen(true)}>
|
||||
<Button variant="secondary" size="sm" onClick={() => setLeaveOrganizationModalOpen(true)}>
|
||||
{t("environments.settings.general.leave_organization")}
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isInviteDisabled && canInvite && !isUserManagementDisabledFromUi && (
|
||||
{!isInviteDisabled && isOwnerOrManager && !isUserManagementDisabledFromUi && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsInviteMemberModalOpen(true);
|
||||
setInviteMemberModalOpen(true);
|
||||
}}>
|
||||
{t("environments.settings.teams.invite_member")}
|
||||
</Button>
|
||||
@@ -159,7 +153,7 @@ export const OrganizationActions = ({
|
||||
</div>
|
||||
<InviteMemberModal
|
||||
open={isInviteMemberModalOpen}
|
||||
setOpen={setIsInviteMemberModalOpen}
|
||||
setOpen={setInviteMemberModalOpen}
|
||||
onSubmit={handleAddMembers}
|
||||
membershipRole={membershipRole}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
@@ -167,12 +161,9 @@ export const OrganizationActions = ({
|
||||
environmentId={environmentId}
|
||||
teams={teams}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isTeamAdmin={isTeamAdmin}
|
||||
userAdminTeamIds={userAdminTeamIds}
|
||||
/>
|
||||
|
||||
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setIsLeaveOrganizationModalOpen}>
|
||||
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setLeaveOrganizationModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.settings.general.leave_organization_title")}</DialogTitle>
|
||||
@@ -186,7 +177,7 @@ export const OrganizationActions = ({
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setIsLeaveOrganizationModalOpen(false)}>
|
||||
<Button variant="secondary" onClick={() => setLeaveOrganizationModalOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -7,14 +7,13 @@ import { useRouter } from "next/navigation";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
@@ -28,7 +27,6 @@ interface IndividualInviteTabProps {
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
membershipRole?: TOrganizationRole;
|
||||
showTeamAdminRestrictions: boolean;
|
||||
}
|
||||
|
||||
export const IndividualInviteTab = ({
|
||||
@@ -39,32 +37,22 @@ export const IndividualInviteTab = ({
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
membershipRole,
|
||||
showTeamAdminRestrictions,
|
||||
}: IndividualInviteTabProps) => {
|
||||
const ZFormSchema = z.object({
|
||||
name: ZUserName,
|
||||
email: z.string().min(1, { message: "Email is required" }).email({ message: "Invalid email" }),
|
||||
role: ZOrganizationRole,
|
||||
teamIds: showTeamAdminRestrictions
|
||||
? z.array(ZId).min(1, { message: "Team admins must select at least one team" })
|
||||
: z.array(ZId),
|
||||
teamIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
type TFormData = z.infer<typeof ZFormSchema>;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Determine default role based on permissions
|
||||
let defaultRole: TOrganizationRole = "owner";
|
||||
if (showTeamAdminRestrictions || isAccessControlAllowed) {
|
||||
defaultRole = "member";
|
||||
}
|
||||
|
||||
const form = useForm<TFormData>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
role: defaultRole,
|
||||
role: isAccessControlAllowed ? "member" : "owner",
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
@@ -116,61 +104,43 @@ export const IndividualInviteTab = ({
|
||||
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
{showTeamAdminRestrictions ? (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="memberRoleSelect">{t("environments.settings.teams.organization_role")}</Label>
|
||||
<Input value={t("environments.settings.teams.member")} disabled />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AddMemberRole
|
||||
control={control}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
{watch("role") === "member" && (
|
||||
<Alert className="mt-2" variant="info">
|
||||
<AlertDescription>
|
||||
{t("environments.settings.teams.member_role_info_message")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
<AddMemberRole
|
||||
control={control}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
{watch("role") === "member" && (
|
||||
<Alert className="mt-2" variant="info">
|
||||
<AlertDescription>{t("environments.settings.teams.member_role_info_message")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAccessControlAllowed && (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="teamIds"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col space-y-2">
|
||||
<FormLabel>{t("common.add_to_team")} </FormLabel>
|
||||
<div className="space-y-2">
|
||||
<MultiSelect
|
||||
value={field.value}
|
||||
options={teamOptions}
|
||||
placeholder={t("environments.settings.teams.team_select_placeholder")}
|
||||
disabled={!teamOptions.length}
|
||||
onChange={(val) => field.onChange(val)}
|
||||
/>
|
||||
{!teamOptions.length && (
|
||||
<Small className="font-normal text-amber-600">
|
||||
{t("environments.settings.teams.create_first_team_message")}
|
||||
</Small>
|
||||
)}
|
||||
</div>
|
||||
<FormError>{errors.teamIds?.message}</FormError>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="teamRoleInput">{t("common.team_role")}</Label>
|
||||
<Input value={t("environments.settings.teams.contributor")} disabled />
|
||||
</div>
|
||||
</>
|
||||
<FormField
|
||||
control={control}
|
||||
name="teamIds"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col space-y-2">
|
||||
<FormLabel>{t("common.add_to_team")} </FormLabel>
|
||||
<div className="space-y-2">
|
||||
<MultiSelect
|
||||
value={field.value}
|
||||
options={teamOptions}
|
||||
placeholder={t("environments.settings.teams.team_select_placeholder")}
|
||||
disabled={!teamOptions.length}
|
||||
onChange={(val) => field.onChange(val)}
|
||||
/>
|
||||
{!teamOptions.length && (
|
||||
<Small className="font-normal text-amber-600">
|
||||
{t("environments.settings.teams.create_first_team_message")}
|
||||
</Small>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAccessControlAllowed && (
|
||||
|
||||
@@ -26,9 +26,6 @@ interface InviteMemberModalProps {
|
||||
environmentId: string;
|
||||
membershipRole?: TOrganizationRole;
|
||||
isStorageConfigured: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
userAdminTeamIds?: string[];
|
||||
}
|
||||
|
||||
export const InviteMemberModal = ({
|
||||
@@ -41,21 +38,11 @@ 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: (
|
||||
<IndividualInviteTab
|
||||
@@ -64,9 +51,8 @@ export const InviteMemberModal = ({
|
||||
onSubmit={onSubmit}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
teams={filteredTeams}
|
||||
teams={teams}
|
||||
membershipRole={membershipRole}
|
||||
showTeamAdminRestrictions={showTeamAdminRestrictions}
|
||||
/>
|
||||
),
|
||||
bulk: (
|
||||
@@ -89,18 +75,16 @@ export const InviteMemberModal = ({
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex flex-col gap-6" unconstrained>
|
||||
{!showTeamAdminRestrictions && (
|
||||
<TabToggle
|
||||
id="type"
|
||||
options={[
|
||||
{ value: "individual", label: t("environments.settings.teams.individual") },
|
||||
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
|
||||
]}
|
||||
onChange={(inviteType) => setType(inviteType)}
|
||||
defaultSelected={type}
|
||||
/>
|
||||
)}
|
||||
{showTeamAdminRestrictions ? tabs.individual : tabs[type]}
|
||||
<TabToggle
|
||||
id="type"
|
||||
options={[
|
||||
{ value: "individual", label: t("environments.settings.teams.individual") },
|
||||
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
|
||||
]}
|
||||
onChange={(inviteType) => setType(inviteType)}
|
||||
defaultSelected={type}
|
||||
/>
|
||||
{tabs[type]}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,7 +5,6 @@ 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";
|
||||
@@ -46,10 +45,6 @@ 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) {
|
||||
@@ -74,8 +69,6 @@ export const MembersView = async ({
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
teams={teams}
|
||||
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
|
||||
isTeamAdmin={isTeamAdminUser}
|
||||
userAdminTeamIds={userAdminTeamIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -17,21 +16,11 @@ export const TeamsPage = async (props) => {
|
||||
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||
|
||||
// Check if user has standard user management access (owner/manager)
|
||||
const hasStandardUserManagementAccess = getUserManagementAccess(
|
||||
const hasUserManagementAccess = 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 (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
|
||||
@@ -38,7 +38,6 @@ interface ThemeStylingProps {
|
||||
isUnsplashConfigured: boolean;
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ThemeStyling = ({
|
||||
@@ -48,7 +47,6 @@ export const ThemeStyling = ({
|
||||
isUnsplashConfigured,
|
||||
isReadOnly,
|
||||
isStorageConfigured = true,
|
||||
publicDomain,
|
||||
}: ThemeStylingProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -156,6 +154,7 @@ export const ThemeStyling = ({
|
||||
open={cardStylingOpen}
|
||||
setOpen={setCardStylingOpen}
|
||||
isSettingsPage
|
||||
project={project}
|
||||
surveyType={previewSurveyType}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
/>
|
||||
@@ -201,7 +200,6 @@ export const ThemeStyling = ({
|
||||
}}
|
||||
previewType={previewSurveyType}
|
||||
setPreviewType={setPreviewSurveyType}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -28,7 +27,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
|
||||
}
|
||||
|
||||
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -51,7 +49,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
|
||||
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
|
||||
@@ -20,8 +20,7 @@ import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
|
||||
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
|
||||
import { updateSurvey, updateSurveyDraft } from "@/modules/survey/editor/lib/survey";
|
||||
import { TSurveyDraft, ZSurveyDraft } from "@/modules/survey/editor/types/survey";
|
||||
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
@@ -47,62 +46,6 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurveyDraftAction = authenticatedActionClient.schema(ZSurveyDraft).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurveyDraft }) => {
|
||||
// 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",
|
||||
|
||||
@@ -133,16 +133,13 @@ export const BlockCard = ({
|
||||
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
const hasInvalidButtonLabel =
|
||||
block.buttonLabel !== undefined &&
|
||||
block.buttonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
|
||||
block.buttonLabel !== undefined && !isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
|
||||
|
||||
// Check if back button label is invalid
|
||||
// Back button label should exist for all blocks except the first one
|
||||
const hasInvalidBackButtonLabel =
|
||||
blockIdx > 0 &&
|
||||
block.backButtonLabel !== undefined &&
|
||||
block.backButtonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.backButtonLabel, surveyLanguages);
|
||||
|
||||
// Block should be highlighted if it has invalid elements OR invalid button labels
|
||||
@@ -284,7 +281,7 @@ export const BlockCard = ({
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
aria-label="Drag to reorder block">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -294,29 +291,29 @@ export const BlockCard = ({
|
||||
open={!isBlockCollapsed}
|
||||
onOpenChange={() => setIsBlockCollapsed(!isBlockCollapsed)}
|
||||
className={cn(isBlockCollapsed ? "h-full" : "")}>
|
||||
<Collapsible.CollapsibleTrigger asChild>
|
||||
<div className="block h-full w-full cursor-pointer hover:bg-slate-100">
|
||||
<div className="flex h-full items-center justify-between px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{blockElementsCount} {blockElementsCountText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="block h-full w-full cursor-pointer hover:bg-slate-100">
|
||||
<div className="flex h-full items-center justify-between px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<BlockMenu
|
||||
isFirstBlock={blockIdx === 0}
|
||||
isLastBlock={blockIdx === totalBlocks - 1}
|
||||
isOnlyBlock={totalBlocks === 1}
|
||||
onDuplicate={() => duplicateBlock(block.id)}
|
||||
onDelete={() => deleteBlock(block.id)}
|
||||
onMoveUp={() => moveBlock(block.id, "up")}
|
||||
onMoveDown={() => moveBlock(block.id, "down")}
|
||||
/>
|
||||
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{blockElementsCount} {blockElementsCountText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BlockMenu
|
||||
isFirstBlock={blockIdx === 0}
|
||||
isLastBlock={blockIdx === totalBlocks - 1}
|
||||
isOnlyBlock={totalBlocks === 1}
|
||||
onDuplicate={() => duplicateBlock(block.id)}
|
||||
onDelete={() => deleteBlock(block.id)}
|
||||
onMoveUp={() => moveBlock(block.id, "up")}
|
||||
onMoveDown={() => moveBlock(block.id, "down")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"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<string | null>(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 (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedLanguageName
|
||||
? t("environments.surveys.edit.bulk_edit_options_for", { language: selectedLanguageName })
|
||||
: t("environments.surveys.edit.bulk_edit_options")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("environments.surveys.edit.bulk_edit_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={textareaValue}
|
||||
onChange={(e) => {
|
||||
setTextareaValue(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.shiftKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
rows={15}
|
||||
className="focus:border-brand w-full rounded-md border border-slate-300 bg-white p-3 font-mono text-sm focus:outline-none"
|
||||
placeholder={t("environments.surveys.edit.bulk_edit_description")}
|
||||
/>
|
||||
{validationError && <div className="text-sm text-red-600">{validationError}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{t("environments.surveys.edit.update_options")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,6 @@ interface EditWelcomeCardProps {
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const EditWelcomeCard = ({
|
||||
@@ -35,7 +34,6 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: EditWelcomeCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -67,7 +65,7 @@ export const EditWelcomeCard = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<Hand className="h-4 w-4" />
|
||||
@@ -137,7 +135,6 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
@@ -153,7 +150,6 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +170,6 @@ export const EditWelcomeCard = ({
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -513,8 +513,8 @@ export const ElementsView = ({
|
||||
id: newBlockId,
|
||||
name: getBlockName(index ?? prevSurvey.blocks.length),
|
||||
elements: [{ ...updatedElement, isDraft: true }],
|
||||
buttonLabel: createI18nString("", []),
|
||||
backButtonLabel: createI18nString("", []),
|
||||
buttonLabel: createI18nString(t("templates.next"), []),
|
||||
backButtonLabel: createI18nString(t("templates.back"), []),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -808,7 +808,6 @@ export const ElementsView = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { ChangeEvent, useRef, useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
|
||||
type LogoSettingsCardProps = {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
environmentId: string;
|
||||
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
|
||||
disabled?: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
};
|
||||
|
||||
export const LogoSettingsCard = ({
|
||||
open,
|
||||
setOpen,
|
||||
environmentId,
|
||||
form,
|
||||
disabled = false,
|
||||
isStorageConfigured,
|
||||
}: LogoSettingsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [parent] = useAutoAnimate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const logoUrl = form.watch("logo")?.url;
|
||||
const logoBgColor = form.watch("logo")?.bgColor;
|
||||
const isBgColorEnabled = !!logoBgColor;
|
||||
const isLogoHidden = form.watch("isLogoHidden");
|
||||
|
||||
const setLogoUrl = (url: string | undefined) => {
|
||||
const currentLogo = form.getValues("logo");
|
||||
form.setValue("logo", url ? { ...currentLogo, url } : undefined);
|
||||
};
|
||||
|
||||
const setLogoBgColor = (bgColor: string | undefined) => {
|
||||
const currentLogo = form.getValues("logo");
|
||||
form.setValue("logo", {
|
||||
...currentLogo,
|
||||
url: logoUrl,
|
||||
bgColor,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (files: string[]) => {
|
||||
if (files.length > 0) {
|
||||
setLogoUrl(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHiddenFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!isStorageConfigured) {
|
||||
showStorageNotConfiguredToast();
|
||||
return;
|
||||
}
|
||||
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId);
|
||||
if (uploadResult.error) {
|
||||
toast.error(t("common.upload_failed"));
|
||||
return;
|
||||
}
|
||||
setLogoUrl(uploadResult.url);
|
||||
} catch {
|
||||
toast.error(t("common.upload_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLogo = () => {
|
||||
form.setValue("logo", undefined);
|
||||
};
|
||||
|
||||
const toggleBackgroundColor = (enabled: boolean) => {
|
||||
setLogoBgColor(enabled ? logoBgColor || "#f8f8f8" : undefined);
|
||||
};
|
||||
|
||||
const handleBgColorChange = (color: string) => {
|
||||
setLogoBgColor(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={(openState) => {
|
||||
if (disabled) return;
|
||||
setOpen(openState);
|
||||
}}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full cursor-pointer rounded-lg hover:bg-slate-50",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
|
||||
)}>
|
||||
<div className="inline-flex w-full px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-base font-semibold text-slate-800">
|
||||
{t("environments.surveys.edit.logo_settings")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t("environments.surveys.edit.customize_survey_logo")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<div className="flex flex-col gap-6 p-6 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isLogoHidden"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={field.onChange} disabled={disabled} />
|
||||
</FormControl>
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
{t("environments.surveys.edit.hide_logo")}
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
{t("environments.surveys.edit.hide_logo_from_survey")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isLogoHidden && (
|
||||
<div className="space-y-4">
|
||||
<div className="font-medium text-slate-800">
|
||||
{t("environments.surveys.edit.overwrite_survey_logo")}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for replacing logo */}
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg, image/png, image/webp, image/heic"
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
onChange={handleHiddenFileChange}
|
||||
/>
|
||||
|
||||
{logoUrl ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt="Survey Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
style={{ backgroundColor: logoBgColor || undefined }}
|
||||
className="h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isStorageConfigured) {
|
||||
showStorageNotConfiguredToast();
|
||||
return;
|
||||
}
|
||||
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={disabled || isLoading}>
|
||||
{t("environments.project.look.replace_logo")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRemoveLogo}
|
||||
disabled={disabled}>
|
||||
{t("environments.project.look.remove_logo")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={isBgColorEnabled}
|
||||
onToggle={toggleBackgroundColor}
|
||||
htmlId="surveyLogoBgColor"
|
||||
title={t("environments.project.look.add_background_color")}
|
||||
description={t("environments.project.look.add_background_color_description")}
|
||||
childBorder
|
||||
customContainerClass="p-0"
|
||||
childrenContainerClass="overflow-visible"
|
||||
disabled={disabled}>
|
||||
{isBgColorEnabled && (
|
||||
<div className="px-2">
|
||||
<ColorPicker
|
||||
color={logoBgColor || "#f8f8f8"}
|
||||
onChange={handleBgColorChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdvancedOptionToggle>
|
||||
</>
|
||||
) : (
|
||||
<FileInput
|
||||
id="survey-logo-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={handleFileInputChange}
|
||||
disabled={disabled}
|
||||
maxSizeInMB={5}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
@@ -8,14 +8,12 @@ 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";
|
||||
@@ -51,7 +49,6 @@ export const MultipleChoiceElementForm = ({
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
const [isInvalidValue, setisInvalidValue] = useState<string | null>(null);
|
||||
const [isBulkEditOpen, setIsBulkEditOpen] = useState(false);
|
||||
|
||||
const elementRef = useRef<HTMLInputElement>(null);
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -93,31 +90,11 @@ 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 [
|
||||
...regularChoicesFromInput,
|
||||
...(otherChoice ? [otherChoice] : []),
|
||||
...(noneChoice ? [noneChoice] : []),
|
||||
];
|
||||
return [...regularChoices, ...(otherChoice ? [otherChoice] : []), ...(noneChoice ? [noneChoice] : [])];
|
||||
};
|
||||
|
||||
const addChoice = (choiceIdx?: number) => {
|
||||
@@ -306,7 +283,7 @@ export const MultipleChoiceElementForm = ({
|
||||
updateElement(elementIdx, { choices: newChoices });
|
||||
}}>
|
||||
<SortableContext items={element.choices} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex max-h-[25dvh] flex-col gap-2 overflow-y-auto py-1 pr-1" ref={parent}>
|
||||
<div className="flex flex-col gap-2" ref={parent}>
|
||||
{element.choices?.map((choice, choiceIdx) => (
|
||||
<ElementOptionChoice
|
||||
key={choice.id}
|
||||
@@ -331,9 +308,6 @@ export const MultipleChoiceElementForm = ({
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 flex items-center justify-between space-x-2">
|
||||
<div className="flex gap-2">
|
||||
{specialChoices.map((specialChoice) => {
|
||||
@@ -349,9 +323,6 @@ export const MultipleChoiceElementForm = ({
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button size="sm" variant="secondary" type="button" onClick={() => setIsBulkEditOpen(true)}>
|
||||
{bulkEditButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -381,23 +352,6 @@ export const MultipleChoiceElementForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BulkEditOptionsModal
|
||||
isOpen={isBulkEditOpen}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { defaultStyling } from "@/lib/styling/constants";
|
||||
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
||||
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { BackgroundStylingCard } from "@/modules/ui/components/background-styling-card";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -65,7 +64,6 @@ export const StylingView = ({
|
||||
const setOverwriteThemeStyling = (value: boolean) => form.setValue("overwriteThemeStyling", value);
|
||||
|
||||
const [formStylingOpen, setFormStylingOpen] = useState(false);
|
||||
const [logoSettingsOpen, setLogoSettingsOpen] = useState(false);
|
||||
const [cardStylingOpen, setCardStylingOpen] = useState(false);
|
||||
const [stylingOpen, setStylingOpen] = useState(false);
|
||||
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
|
||||
@@ -90,7 +88,6 @@ export const StylingView = ({
|
||||
useEffect(() => {
|
||||
if (!overwriteThemeStyling) {
|
||||
setFormStylingOpen(false);
|
||||
setLogoSettingsOpen(false);
|
||||
setCardStylingOpen(false);
|
||||
setStylingOpen(false);
|
||||
}
|
||||
@@ -201,31 +198,21 @@ export const StylingView = ({
|
||||
setOpen={setCardStylingOpen}
|
||||
surveyType={localSurvey.type}
|
||||
disabled={!overwriteThemeStyling}
|
||||
project={project}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
/>
|
||||
|
||||
{localSurvey.type === "link" && (
|
||||
<>
|
||||
<BackgroundStylingCard
|
||||
open={stylingOpen}
|
||||
setOpen={setStylingOpen}
|
||||
environmentId={environmentId}
|
||||
colors={colors}
|
||||
disabled={!overwriteThemeStyling}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
|
||||
<LogoSettingsCard
|
||||
open={logoSettingsOpen}
|
||||
setOpen={setLogoSettingsOpen}
|
||||
disabled={!overwriteThemeStyling}
|
||||
environmentId={environmentId}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</>
|
||||
<BackgroundStylingCard
|
||||
open={stylingOpen}
|
||||
setOpen={setStylingOpen}
|
||||
environmentId={environmentId}
|
||||
colors={colors}
|
||||
disabled={!overwriteThemeStyling}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCxMode && (
|
||||
|
||||
@@ -50,7 +50,6 @@ interface SurveyEditorProps {
|
||||
isStorageConfigured: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -80,7 +79,6 @@ export const SurveyEditor = ({
|
||||
isStorageConfigured,
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
publicDomain,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
|
||||
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
||||
@@ -274,7 +272,6 @@ export const SurveyEditor = ({
|
||||
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
|
||||
languageCode={selectedLanguageCode}
|
||||
isSpamProtectionAllowed={isSpamProtectionAllowed}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -19,12 +19,11 @@ 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, updateSurveyDraftAction } from "../actions";
|
||||
import { updateSurveyAction } from "../actions";
|
||||
import { isSurveyValid } from "../lib/validation";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
@@ -228,38 +227,6 @@ export const SurveyMenuBar = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
// Add new handler after handleSurveySave
|
||||
const handleSurveySaveDraft = async (): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
setIsSurveySaving(true);
|
||||
|
||||
@@ -400,7 +367,7 @@ export const SurveyMenuBar = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
|
||||
{!isStorageConfigured && (
|
||||
<div>
|
||||
<Alert variant="warning" size="small">
|
||||
@@ -431,11 +398,12 @@ export const SurveyMenuBar = ({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => (localSurvey.status === "draft" ? handleSurveySaveDraft() : handleSurveySave())}
|
||||
onClick={() => handleSurveySave()}
|
||||
type="submit">
|
||||
{localSurvey.status === "draft" ? t("common.save_as_draft") : t("common.save")}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{localSurvey.status !== "draft" && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
|
||||
@@ -4,11 +4,10 @@ import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { updateSurveyInternal } from "@/lib/survey/service";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { checkTriggersValidity, handleTriggerUpdates, updateSurvey, updateSurveyDraft } from "./survey";
|
||||
import { checkTriggersValidity, handleTriggerUpdates, updateSurvey } from "./survey";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -27,10 +26,6 @@ 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(),
|
||||
}));
|
||||
@@ -697,89 +692,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,18 +4,12 @@ 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<TSurvey> => {
|
||||
// Use internal version with skipValidation=true to allow incomplete drafts
|
||||
return updateSurveyInternal(updatedSurvey, true);
|
||||
};
|
||||
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
try {
|
||||
const surveyId = updatedSurvey.id;
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -106,7 +105,6 @@ export const SurveyEditorPage = async (props) => {
|
||||
}
|
||||
|
||||
const isCxMode = searchParams.mode === "cx";
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<SurveyEditor
|
||||
@@ -136,7 +134,6 @@ export const SurveyEditorPage = async (props) => {
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,8 +9,6 @@ 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<typeof ZCreateSurveyFollowUpFormSchema>;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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<typeof ZSurveyDraft>;
|
||||
@@ -1,21 +1,34 @@
|
||||
import { Column, Hr, Row, Text } from "@react-email/components";
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import dompurify from "isomorphic-dompurify";
|
||||
import React from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
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;
|
||||
}
|
||||
@@ -29,97 +42,91 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
|
||||
|
||||
const elements = props.attachResponseData ? getElementResponseMapping(props.survey, props.response) : [];
|
||||
const t = await getTranslate();
|
||||
// If the logo is not set, we are not using white labeling
|
||||
const isDefaultLogo = !props.logoUrl || props.logoUrl === fbLogoUrl;
|
||||
|
||||
return (
|
||||
<EmailTemplate logoUrl={props.logoUrl} t={t}>
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(body, {
|
||||
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
|
||||
allowedAttributes: {
|
||||
a: ["href", "rel", "target"],
|
||||
"*": ["dir", "class"],
|
||||
},
|
||||
allowedSchemes: ["http", "https"],
|
||||
allowedSchemesByTag: {
|
||||
a: ["http", "https"],
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<Body
|
||||
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-slate-800"
|
||||
style={{
|
||||
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
|
||||
}}>
|
||||
<Section>
|
||||
{isDefaultLogo ? (
|
||||
<Link href={logoLink} target="_blank">
|
||||
<Img alt="Logo" className="mx-auto w-60" src={fbLogoUrl} />
|
||||
</Link>
|
||||
) : (
|
||||
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={props.logoUrl} />
|
||||
)}
|
||||
</Section>
|
||||
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left text-sm">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: dompurify.sanitize(body, {
|
||||
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
|
||||
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
|
||||
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
|
||||
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{elements.length > 0 ? (
|
||||
<>
|
||||
<Hr />
|
||||
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{elements.length > 0 ? <Hr /> : null}
|
||||
|
||||
{elements.map((e) => {
|
||||
if (!e.response) return;
|
||||
return (
|
||||
<Row key={e.element}>
|
||||
<Column className="w-full">
|
||||
<Text className="mb-2 text-sm font-semibold text-slate-900">{e.element}</Text>
|
||||
{renderEmailResponseValue(e.response, e.type, t, true)}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
{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];
|
||||
{elements.map((e) => {
|
||||
if (!e.response) return;
|
||||
return (
|
||||
<Row key={variable.id}>
|
||||
<Column className="w-full">
|
||||
<Text className="mb-2 text-sm font-semibold text-slate-900">
|
||||
{variable.type === "number"
|
||||
? `${t("emails.number_variable")}: ${variable.name}`
|
||||
: `${t("emails.text_variable")}: ${variable.name}`}
|
||||
</Text>
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
<Row key={e.element}>
|
||||
<Column className="w-full font-medium">
|
||||
<Text className="mb-2 text-sm">{e.element}</Text>
|
||||
{renderEmailResponseValue(e.response, e.type, t, true)}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
|
||||
{props.attachResponseData &&
|
||||
props.includeHiddenFields &&
|
||||
props.survey.hiddenFields.fieldIds
|
||||
?.filter((hiddenFieldId) => {
|
||||
const hiddenFieldResponse = props.response.data[hiddenFieldId];
|
||||
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
|
||||
})
|
||||
.map((hiddenFieldId) => {
|
||||
const hiddenFieldResponse = props.response.data[hiddenFieldId] as string;
|
||||
return (
|
||||
<Row key={hiddenFieldId}>
|
||||
<Column className="w-full">
|
||||
<Text className="mb-2 text-sm font-semibold text-slate-900">
|
||||
{t("emails.hidden_field")}: {hiddenFieldId}
|
||||
</Text>
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</EmailTemplate>
|
||||
{/* If the logo is not set, we are not using white labeling */}
|
||||
{isDefaultLogo ? (
|
||||
<Section className="mt-4 text-center text-sm">
|
||||
<Link
|
||||
className="m-0 text-sm text-slate-500"
|
||||
href="https://formbricks.com/?utm_source=email_header&utm_medium=email"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("emails.email_template_text_1")}
|
||||
</Link>
|
||||
{IMPRINT_ADDRESS && (
|
||||
<Text className="m-0 text-sm text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
|
||||
)}
|
||||
<Text className="m-0 text-sm text-slate-500 opacity-50">
|
||||
{IMPRINT_URL && (
|
||||
<Link
|
||||
href={IMPRINT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-slate-500">
|
||||
{t("emails.imprint")}
|
||||
</Link>
|
||||
)}
|
||||
{IMPRINT_URL && PRIVACY_URL && " • "}
|
||||
{PRIVACY_URL && (
|
||||
<Link
|
||||
href={PRIVACY_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-slate-500">
|
||||
{t("emails.privacy_policy")}
|
||||
</Link>
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,11 +46,6 @@ export const FollowUpItem = ({
|
||||
|
||||
if (!to) return true;
|
||||
|
||||
// Verified email is always valid as an option (handled at execution time)
|
||||
if (to === "verifiedEmail") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Derive questions from blocks
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
|
||||
@@ -155,7 +150,7 @@ export const FollowUpItem = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="absolute top-4 right-4 flex items-center">
|
||||
<div className="absolute right-4 top-4 flex items-center">
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -201,8 +196,6 @@ export const FollowUpItem = ({
|
||||
emailTo: followUp.action.properties.to,
|
||||
replyTo: followUp.action.properties.replyTo,
|
||||
attachResponseData: followUp.action.properties.attachResponseData,
|
||||
includeVariables: followUp.action.properties.includeVariables ?? false,
|
||||
includeHiddenFields: followUp.action.properties.includeHiddenFields ?? false,
|
||||
}}
|
||||
mode="edit"
|
||||
teamMemberDetails={teamMemberDetails}
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getElementIconMap } from "@/modules/survey/lib/elements";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
@@ -79,7 +78,7 @@ interface AddFollowUpModalProps {
|
||||
}
|
||||
|
||||
type EmailSendToOption = {
|
||||
type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user" | "verifiedEmail";
|
||||
type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user";
|
||||
label: string;
|
||||
id: string;
|
||||
};
|
||||
@@ -141,18 +140,7 @@ export const FollowUpModal = ({
|
||||
? updatedTeamMemberDetails
|
||||
: [...updatedTeamMemberDetails, { email: userEmail, name: "Yourself" }];
|
||||
|
||||
const verifiedEmailOption = localSurvey.isVerifyEmailEnabled
|
||||
? [
|
||||
{
|
||||
label: t("common.verified_email"),
|
||||
id: "verifiedEmail",
|
||||
type: "verifiedEmail" as EmailSendToOption["type"],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...verifiedEmailOption,
|
||||
...openTextAndContactElements.map((element) => ({
|
||||
label: getTextContent(
|
||||
recallToHeadline(element.headline, localSurvey, false, selectedLanguageCode)[selectedLanguageCode]
|
||||
@@ -176,7 +164,7 @@ export const FollowUpModal = ({
|
||||
type: "user" as EmailSendToOption["type"],
|
||||
})),
|
||||
] satisfies EmailSendToOption[];
|
||||
}, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail, t]);
|
||||
}, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail]);
|
||||
|
||||
const form = useForm<TCreateSurveyFollowUpForm>({
|
||||
defaultValues: {
|
||||
@@ -271,8 +259,6 @@ export const FollowUpModal = ({
|
||||
subject: data.subject,
|
||||
body: sanitizedBody,
|
||||
attachResponseData: data.attachResponseData,
|
||||
includeVariables: data.includeVariables,
|
||||
includeHiddenFields: data.includeHiddenFields,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -320,8 +306,6 @@ export const FollowUpModal = ({
|
||||
subject: data.subject,
|
||||
body: sanitizedBody,
|
||||
attachResponseData: data.attachResponseData,
|
||||
includeVariables: data.includeVariables,
|
||||
includeHiddenFields: data.includeHiddenFields,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -377,8 +361,6 @@ export const FollowUpModal = ({
|
||||
subject: defaultValues?.subject ?? "Thanks for your answers!",
|
||||
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
|
||||
attachResponseData: defaultValues?.attachResponseData ?? false,
|
||||
includeVariables: defaultValues?.includeVariables ?? false,
|
||||
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
|
||||
});
|
||||
}
|
||||
}, [open, defaultValues, emailSendToOptions, form, userEmail, locale, t]);
|
||||
@@ -390,50 +372,33 @@ export const FollowUpModal = ({
|
||||
setOpen(open);
|
||||
};
|
||||
|
||||
const emailSendToVerifiedEmailOptions = emailSendToOptions.filter(
|
||||
(option) => option.type === "verifiedEmail"
|
||||
);
|
||||
const emailSendToElementOptions = emailSendToOptions.filter(
|
||||
(option) => option.type === "openTextElement" || option.type === "contactInfoElement"
|
||||
);
|
||||
const emailSendToHiddenFieldOptions = emailSendToOptions.filter((option) => option.type === "hiddenField");
|
||||
const userSendToEmailOptions = emailSendToOptions.filter((option) => option.type === "user");
|
||||
|
||||
const getSelectItemIcon = (
|
||||
type: EmailSendToOption["type"]
|
||||
): { icon: React.ReactNode; textClass?: string } => {
|
||||
switch (type) {
|
||||
case "verifiedEmail":
|
||||
return { icon: <MailIcon className="h-4 w-4" /> };
|
||||
case "hiddenField":
|
||||
return { icon: <EyeOffIcon className="h-4 w-4" /> };
|
||||
case "user":
|
||||
return {
|
||||
icon: <UserIcon className="h-4 w-4" />,
|
||||
textClass: "overflow-hidden text-ellipsis whitespace-nowrap",
|
||||
};
|
||||
case "openTextElement":
|
||||
case "contactInfoElement":
|
||||
return {
|
||||
icon: (
|
||||
<div className="h-4 w-4">
|
||||
{ELEMENTS_ICON_MAP[type === "openTextElement" ? "openText" : "contactInfo"]}
|
||||
</div>
|
||||
),
|
||||
textClass: "overflow-hidden text-ellipsis whitespace-nowrap",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const renderSelectItem = (option: EmailSendToOption) => {
|
||||
const { icon, textClass } = getSelectItemIcon(option.type);
|
||||
|
||||
return (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{icon}
|
||||
<span className={textClass}>{option.label}</span>
|
||||
</div>
|
||||
{option.type === "hiddenField" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
) : option.type === "user" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4">
|
||||
{ELEMENTS_ICON_MAP[option.type === "openTextElement" ? "openText" : "contactInfo"]}
|
||||
</div>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
};
|
||||
@@ -683,8 +648,7 @@ export const FollowUpModal = ({
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{emailSendToVerifiedEmailOptions.length > 0 ||
|
||||
emailSendToElementOptions.length > 0 ? (
|
||||
{emailSendToElementOptions.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2 p-2">
|
||||
<p className="text-sm text-slate-500">
|
||||
@@ -692,10 +656,6 @@ export const FollowUpModal = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{emailSendToVerifiedEmailOptions.map((option) =>
|
||||
renderSelectItem(option)
|
||||
)}
|
||||
|
||||
{emailSendToElementOptions.map((option) =>
|
||||
renderSelectItem(option)
|
||||
)}
|
||||
@@ -872,60 +832,27 @@ export const FollowUpModal = ({
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="attachResponseData"
|
||||
isChecked={field.value}
|
||||
onToggle={(checked) => field.onChange(checked)}
|
||||
title={t(
|
||||
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
|
||||
)}
|
||||
description={t(
|
||||
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
|
||||
)}
|
||||
customContainerClass="p-0"
|
||||
childBorder>
|
||||
<div className="flex w-full flex-col gap-4 p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeVariables"
|
||||
render={({ field: variablesField }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeVariables"
|
||||
checked={variablesField.value}
|
||||
onCheckedChange={(checked) => variablesField.onChange(checked)}
|
||||
disabled={!field.value}
|
||||
/>
|
||||
<FormLabel htmlFor="includeVariables" className="font-medium">
|
||||
{t("environments.surveys.edit.follow_ups_include_variables")}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="attachResponseData"
|
||||
checked={field.value}
|
||||
defaultChecked={defaultValues?.attachResponseData ?? false}
|
||||
onCheckedChange={(checked) => field.onChange(checked)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeHiddenFields"
|
||||
render={({ field: hiddenFieldsField }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeHiddenFields"
|
||||
checked={hiddenFieldsField.value}
|
||||
onCheckedChange={(checked) => hiddenFieldsField.onChange(checked)}
|
||||
disabled={!field.value}
|
||||
/>
|
||||
<FormLabel htmlFor="includeHiddenFields" className="font-medium">
|
||||
{t("environments.surveys.edit.follow_ups_include_hidden_fields")}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormLabel htmlFor="attachResponseData" className="font-medium">
|
||||
{t(
|
||||
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
|
||||
)}
|
||||
/>
|
||||
</FormLabel>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<FormDescription className="text-sm text-slate-500">
|
||||
{t(
|
||||
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -12,16 +12,12 @@ export const sendFollowUpEmail = async ({
|
||||
survey,
|
||||
response,
|
||||
attachResponseData = false,
|
||||
includeVariables = false,
|
||||
includeHiddenFields = false,
|
||||
logoUrl,
|
||||
}: {
|
||||
followUp: TSurveyFollowUp;
|
||||
to: string;
|
||||
replyTo: string[];
|
||||
attachResponseData: boolean;
|
||||
includeVariables?: boolean;
|
||||
includeHiddenFields?: boolean;
|
||||
survey: TSurvey;
|
||||
response: TResponse;
|
||||
logoUrl?: string;
|
||||
@@ -37,8 +33,6 @@ export const sendFollowUpEmail = async ({
|
||||
followUp,
|
||||
logoUrl,
|
||||
attachResponseData,
|
||||
includeVariables,
|
||||
includeHiddenFields,
|
||||
survey,
|
||||
response,
|
||||
})
|
||||
|
||||
@@ -40,8 +40,6 @@ const evaluateFollowUp = async (
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
includeVariables: properties.includeVariables,
|
||||
includeHiddenFields: properties.includeHiddenFields,
|
||||
logoUrl,
|
||||
});
|
||||
|
||||
@@ -73,8 +71,6 @@ const evaluateFollowUp = async (
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
includeVariables: properties.includeVariables,
|
||||
includeHiddenFields: properties.includeHiddenFields,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -108,8 +104,6 @@ const evaluateFollowUp = async (
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
includeVariables: properties.includeVariables,
|
||||
includeHiddenFields: properties.includeHiddenFields,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -79,9 +79,7 @@ export const LinkSurveyWrapper = ({
|
||||
styling={styling}
|
||||
onBackgroundLoaded={handleBackgroundLoaded}>
|
||||
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
|
||||
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
|
||||
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
|
||||
)}
|
||||
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
|
||||
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
||||
{isPreview && (
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
interface SurveyClientWrapperProps {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { transformElement } from "./transformers";
|
||||
import { validateElement } from "./validators";
|
||||
|
||||
/**
|
||||
* Extract prefilled values from URL search parameters
|
||||
*
|
||||
* Supports prefilling for all survey element types with the following features:
|
||||
* - Option ID or label matching for choice-based elements (single/multi-select, ranking, picture selection)
|
||||
* - Comma-separated values for multi-select and ranking
|
||||
* - Backward compatibility with label-based prefilling
|
||||
*
|
||||
* @param survey - The survey object containing blocks and elements
|
||||
* @param searchParams - URL search parameters (e.g., from useSearchParams() or new URLSearchParams())
|
||||
* @param languageId - Current language code for label matching
|
||||
* @returns Object with element IDs as keys and prefilled values, or undefined if no valid prefills
|
||||
*
|
||||
* @example
|
||||
* // Single select with option ID
|
||||
* ?questionId=option-abc123
|
||||
*
|
||||
* // Multi-select with labels (backward compatible)
|
||||
* ?questionId=Option1,Option2,Option3
|
||||
*
|
||||
* // Ranking with option IDs
|
||||
* ?rankingId=choice-3,choice-1,choice-2
|
||||
*
|
||||
* // NPS question
|
||||
* ?npsId=9
|
||||
*
|
||||
* // Multiple questions
|
||||
* ?q1=answer1&q2=10&q3=option-xyz
|
||||
*/
|
||||
export const getPrefillValue = (
|
||||
survey: TSurvey,
|
||||
searchParams: URLSearchParams,
|
||||
languageId: string
|
||||
): TResponseData | undefined => {
|
||||
const prefillData: TResponseData = {};
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
searchParams.forEach((value, key) => {
|
||||
try {
|
||||
// Skip reserved parameter names
|
||||
if (FORBIDDEN_IDS.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching element
|
||||
const element = elements.find((el) => el.id === key);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the value for this element type (returns match data)
|
||||
const validationResult = validateElement(element, value, languageId);
|
||||
if (!validationResult.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform the value using pre-matched data from validation
|
||||
const transformedValue = transformElement(validationResult, value, languageId);
|
||||
prefillData[element.id] = transformedValue;
|
||||
} catch (error) {
|
||||
// Catch any errors to prevent one bad prefill from breaking all prefills
|
||||
console.error(`[Prefill] Error processing prefill for ${key}:`, error);
|
||||
}
|
||||
});
|
||||
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { matchMultipleOptionsByIdOrLabel, matchOptionByIdOrLabel } from "./matchers";
|
||||
|
||||
describe("matchOptionByIdOrLabel", () => {
|
||||
const choices = [
|
||||
{ id: "choice-1", label: { en: "First", de: "Erste" } },
|
||||
{ id: "choice-2", label: { en: "Second", de: "Zweite" } },
|
||||
{ id: "other", label: { en: "Other", de: "Andere" } },
|
||||
];
|
||||
|
||||
test("matches by ID", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "choice-1", "en");
|
||||
expect(result).toEqual(choices[0]);
|
||||
});
|
||||
|
||||
test("matches by label in English", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "First", "en");
|
||||
expect(result).toEqual(choices[0]);
|
||||
});
|
||||
|
||||
test("matches by label in German", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "Zweite", "de");
|
||||
expect(result).toEqual(choices[1]);
|
||||
});
|
||||
|
||||
test("prefers ID match over label match", () => {
|
||||
const choicesWithConflict = [
|
||||
{ id: "First", label: { en: "Not First" } },
|
||||
{ id: "choice-2", label: { en: "First" } },
|
||||
];
|
||||
const result = matchOptionByIdOrLabel(choicesWithConflict, "First", "en");
|
||||
expect(result).toEqual(choicesWithConflict[0]); // Matches by ID, not label
|
||||
});
|
||||
|
||||
test("returns null for no match", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "NonExistent", "en");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "", "en");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles special characters in labels", () => {
|
||||
const specialChoices = [{ id: "c1", label: { en: "Option (1)" } }];
|
||||
const result = matchOptionByIdOrLabel(specialChoices, "Option (1)", "en");
|
||||
expect(result).toEqual(specialChoices[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchMultipleOptionsByIdOrLabel", () => {
|
||||
const choices = [
|
||||
{ id: "choice-1", label: { en: "First" } },
|
||||
{ id: "choice-2", label: { en: "Second" } },
|
||||
{ id: "choice-3", label: { en: "Third" } },
|
||||
];
|
||||
|
||||
test("matches multiple values by ID", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "choice-3"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("matches multiple values by label", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "Third"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("matches mixed IDs and labels", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "Second", "choice-3"], "en");
|
||||
expect(result).toEqual([choices[0], choices[1], choices[2]]);
|
||||
});
|
||||
|
||||
test("preserves order of values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["Third", "First", "Second"], "en");
|
||||
expect(result).toEqual([choices[2], choices[0], choices[1]]);
|
||||
});
|
||||
|
||||
test("skips non-matching values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "NonExistent", "Third"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("returns empty array for all non-matching values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["NonExistent1", "NonExistent2"], "en");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles empty values array", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, [], "en");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { TSurveyElementChoice } from "@formbricks/types/surveys/elements";
|
||||
|
||||
/**
|
||||
* Match a value against element choices by ID first, then by label
|
||||
* This enables both option ID-based and label-based prefilling
|
||||
*
|
||||
* @param choices - Array of choice objects with id and label
|
||||
* @param value - Value from URL parameter (either choice ID or label text)
|
||||
* @param languageCode - Current language code for label matching
|
||||
* @returns Matched choice or null if no match found
|
||||
*/
|
||||
export const matchOptionByIdOrLabel = (
|
||||
choices: TSurveyElementChoice[],
|
||||
value: string,
|
||||
languageCode: string
|
||||
): TSurveyElementChoice | null => {
|
||||
const matchById = choices.find((choice) => choice.id === value);
|
||||
if (matchById) return matchById;
|
||||
|
||||
const matchByLabel = choices.find((choice) => choice.label[languageCode] === value);
|
||||
if (matchByLabel) return matchByLabel;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Match multiple values against choices
|
||||
* Used for multi-select and ranking elements
|
||||
*
|
||||
* @param choices - Array of choice objects
|
||||
* @param values - Array of values from URL parameter
|
||||
* @param languageCode - Current language code
|
||||
* @returns Array of matched choices (preserves order)
|
||||
*/
|
||||
export const matchMultipleOptionsByIdOrLabel = (
|
||||
choices: TSurveyElementChoice[],
|
||||
values: string[],
|
||||
languageCode: string
|
||||
): TSurveyElementChoice[] =>
|
||||
values
|
||||
.map((value) => matchOptionByIdOrLabel(choices, value, languageCode))
|
||||
.filter((match) => match !== null);
|
||||
@@ -1,64 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseCommaSeparated, parseNumber } from "./parsers";
|
||||
|
||||
describe("parseCommaSeparated", () => {
|
||||
test("parses simple comma-separated values", () => {
|
||||
expect(parseCommaSeparated("a,b,c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("trims whitespace from values", () => {
|
||||
expect(parseCommaSeparated("a , b , c")).toEqual(["a", "b", "c"]);
|
||||
expect(parseCommaSeparated(" a, b, c ")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("filters out empty values", () => {
|
||||
expect(parseCommaSeparated("a,,b")).toEqual(["a", "b"]);
|
||||
expect(parseCommaSeparated("a,b,")).toEqual(["a", "b"]);
|
||||
expect(parseCommaSeparated(",a,b")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(parseCommaSeparated("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles single value", () => {
|
||||
expect(parseCommaSeparated("single")).toEqual(["single"]);
|
||||
});
|
||||
|
||||
test("handles values with spaces", () => {
|
||||
expect(parseCommaSeparated("First Choice,Second Choice")).toEqual(["First Choice", "Second Choice"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNumber", () => {
|
||||
test("parses valid integers", () => {
|
||||
expect(parseNumber("5")).toBe(5);
|
||||
expect(parseNumber("0")).toBe(0);
|
||||
expect(parseNumber("10")).toBe(10);
|
||||
});
|
||||
|
||||
test("parses valid floats", () => {
|
||||
expect(parseNumber("5.5")).toBe(5.5);
|
||||
expect(parseNumber("0.1")).toBe(0.1);
|
||||
});
|
||||
|
||||
test("parses negative numbers", () => {
|
||||
expect(parseNumber("-5")).toBe(-5);
|
||||
expect(parseNumber("-5.5")).toBe(-5.5);
|
||||
});
|
||||
|
||||
test("handles ampersand replacement", () => {
|
||||
expect(parseNumber("5&5")).toBe(null); // Invalid after replacement
|
||||
});
|
||||
|
||||
test("returns null for invalid strings", () => {
|
||||
expect(parseNumber("abc")).toBeNull();
|
||||
expect(parseNumber("")).toBeNull();
|
||||
expect(parseNumber("5a")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for NaN result", () => {
|
||||
expect(parseNumber("NaN")).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user