Compare commits

..

35 Commits

Author SHA1 Message Date
Johannes
85285d1fe1 make work with blocks 2025-12-16 23:03:38 +01:00
Johannes
1ae98226ad Merge branch 'main' of https://github.com/formbricks/formbricks into feature/response-generation 2025-12-16 11:21:35 +01:00
Matti Nannt
e9f800f017 fix: prepare pnpm in runner stage for airgapped deployments (#6925) 2025-12-15 13:30:55 +00:00
Johannes
ba2070b638 feat: add vars & hidden fields + send to verified email to followups (#6874)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-14 09:09:43 +00:00
Johannes
75cdb25d27 fix: improve survey response queue robustness to prevent data loss (#6959)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-14 08:18:11 +00:00
Johannes
6bc7db852c feat: Save draft without validation (Duplicate of #6847) (#6966)
Co-authored-by: Mahadeva Peruka <97960828+mahadevaperuka@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 21:52:00 +00:00
Matti Nannt
ffb4eac1a4 chore: upgrade azure-playwright (#6949) 2025-12-12 18:14:21 +00:00
Bhagya Amarasinghe
56da3b5725 chore: remove docker compose version pinning and update Traefik image version to v2.11.31 in docker-compose and documentation (#6967)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-12 11:29:26 +01:00
dependabot[bot]
c189af5482 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6971)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-12 11:25:57 +01:00
Johannes
5dbf42fd6a feat: add bulk edit for single-select and multi-select options (#6951)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 06:49:49 +00:00
Anshuman Pandey
42525a86a8 fix: close the survey on formbricks.logout (#6955) 2025-12-12 06:03:35 +00:00
Anshuman Pandey
b96f0e67c5 fix: preserve attribute key casing during CSV contact upload (#6958)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-12 05:22:48 +00:00
Johannes
2d7b99ba26 feat: allow team admins to invite members to their own teams (#6891)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 05:01:48 +00:00
Matti Nannt
666a79044f fix: skip instance ID in license check during E2E tests (#6968)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 04:05:25 +00:00
Johannes
c3d97c2932 fix: docs links (#6960) 2025-12-10 10:59:25 +00:00
Anshuman Pandey
cc5d630a05 chore: adds docs for min ios and android versions (#6956) 2025-12-09 10:11:00 +00:00
Anshuman Pandey
be38d76ccf fix: removes empty imageUrl and videoUrl keys from elements (#6950) 2025-12-09 09:52:01 +00:00
Joel Ekström Svensson
a8eea306e5 feat: Add Swedish sv-SE translation (#6913)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-08 14:49:44 +00:00
Matti Nannt
4fd53ac115 refactor: centralize instance ID generation (#6952) 2025-12-08 13:42:54 +00:00
Matti Nannt
eb92392ed1 fix: add node-forge security override to resolve Dependabot #230 (#6948) 2025-12-08 12:34:36 +00:00
dependabot[bot]
7412b32526 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-04 13:40:52 +00:00
Matti Nannt
193346a70d fix: upgrade Next.js to 15.5.7 and React to 19.1.2 to fix CVE-2025-66478 and CVE-2025-55182 (#6943) 2025-12-04 10:50:04 +00:00
Johannes
a1d4754b04 feat: allow survey-level logo override in styling tab (#6887)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-04 08:51:56 +00:00
Johannes
f4b918a4b6 feat: add survey metadata to webhook payload (#6939)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-04 07:08:42 +00:00
Dhruwang Jariwala
fb9a0b197a fix: disable keyboard navigation for 'other' option in multiple-choice component (#6941) 2025-12-04 06:59:13 +00:00
Dhruwang Jariwala
95b6c16dd1 fix: truncate language switch text #6910 (#6934)
Co-authored-by: Mahadeva Peruka <97960828+mahadevaperuka@users.noreply.github.com>
2025-12-03 13:40:26 +00:00
Johannes
cfdf09650f fix: error message in rating Question (#6909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-03 09:15:34 +00:00
Anshuman Pandey
4c94fc25ae fix: fixes pnpm i18n script to generate surveys package translations as well (#6930) 2025-12-02 09:56:35 +00:00
Johannes
ccf501d925 fix: keyboard nav for MQP with multiple questions (#6926)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-02 06:40:30 +00:00
Dhruwang Jariwala
04dfbe0777 fix: removed unused t wrapper (#6923) 2025-12-01 16:35:13 +00:00
Matti Nannt
cbf255ab0d docs: add custom subpath deployment guide (#6922) 2025-12-01 15:33:51 +01:00
Dhruwang Jariwala
942366956c fix: missing finish label on last card (#6915) 2025-12-01 13:50:49 +00:00
Dhruwang Jariwala
a6ee796cef fix: back button label validation (#6916) 2025-12-01 12:09:50 +00:00
Dhruwang Jariwala
a535529bd3 fix: border around language select dropdown (#6914) 2025-12-01 08:57:36 +00:00
Johannes
d25dc8f85d add generate response functionality 2025-11-13 09:24:44 +01:00
140 changed files with 6906 additions and 1702 deletions

View File

@@ -3,14 +3,10 @@ name: E2E Tests
on:
workflow_call:
secrets:
AZURE_CLIENT_ID:
required: false
AZURE_TENANT_ID:
required: false
AZURE_SUBSCRIPTION_ID:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
@@ -21,7 +17,6 @@ env:
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write
contents: read
actions: read
@@ -114,7 +109,7 @@ jobs:
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
@@ -124,7 +119,7 @@ jobs:
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
@@ -207,32 +202,30 @@ jobs:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Set Azure Secret Variables
run: |
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
else
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
fi
- name: Azure login
if: env.AZURE_ENABLED == 'true'
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run E2E Tests (Azure)
if: env.AZURE_ENABLED == 'true'
- name: Determine Playwright execution mode
shell: bash
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
CI: true
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
run: |
pnpm test-e2e:azure
set -euo pipefail
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
echo "PW_MODE=service" >> "$GITHUB_ENV"
else
echo "PW_MODE=local" >> "$GITHUB_ENV"
fi
- name: Run E2E Tests (Playwright Service)
if: env.PW_MODE == 'service'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
CI: true
run: pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.AZURE_ENABLED == 'false'
if: env.PW_MODE == 'local'
env:
CI: true
run: |

View File

@@ -73,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
RUN corepack enable
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -134,12 +134,13 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]

View File

@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
},
{
id: "teams",
label: t("common.teams"),
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{

View File

@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
},
{
id: "teams",
label: t("common.teams"),
label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},

View File

@@ -3,8 +3,13 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { createResponseWithQuotaEvaluation } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -17,6 +22,29 @@ import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customizat
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const loremIpsumSentences = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.",
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit.",
"Nisi ut aliquip ex ea commodo consequat.",
"Pellentesque habitant morbi tristique senectus et netus et malesuada fames.",
"Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante.",
"Donec eu libero sit amet quam egestas semper.",
"Aenean ultricies mi vitae est. Mauris placerat eleifend leo.",
];
function generateLoremIpsum(): string {
const sentenceCount = Math.floor(Math.random() * 3) + 1;
const selectedSentences: string[] = [];
for (let i = 0; i < sentenceCount; i++) {
const randomIndex = Math.floor(Math.random() * loremIpsumSentences.length);
selectedSentences.push(loremIpsumSentences[randomIndex]);
}
return selectedSentences.join(" ");
}
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
});
@@ -260,3 +288,169 @@ export const updateSingleUseLinksAction = authenticatedActionClient
return updatedSurvey;
});
const ZGenerateTestResponsesAction = z.object({
surveyId: ZId,
environmentId: ZId,
});
export const generateTestResponsesAction = authenticatedActionClient
.schema(ZGenerateTestResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
if (survey.environmentId !== parsedInput.environmentId) {
throw new OperationNotAllowedError("Survey does not belong to the specified environment");
}
const supportedElementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.Matrix,
];
// Extract elements from blocks
const elements = getElementsFromBlocks(survey.blocks);
const supportedElements = elements.filter((element) => supportedElementTypes.includes(element.type));
if (supportedElements.length === 0) {
throw new OperationNotAllowedError(
"Survey does not contain any supported question types (OpenText, NPS, Rating, Multiple Choice, Picture Selection, Ranking, or Matrix)"
);
}
const responsesToCreate = 5;
const createdResponses: string[] = [];
for (let i = 0; i < responsesToCreate; i++) {
const responseData: Record<string, string | number | string[] | Record<string, string>> = {};
for (const element of supportedElements) {
if (element.type === TSurveyElementTypeEnum.OpenText) {
responseData[element.id] = generateLoremIpsum();
} else if (element.type === TSurveyElementTypeEnum.NPS) {
responseData[element.id] = Math.floor(Math.random() * 11);
} else if (element.type === TSurveyElementTypeEnum.Rating) {
const range = "range" in element && typeof element.range === "number" ? element.range : 5;
responseData[element.id] = Math.floor(Math.random() * range) + 1;
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
// Single choice: pick one random option, store the label
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const randomIndex = Math.floor(Math.random() * element.choices.length);
const selectedChoice = element.choices[randomIndex];
// For "other" option, generate custom text; otherwise use the choice label
responseData[element.id] =
selectedChoice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(selectedChoice.label, "default");
}
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
// Multi choice: pick 1-3 random options, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.PictureSelection) {
// Picture selection: single or multi based on allowMulti
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const allowMulti = "allowMulti" in element ? element.allowMulti : false;
if (allowMulti) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => choice.id);
} else {
const randomIndex = Math.floor(Math.random() * element.choices.length);
responseData[element.id] = element.choices[randomIndex].id;
}
}
} else if (element.type === TSurveyElementTypeEnum.Ranking) {
// Ranking: all options in random order, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.Matrix) {
// Matrix: for each row, pick a random column
if (
"rows" in element &&
"columns" in element &&
Array.isArray(element.rows) &&
Array.isArray(element.columns) &&
element.rows.length > 0 &&
element.columns.length > 0
) {
const matrixData: Record<string, string> = {};
for (const row of element.rows) {
const randomColumnIndex = Math.floor(Math.random() * element.columns.length);
matrixData[row.id] = element.columns[randomColumnIndex].id;
}
responseData[element.id] = matrixData;
}
}
}
const responseInput: TResponseInput = {
environmentId: parsedInput.environmentId,
surveyId: parsedInput.surveyId,
finished: true,
data: responseData,
meta: {
source: "test",
userAgent: {
browser: "Test Generator",
device: "desktop",
os: "Test OS",
},
},
};
try {
const response = await createResponseWithQuotaEvaluation(responseInput);
createdResponses.push(response.id);
} catch (error) {
throw new UnknownError(
`Failed to create response: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
return {
success: true,
createdCount: createdResponses.length,
};
});

View File

@@ -1,6 +1,6 @@
"use client";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, Sparkles, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -20,7 +20,7 @@ import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/action
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
import { generateTestResponsesAction, resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -63,6 +63,7 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isGeneratingResponses, setIsGeneratingResponses] = useState(false);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
@@ -147,6 +148,23 @@ export const SurveyAnalysisCTA = ({
setIsResetModalOpen(false);
};
const handleGenerateTestResponses = async () => {
if (isGeneratingResponses) return;
setIsGeneratingResponses(true);
const result = await generateTestResponsesAction({
surveyId: survey.id,
environmentId: environment.id,
});
if (result?.data?.success) {
toast.success(`Successfully generated ${result.data.createdCount} test responses`);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsGeneratingResponses(false);
};
const iconActions = [
{
icon: BellRing,
@@ -163,6 +181,12 @@ export const SurveyAnalysisCTA = ({
},
isVisible: survey.type === "link",
},
{
icon: Sparkles,
tooltip: isGeneratingResponses ? "Generating responses..." : "Generate test responses",
onClick: handleGenerateTestResponses,
isVisible: !isReadOnly,
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),

View File

@@ -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,15 +129,12 @@ export const sendTelemetryEvents = async () => {
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
*/
const sendTelemetry = async (lastSent: number) => {
// Get the oldest organization to generate a stable, anonymized instance ID.
// Get the instance info (hashed oldest organization ID and creation date).
// Using the oldest org ensures the ID doesn't change over time.
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, createdAt: true },
});
const instanceInfo = await getInstanceInfo();
if (!instanceInfo) return; // No organization exists, nothing to report
if (!oldestOrg) return; // No organization exists, nothing to report
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
const { instanceId, createdAt: instanceCreatedAt } = instanceInfo;
// Optimize database queries to reduce connection pool usage:
// Instead of 15 parallel queries (which could exhaust the connection pool),
@@ -248,7 +245,7 @@ const sendTelemetry = async (lastSent: number) => {
version: packageJson.version, // Formbricks version for compatibility tracking
},
temporal: {
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
},
};

View File

@@ -51,6 +51,22 @@ 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({
@@ -81,7 +97,16 @@ export const POST = async (request: Request) => {
body: JSON.stringify({
webhookId: webhook.id,
event,
data: response,
data: {
...response,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
},
}),
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
@@ -89,18 +114,12 @@ export const POST = async (request: Request) => {
);
if (event === "responseFinished") {
// Fetch integrations, survey, and responseCount in parallel
const [integrations, survey, responseCount] = await Promise.all([
// Fetch integrations and responseCount in parallel
const [integrations, 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);
}

View File

@@ -17,7 +17,8 @@
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
"es-ES",
"sv-SE"
]
},
"version": 1.8

View File

@@ -234,6 +234,7 @@ checksums:
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
@@ -310,9 +311,10 @@ checksums:
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
common/quotas: e6afead11b5b8ae627885ce2b84a548f
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: 426ba960bfedf186a878b7467867f9d2
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
@@ -322,10 +324,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
@@ -380,7 +382,8 @@ checksums:
common/team_access: 45c6232c71b760eaa33b932dabab4c1c
common/team_id: 134e32d6f7184577a46b2fd83e85e532
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/teams: a2fbdec69342366a2b6033d119aa279a
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
@@ -395,6 +398,7 @@ 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
@@ -440,6 +444,7 @@ checksums:
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
@@ -451,12 +456,14 @@ checksums:
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
emails/password_changed_email_heading: 601f68fc8bef9c5ecf79f4ec4de5ad06
emails/password_changed_email_text: f9ed4db250ec1b2adf4cb4527ec72d78
emails/password_reset_notify_email_subject: 0a6805fc27c5bb7999f0d311ef5981e1
emails/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
emails/reject: 417c19f66db70a0548bdeb398cdc46e0
emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f
emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9
emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9
emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5
emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06
@@ -468,6 +475,7 @@ checksums:
emails/survey_response_finished_email_turn_off_notifications_for_this_form: 7b6a7074490ceaf3d1903a37169364d6
emails/survey_response_finished_email_view_more_responses: fe053505f470cbbb5823ca15ceefcedd
emails/survey_response_finished_email_view_survey_summary: c4e8b5207c0dc856a01011c8b91e0d94
emails/text_variable: 5fdfcc48b8010a4f44e16b8051272a75
emails/verification_email_click_on_this_link: 3c9ad15bd2e3822d3ecd85a421311ebc
emails/verification_email_heading: 0f86a46d434bb4595b8753d3cf2524e0
emails/verification_email_hey: 20c5157a424f7d49ceeb27e6fb13d194
@@ -839,7 +847,6 @@ checksums:
environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553
environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad
environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d
environments/project/teams/only_organization_owners_and_managers_can_manage_teams: 179056fade669d34f63fb1ee965b8024
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
@@ -1085,13 +1092,17 @@ checksums:
environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab
environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac
environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f
environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2
environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18
environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12
environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10
environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf
environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca
environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253
@@ -1170,12 +1181,15 @@ 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: 4e1e6fd2ec767bbff8767f6c0f68a731
environments/surveys/edit/block_deleted: c682259eb138ad84f8b4441abfd9b572
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
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
@@ -1187,7 +1201,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: 01e88d58219539fb831e79f0bb3ce88e
environments/surveys/edit/card_styling: 47137a7e809b060ca94418202a8fd3c5
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486
@@ -1243,6 +1257,7 @@ 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
@@ -1294,11 +1309,13 @@ checksums:
environments/surveys/edit/follow_ups_ending_card_delete_modal_text: 71ac1865afe2b2f76836dcbebd1a813e
environments/surveys/edit/follow_ups_ending_card_delete_modal_title: 11d0b31535034e0a86c906557fb6f22e
environments/surveys/edit/follow_ups_hidden_field_error: 28aa017b194fb6d7d6c06a8a0bf843ff
environments/surveys/edit/follow_ups_include_hidden_fields: 8f0c2f8ddd3b95a3e7456a42be9362bb
environments/surveys/edit/follow_ups_include_variables: 2604dd580ceafec167ff9136d800f31e
environments/surveys/edit/follow_ups_item_ending_tag: 159c4e3bc953aae9a9dba27f7917228b
environments/surveys/edit/follow_ups_item_issue_detected_tag: bfb6b1f7b9f0a0a76bac853f01f72ba8
environments/surveys/edit/follow_ups_item_response_tag: 4b63073494e2224e1333624c6cee4240
environments/surveys/edit/follow_ups_item_send_email_tag: 0ef83c0bb40de25921a9ee7fa05babec
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: d23abb5a7e610b1ec3273c60d36a81e7
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: 901a493d60331420da61d0e76bf07eae
environments/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
environments/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
environments/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88
@@ -1343,9 +1360,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
@@ -1392,6 +1409,7 @@ 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
@@ -1420,10 +1438,12 @@ 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
@@ -1566,6 +1586,7 @@ checksums:
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7

View File

@@ -176,6 +176,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
];
// Billing constants

View File

@@ -140,6 +140,7 @@ export const appLanguages = [
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
},
},
{
@@ -156,6 +157,7 @@ export const appLanguages = [
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
"sv-SE": "Tyska",
},
},
{
@@ -172,6 +174,7 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
},
},
{
@@ -188,6 +191,7 @@ export const appLanguages = [
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
"sv-SE": "Franska",
},
},
{
@@ -199,11 +203,12 @@ export const appLanguages = [
"fr-FR": "Chinois (Traditionnel)",
"zh-Hant-TW": "繁體中文",
"pt-PT": "Chinês (Tradicional)",
"ro-RO": "Chineză (Tradicională)",
"ro-RO": "Chineza (Tradițională)",
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
"sv-SE": "Kinesiska (traditionell)",
},
},
{
@@ -220,6 +225,7 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
},
},
{
@@ -236,6 +242,7 @@ export const appLanguages = [
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"sv-SE": "Rumänska",
},
},
{
@@ -252,6 +259,7 @@ export const appLanguages = [
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
"sv-SE": "Japanska",
},
},
{
@@ -263,11 +271,12 @@ export const appLanguages = [
"fr-FR": "Chinois (Simplifié)",
"zh-Hant-TW": "簡體中文",
"pt-PT": "Chinês (Simplificado)",
"ro-RO": "Chineză (Simplificată)",
"ro-RO": "Chineza (Simplificată)",
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
},
},
{
@@ -279,11 +288,12 @@ export const appLanguages = [
"fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês",
"ro-RO": "Olandeză",
"ro-RO": "Olandeza",
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"sv-SE": "Nederländska",
},
},
{
@@ -300,6 +310,24 @@ 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",
},
},
];

50
apps/web/lib/instance.ts Normal file
View File

@@ -0,0 +1,50 @@
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;
};

View File

@@ -33,6 +33,7 @@ import {
handleTriggerUpdates,
loadNewSegmentInSurvey,
updateSurvey,
updateSurveyInternal,
} from "./service";
// Mock organization service
@@ -948,3 +949,74 @@ describe("Tests for getSurveysBySegmentId", () => {
});
});
});
describe("updateSurveyDraftAction", () => {
beforeEach(() => {
vi.mocked(getActionClasses).mockResolvedValue([mockActionClass] as TActionClass[]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganizationOutput);
});
describe("Happy Path", () => {
test("should save draft with missing translations", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.survey.update.mockResolvedValue(mockSurveyOutput);
// Create a survey with incomplete i18n/fields
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline or other required fields
},
],
} as unknown as TSurvey;
// Expect success (skipValidation = true)
const result = await updateSurveyInternal(incompleteSurvey, true);
expect(result).toBeDefined();
expect(prisma.survey.update).toHaveBeenCalled();
});
test("should allow draft with invalid images if gating is applied", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.survey.update.mockResolvedValue(mockSurveyOutput);
const surveyWithInvalidImage = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question" },
imageUrl: "http://invalid-image-url.com/image.txt", // Invalid image extension
},
],
} as unknown as TSurvey;
// Expect success (skipValidation = true)
await updateSurveyInternal(surveyWithInvalidImage, true);
expect(prisma.survey.update).toHaveBeenCalled();
});
});
describe("Sad Path", () => {
test("should reject publishing survey with incomplete translations", async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
});
});

View File

@@ -284,8 +284,13 @@ export const getSurveyCount = reactCache(async (environmentId: string): Promise<
}
});
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
validateInputs([updatedSurvey, ZSurvey]);
export const updateSurveyInternal = async (
updatedSurvey: TSurvey,
skipValidation = false
): Promise<TSurvey> => {
if (!skipValidation) {
validateInputs([updatedSurvey, ZSurvey]);
}
try {
const surveyId = updatedSurvey.id;
@@ -301,10 +306,12 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
checkForInvalidImagesInQuestions(questions);
if (!skipValidation) {
checkForInvalidImagesInQuestions(questions);
}
// Add blocks media validation
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
if (!skipValidation && updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
if (!blocksValidation.ok) {
throw new InvalidInputError(blocksValidation.error.message);
@@ -368,7 +375,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
@@ -568,6 +575,15 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
}
};
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

View File

@@ -69,6 +69,12 @@ 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", () => {

View File

@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -93,6 +93,8 @@ 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":

View File

@@ -1,6 +1,7 @@
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
@@ -84,4 +85,25 @@ 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();
});
});

View File

@@ -261,6 +261,7 @@
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen",
"remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"team_role": "Team-Rolle",
"teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"hidden_field": "Verstecktes Feld",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
"number_variable": "Zahlenvariable",
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
"response_data": "Antwortdaten",
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅",
"response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅",
"schedule_your_meeting": "Termin planen",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Benachrichtigungen für dieses Formular ausschalten",
"survey_response_finished_email_view_more_responses": "Zeige {responseCount} weitere Antworten",
"survey_response_finished_email_view_survey_summary": "Umfragezusammenfassung anzeigen",
"text_variable": "Textvariable",
"verification_email_click_on_this_link": "Du kannst auch auf diesen Link klicken:",
"verification_email_heading": "Fast geschafft!",
"verification_email_hey": "Hey 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Teams verwalten",
"no_teams_found": "Keine Teams gefunden",
"only_organization_owners_and_managers_can_manage_teams": "Nur Organisationsinhaber und -manager können Teams verwalten.",
"permission": "Berechtigung",
"team_name": "Teamname",
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
@@ -1166,13 +1173,17 @@
"manage_team": "Team verwalten",
"manage_team_disabled": "Nur Organisationsbesitzer, Manager und Team-Admins können Teams verwalten.",
"manager_role_description": "Manager können auf alle Projekte zugreifen und Mitglieder hinzufügen und entfernen.",
"member": "Mitglied",
"member_role_description": "Mitglieder können in ausgewählten Projekten arbeiten.",
"member_role_info_message": "Um neuen Mitgliedern Zugriff auf ein Projekt zu geben, füge sie bitte unten einem Team hinzu. Mit Teams kannst du steuern, wer auf welches Projekt zugreifen kann.",
"organization_role": "Organisationsrolle",
"owner_role_description": "Besitzer haben die volle Kontrolle über die Organisation.",
"please_fill_all_member_fields": "Bitte fülle alle Felder aus, um ein neues Mitglied hinzuzufügen.",
"please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"select_member": "Mitglied auswählen",
"select_project": "Projekt auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",
@@ -1255,11 +1266,15 @@
"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": "Hintergründe",
"background_styling": "Hintergrundgestaltung",
"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",
@@ -1271,7 +1286,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": "Kartenstil",
"card_styling": "Kartengestaltung",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
"caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Dieser Abschluss wird in Follow-ups verwendet. Wenn Sie ihn löschen, wird er aus allen Follow-ups entfernt. Sind Sie sicher, dass Sie ihn löschen möchten?",
"follow_ups_ending_card_delete_modal_title": "Abschlusskarte löschen?",
"follow_ups_hidden_field_error": "Verstecktes Feld wird in einem Follow-up verwendet. Bitte entfernen Sie es zuerst aus dem Follow-up.",
"follow_ups_include_hidden_fields": "Werte versteckter Felder einbeziehen",
"follow_ups_include_variables": "Variablenwerte einbeziehen",
"follow_ups_item_ending_tag": "Abschluss",
"follow_ups_item_issue_detected_tag": "Problem erkannt",
"follow_ups_item_response_tag": "Jede Antwort",
"follow_ups_item_send_email_tag": "E-Mail senden",
"follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu",
"follow_ups_modal_action_attach_response_data_description": "Fügt nur die Fragen bei, die in der Umfrageantwort beantwortet wurden",
"follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen",
"follow_ups_modal_action_body_label": "Inhalt",
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
@@ -1427,9 +1445,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",
@@ -1476,6 +1494,7 @@
"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",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
"until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben",
"untitled_block": "Unbenannter Block",
"update_options": "Optionen aktualisieren",
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
"upload": "Hochladen",

View File

@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Member",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read Docs",
"read_docs": "Read docs",
"recipients": "Recipients",
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"team_role": "Team role",
"teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"hidden_field": "Hidden field",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "You've got a new organization member!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"new_email_verification_text": "To verify your new email address, please click the button below:",
"number_variable": "Number variable",
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
"response_data": "Response data",
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
"response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
"schedule_your_meeting": "Schedule your meeting",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form",
"survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
"survey_response_finished_email_view_survey_summary": "View survey summary",
"text_variable": "Text variable",
"verification_email_click_on_this_link": "You can also click on this link:",
"verification_email_heading": "Almost there!",
"verification_email_hey": "Hey \uD83D\uDC4B",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Manage teams",
"no_teams_found": "No teams found",
"only_organization_owners_and_managers_can_manage_teams": "Only organization owners and managers can manage teams.",
"permission": "Permission",
"team_name": "Team Name",
"team_settings_description": "See which teams can access this project."
@@ -1166,13 +1173,17 @@
"manage_team": "Manage team",
"manage_team_disabled": "Only organization owners, managers and team admins can manage teams.",
"manager_role_description": "Managers can access all projects and add and remove members.",
"member": "Member",
"member_role_description": "Members can work in selected projects.",
"member_role_info_message": "To give new members access to a project, please add them to a Team below. With Teams you can manage who has access to which project.",
"organization_role": "Organization role",
"owner_role_description": "Owners have full control over the organization.",
"please_fill_all_member_fields": "Please fill all the fields to add a new member.",
"please_fill_all_project_fields": "Please fill all the fields to add a new project.",
"read": "Read",
"read_write": "Read & Write",
"select_member": "Select member",
"select_project": "Select project",
"team_admin": "Team Admin",
"team_created_successfully": "Team created successfully.",
"team_deleted_successfully": "Team deleted successfully.",
@@ -1255,11 +1266,15 @@
"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",
@@ -1271,7 +1286,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?",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "This ending card is used in follow-ups. Deleting it will remove it from all follow-ups. Are you sure you want to delete it?",
"follow_ups_ending_card_delete_modal_title": "Delete ending card?",
"follow_ups_hidden_field_error": "Hidden field is used in a follow-up. Please remove it from follow-up first.",
"follow_ups_include_hidden_fields": "Include hidden field values",
"follow_ups_include_variables": "Include variable values",
"follow_ups_item_ending_tag": "Ending(s)",
"follow_ups_item_issue_detected_tag": "Issue detected",
"follow_ups_item_response_tag": "Any response",
"follow_ups_item_send_email_tag": "Send email",
"follow_ups_modal_action_attach_response_data_description": "Add the data of the survey response to the follow-up",
"follow_ups_modal_action_attach_response_data_description": "Attaches only the questions that were answered in the survey response",
"follow_ups_modal_action_attach_response_data_label": "Attach response data",
"follow_ups_modal_action_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
@@ -1427,9 +1445,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",
@@ -1476,6 +1494,7 @@
"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",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
"until_they_submit_a_response": "Ask until they submit a response",
"untitled_block": "Untitled Block",
"update_options": "Update options",
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
"upload": "Upload",

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Miembro",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Leer documentación",
"recipients": "Destinatarios",
"remove": "Eliminar",
"remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
"report_survey": "Reportar encuesta",
"request_pricing": "Solicitar precios",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Acceso de equipo",
"team_id": "ID de equipo",
"team_name": "Nombre del equipo",
"teams": "Control de acceso",
"team_role": "Rol del equipo",
"teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
"hidden_field": "Campo oculto",
"imprint": "Aviso legal",
"invite_accepted_email_heading": "Hola",
"invite_accepted_email_subject": "¡Tienes un nuevo miembro en la organización!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "te ha invitado a unirte a Formbricks. Para aceptar la invitación, por favor haz clic en el enlace a continuación:",
"invite_member_email_subject": "¡Estás invitado a colaborar en Formbricks!",
"new_email_verification_text": "Para verificar tu nueva dirección de correo electrónico, por favor haz clic en el botón a continuación:",
"number_variable": "Variable numérica",
"password_changed_email_heading": "Contraseña cambiada",
"password_changed_email_text": "Tu contraseña se ha cambiado correctamente.",
"password_reset_notify_email_subject": "Tu contraseña de Formbricks ha sido cambiada",
"privacy_policy": "Política de privacidad",
"reject": "Rechazar",
"render_email_response_value_file_upload_response_link_not_included": "El enlace al archivo subido no está incluido por razones de privacidad de datos",
"response_data": "Datos de respuesta",
"response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅",
"response_finished_email_subject_with_email": "{personEmail} acaba de completar tu encuesta {surveyName} ✅",
"schedule_your_meeting": "Programa tu reunión",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desactivar notificaciones para este formulario",
"survey_response_finished_email_view_more_responses": "Ver {responseCount} respuestas más",
"survey_response_finished_email_view_survey_summary": "Ver resumen de la encuesta",
"text_variable": "Variable de texto",
"verification_email_click_on_this_link": "También puedes hacer clic en este enlace:",
"verification_email_heading": "¡Ya casi está!",
"verification_email_hey": "Hola 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Gestionar equipos",
"no_teams_found": "No se han encontrado equipos",
"only_organization_owners_and_managers_can_manage_teams": "Solo los propietarios y gestores de la organización pueden gestionar equipos.",
"permission": "Permiso",
"team_name": "Nombre del equipo",
"team_settings_description": "Consulta qué equipos pueden acceder a este proyecto."
@@ -1166,13 +1173,17 @@
"manage_team": "Gestionar equipo",
"manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
"member": "Miembro",
"member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.",
"member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.",
"organization_role": "Rol en la organización",
"owner_role_description": "Los propietarios tienen control total sobre la organización.",
"please_fill_all_member_fields": "Por favor, rellena todos los campos para añadir un nuevo miembro.",
"please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"select_member": "Seleccionar miembro",
"select_project": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",
@@ -1255,11 +1266,15 @@
"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 de fondo",
"background_styling": "Estilo del 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",
@@ -1271,7 +1286,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 tarjeta",
"card_styling": "Estilo de la tarjeta",
"casual": "Informal",
"caution_edit_duplicate": "Duplicar y editar",
"caution_edit_published_survey": "¿Editar una encuesta publicada?",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Esta tarjeta de finalización se utiliza en seguimientos. Al eliminarla se quitará de todos los seguimientos. ¿Estás seguro de que quieres eliminarla?",
"follow_ups_ending_card_delete_modal_title": "¿Eliminar tarjeta de finalización?",
"follow_ups_hidden_field_error": "El campo oculto se utiliza en un seguimiento. Por favor, elimínalo primero del seguimiento.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variables",
"follow_ups_item_ending_tag": "Finalización(es)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Cualquier respuesta",
"follow_ups_item_send_email_tag": "Enviar correo electrónico",
"follow_ups_modal_action_attach_response_data_description": "Añadir los datos de la respuesta de la encuesta al seguimiento",
"follow_ups_modal_action_attach_response_data_description": "Adjunta solo las preguntas que fueron respondidas en la respuesta de la encuesta",
"follow_ups_modal_action_attach_response_data_label": "Adjuntar datos de respuesta",
"follow_ups_modal_action_body_label": "Cuerpo",
"follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico",
@@ -1427,9 +1445,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",
@@ -1476,6 +1494,7 @@
"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",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "Tienes cambios sin guardar en tu encuesta. ¿Quieres guardarlos antes de salir?",
"until_they_submit_a_response": "Preguntar hasta que envíen una respuesta",
"untitled_block": "Bloque sin título",
"update_options": "Actualizar opciones",
"upgrade_notice_description": "Crea encuestas multilingües y desbloquea muchas más funciones",
"upgrade_notice_title": "Desbloquea encuestas multilingües con un plan superior",
"upload": "Subir",

View File

@@ -261,6 +261,7 @@
"maximum": "Max",
"member": "Membre",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
"read_docs": "Lire les documents",
"read_docs": "Lire la documentation",
"recipients": "Destinataires",
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Accès",
"team_id": "Identifiant de l'équipe",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
"number_variable": "Variable numérique",
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
"response_data": "Données de réponse",
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
"response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
"schedule_your_meeting": "Planifier votre rendez-vous",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Désactiver les notifications pour ce formulaire",
"survey_response_finished_email_view_more_responses": "Voir {responseCount} réponses supplémentaires",
"survey_response_finished_email_view_survey_summary": "Voir le résumé de l'enquête",
"text_variable": "Variable texte",
"verification_email_click_on_this_link": "Vous pouvez également cliquer sur ce lien :",
"verification_email_heading": "Presque là !",
"verification_email_hey": "Salut 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Gérer les équipes",
"no_teams_found": "Aucune équipe trouvée",
"only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.",
"permission": "Permission",
"team_name": "Nom de l'équipe",
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
@@ -1166,13 +1173,17 @@
"manage_team": "Gérer l'équipe",
"manage_team_disabled": "Seuls les propriétaires de l'organisation, les gestionnaires et les administrateurs d'équipe peuvent gérer les équipes.",
"manager_role_description": "Les gestionnaires peuvent accéder à tous les projets et ajouter et supprimer des membres.",
"member": "Membre",
"member_role_description": "Les membres peuvent travailler sur des projets sélectionnés.",
"member_role_info_message": "Pour donner accès à un projet aux nouveaux membres, veuillez les ajouter à une équipe ci-dessous. Avec les équipes, vous pouvez gérer qui a accès à quel projet.",
"organization_role": "Rôle dans l'organisation",
"owner_role_description": "Les propriétaires ont un contrôle total sur l'organisation.",
"please_fill_all_member_fields": "Veuillez remplir tous les champs pour ajouter un nouveau membre.",
"please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.",
"read": "Lire",
"read_write": "Lire et Écrire",
"select_member": "Sélectionner membre",
"select_project": "Sélectionner projet",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",
@@ -1255,11 +1266,15 @@
"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 de fond",
"background_styling": "Style d'arrière-plan",
"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",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Cette carte de fin est utilisée dans les suivis. La supprimer la retirera de tous les suivis. Êtes-vous sûr de vouloir la supprimer ?",
"follow_ups_ending_card_delete_modal_title": "Supprimer la carte de fin ?",
"follow_ups_hidden_field_error": "Le champ caché est utilisé dans un suivi. Veuillez d'abord le supprimer du suivi.",
"follow_ups_include_hidden_fields": "Inclure les valeurs des champs cachés",
"follow_ups_include_variables": "Inclure les valeurs des variables",
"follow_ups_item_ending_tag": "Fin(s)",
"follow_ups_item_issue_detected_tag": "Problème détecté",
"follow_ups_item_response_tag": "Une réponse quelconque",
"follow_ups_item_send_email_tag": "Envoyer un e-mail",
"follow_ups_modal_action_attach_response_data_description": "Ajouter les données de la réponse à l'enquête au suivi",
"follow_ups_modal_action_attach_response_data_description": "Joint uniquement les questions auxquelles on a répondu dans la réponse au sondage",
"follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse",
"follow_ups_modal_action_body_label": "Corps",
"follow_ups_modal_action_body_placeholder": "Corps de l'email",
@@ -1427,9 +1445,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",
@@ -1476,6 +1494,7 @@
"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",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
"until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse",
"untitled_block": "Bloc sans titre",
"update_options": "Mettre à jour les options",
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
"upload": "Télécharger",

View File

@@ -261,6 +261,7 @@
"maximum": "最大",
"member": "メンバー",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
@@ -340,6 +341,7 @@
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"remove": "削除",
"remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
"report_survey": "フォームを報告",
"request_pricing": "料金を問い合わせる",
@@ -349,10 +351,10 @@
"responses": "回答",
"restart": "再開",
"role": "役割",
"role_organization": "役割(組織)",
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
"save_as_draft": "下書きとして保存",
"save_changes": "変更を保存",
"saving": "保存中",
"search": "検索",
@@ -407,7 +409,8 @@
"team_access": "チームアクセス",
"team_id": "チームID",
"team_name": "チーム名",
"teams": "アクセス制御",
"team_role": "チームの役割",
"teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
@@ -422,6 +425,7 @@
"updated": "更新済み",
"updated_at": "更新日時",
"upload": "アップロード",
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
"url": "URL",
"user": "ユーザー",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
"imprint": "企業情報",
"invite_accepted_email_heading": "こんにちは",
"invite_accepted_email_subject": "新しい組織メンバーが加わりました!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "が、Formbricksへの参加をあなたに招待しました。招待を承認するには、以下のリンクをクリックしてください。",
"invite_member_email_subject": "Formbricksでのコラボレーションに招待されました",
"new_email_verification_text": "新しいメールアドレスを認証するには、以下のボタンをクリックしてください。",
"number_variable": "数値変数",
"password_changed_email_heading": "パスワードが変更されました",
"password_changed_email_text": "パスワードが正常に変更されました。",
"password_reset_notify_email_subject": "Formbricksのパスワードが変更されました",
"privacy_policy": "プライバシーポリシー",
"reject": "拒否",
"render_email_response_value_file_upload_response_link_not_included": "データプライバシーのため、アップロードされたファイルへのリンクは含まれていません",
"response_data": "回答データ",
"response_finished_email_subject": "{surveyName} の回答が完了しました ✅",
"response_finished_email_subject_with_email": "{personEmail} が {surveyName} フォームを完了しました ✅",
"schedule_your_meeting": "ミーティングを予約",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "このフォームの通知をオフにする",
"survey_response_finished_email_view_more_responses": "さらに {responseCount} 件の回答を見る",
"survey_response_finished_email_view_survey_summary": "フォームの概要を見る",
"text_variable": "テキスト変数",
"verification_email_click_on_this_link": "このリンクをクリックすることもできます:",
"verification_email_heading": "あと少しです!",
"verification_email_hey": "こんにちは 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "チームを管理",
"no_teams_found": "チームが見つかりません",
"only_organization_owners_and_managers_can_manage_teams": "組織のオーナーまたは管理者のみがチームを管理できます。",
"permission": "権限",
"team_name": "チーム名",
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
@@ -1166,13 +1173,17 @@
"manage_team": "チームを管理",
"manage_team_disabled": "組織のオーナー、管理者、チーム管理者のみがチームを管理できます。",
"manager_role_description": "管理者はすべてのプロジェクトにアクセスでき、メンバーを追加および削除できます。",
"member": "メンバー",
"member_role_description": "メンバーは選択されたプロジェクトで作業できます。",
"member_role_info_message": "新しいメンバーにプロジェクトへのアクセス権を付与するには、以下のチームに追加してください。チームを使用すると、誰がどのプロジェクトにアクセスできるかを管理できます。",
"organization_role": "組織の役割",
"owner_role_description": "オーナーは組織を完全に制御できます。",
"please_fill_all_member_fields": "新しいメンバーを追加するには、すべてのフィールドを記入してください。",
"please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。",
"read": "読み取り",
"read_write": "読み書き",
"select_member": "メンバーを選択",
"select_project": "プロジェクトを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",
@@ -1255,11 +1266,15 @@
"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": "ボタンのラベル",
@@ -1271,7 +1286,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": "公開済みのフォームを編集しますか?",
@@ -1327,6 +1342,7 @@
"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": "任意のフォームが表示された後、このフォームが再表示されるまでの日数。",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "この終了カードはフォローアップで使用されています。これを削除すると、すべてのフォローアップから削除されます。本当に削除しますか?",
"follow_ups_ending_card_delete_modal_title": "終了カードを削除しますか?",
"follow_ups_hidden_field_error": "非表示フィールドはフォローアップで使用されています。まず、フォローアップから削除してください。",
"follow_ups_include_hidden_fields": "非表示フィールドの値を含める",
"follow_ups_include_variables": "変数の値を含める",
"follow_ups_item_ending_tag": "終了",
"follow_ups_item_issue_detected_tag": "問題が検出されました",
"follow_ups_item_response_tag": "任意の回答",
"follow_ups_item_send_email_tag": "メールを送信",
"follow_ups_modal_action_attach_response_data_description": "フォームの回答データをフォローアップに追加する",
"follow_ups_modal_action_attach_response_data_description": "アンケート回答で答えられた質問のみを添付します",
"follow_ups_modal_action_attach_response_data_label": "回答データを添付",
"follow_ups_modal_action_body_label": "本文",
"follow_ups_modal_action_body_placeholder": "メールの本文",
@@ -1427,9 +1445,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": "さらに必要な場合は、",
@@ -1476,6 +1494,7 @@
"load_segment": "セグメントを読み込み",
"logic_error_warning": "変更するとロジックエラーが発生します",
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
"logo_settings": "ロゴ設定",
"long_answer": "長文回答",
"long_answer_toggle_description": "回答者が長文の複数行の回答を書けるようにします。",
"lower_label": "下限ラベル",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
"until_they_submit_a_response": "回答が提出されるまで質問する",
"untitled_block": "無題のブロック",
"update_options": "オプションを更新",
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
"upload": "アップロード",

View File

@@ -261,6 +261,7 @@
"maximum": "Maximaal",
"member": "Lid",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens",
"minimum": "Minimum",
@@ -337,9 +338,10 @@
"quota": "Quotum",
"quotas": "Quota",
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
"read_docs": "Lees Documenten",
"read_docs": "Documentatie lezen",
"recipients": "Ontvangers",
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"report_survey": "Verslag enquête",
"request_pricing": "Vraag prijzen aan",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Teamtoegang",
"team_id": "Team-ID",
"team_name": "Teamnaam",
"teams": "Toegangscontrole",
"team_role": "Teamrol",
"teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hoi",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "nodigde je uit om je bij Formbricks aan te sluiten. Om de uitnodiging te accepteren, klikt u op de onderstaande link:",
"invite_member_email_subject": "Je bent uitgenodigd om samen te werken aan Formbricks!",
"new_email_verification_text": "Om uw nieuwe e-mailadres te verifiëren, klikt u op de onderstaande knop:",
"number_variable": "Numerieke variabele",
"password_changed_email_heading": "Wachtwoord gewijzigd",
"password_changed_email_text": "Uw wachtwoord is succesvol gewijzigd.",
"password_reset_notify_email_subject": "Uw Formbricks-wachtwoord is gewijzigd",
"privacy_policy": "Privacybeleid",
"reject": "Afwijzen",
"render_email_response_value_file_upload_response_link_not_included": "De link naar het geüploade bestand is om redenen van gegevensprivacy niet opgenomen",
"response_data": "Responsgegevens",
"response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅",
"response_finished_email_subject_with_email": "{personEmail} heeft zojuist uw {surveyName} enquête voltooid ✅",
"schedule_your_meeting": "Plan uw vergadering",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Schakel meldingen voor dit formulier uit",
"survey_response_finished_email_view_more_responses": "Bekijk nog {responseCount} reacties",
"survey_response_finished_email_view_survey_summary": "Bekijk de samenvatting van het onderzoek",
"text_variable": "Tekstvariabele",
"verification_email_click_on_this_link": "U kunt ook op deze link klikken:",
"verification_email_heading": "Bijna daar!",
"verification_email_hey": "Hé 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Beheer teams",
"no_teams_found": "Geen teams gevonden",
"only_organization_owners_and_managers_can_manage_teams": "Alleen eigenaren en managers van organisaties kunnen teams beheren.",
"permission": "Toestemming",
"team_name": "Teamnaam",
"team_settings_description": "Bekijk welke teams toegang hebben tot dit project."
@@ -1166,13 +1173,17 @@
"manage_team": "Beheer team",
"manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
"member": "Lid",
"member_role_description": "Leden kunnen in geselecteerde projecten werken.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.",
"organization_role": "Organisatierol",
"owner_role_description": "Eigenaars hebben volledige controle over de organisatie.",
"please_fill_all_member_fields": "Vul alle velden in om een nieuw lid toe te voegen.",
"please_fill_all_project_fields": "Vul alle velden in om een nieuw project toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"select_member": "Selecteer lid",
"select_project": "Selecteer project",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",
@@ -1255,11 +1266,15 @@
"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": "Achtergrondstyling",
"background_styling": "Achtergrondstijl",
"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",
@@ -1271,7 +1286,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": "Kaartstyling",
"card_styling": "Kaartstijl",
"casual": "Casual",
"caution_edit_duplicate": "Dupliceren en bewerken",
"caution_edit_published_survey": "Een gepubliceerde enquête bewerken?",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Deze eindkaart wordt gebruikt bij vervolgacties. Als u het verwijdert, wordt het uit alle vervolgacties verwijderd. Weet je zeker dat je het wilt verwijderen?",
"follow_ups_ending_card_delete_modal_title": "Eindkaart verwijderen?",
"follow_ups_hidden_field_error": "Verborgen veld wordt gebruikt in een follow-up. Verwijder het eerst uit de follow-up.",
"follow_ups_include_hidden_fields": "Inclusief waarden van verborgen velden",
"follow_ups_include_variables": "Inclusief variabele waarden",
"follow_ups_item_ending_tag": "Einde(n)",
"follow_ups_item_issue_detected_tag": "Probleem gedetecteerd",
"follow_ups_item_response_tag": "Enige reactie",
"follow_ups_item_send_email_tag": "E-mail verzenden",
"follow_ups_modal_action_attach_response_data_description": "Voeg de gegevens van de enquêtereactie toe aan de follow-up",
"follow_ups_modal_action_attach_response_data_description": "Voegt alleen de vragen toe die zijn beantwoord in de enquêterespons",
"follow_ups_modal_action_attach_response_data_label": "Reactiegegevens bijvoegen",
"follow_ups_modal_action_body_label": "Lichaam",
"follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail",
@@ -1427,9 +1445,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",
@@ -1476,6 +1494,7 @@
"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",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "Er zijn niet-opgeslagen wijzigingen in uw enquête. Wilt u ze bewaren voordat u vertrekt?",
"until_they_submit_a_response": "Vraag totdat ze een reactie indienen",
"untitled_block": "Naamloos blok",
"update_options": "Opties bijwerken",
"upgrade_notice_description": "Creëer meertalige enquêtes en ontgrendel nog veel meer functies",
"upgrade_notice_title": "Ontgrendel meertalige enquêtes met een hoger plan",
"upload": "Uploaden",

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
@@ -337,9 +338,10 @@
"quota": "Cota",
"quotas": "Cotas",
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
"read_docs": "Ler Documentação",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "remover",
"remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"team_role": "Função na equipe",
"teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressum",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
"number_variable": "Variável numérica",
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
"schedule_your_meeting": "Agendar sua reunião",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo da pesquisa",
"text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Você também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Oi 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Gerenciar Equipes",
"no_teams_found": "Nenhuma equipe encontrada",
"only_organization_owners_and_managers_can_manage_teams": "Apenas proprietários e gerentes da organização podem gerenciar equipes.",
"permission": "Permissão",
"team_name": "Nome da equipe",
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
@@ -1166,13 +1173,17 @@
"manage_team": "Gerenciar equipe",
"manage_team_disabled": "Apenas proprietários da organização, gerentes e administradores da equipe podem gerenciar equipes.",
"manager_role_description": "Os gerentes podem acessar todos os projetos e adicionar e remover membros.",
"member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Para dar acesso a novos membros a um projeto, por favor, adicione-os a uma equipe abaixo. Com equipes, você pode gerenciar quem tem acesso a qual projeto.",
"organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controle total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",
@@ -1255,11 +1266,15 @@
"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 de Fundo",
"background_styling": "Estilo do plano 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",
@@ -1271,7 +1286,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": "Estilização de Cartão",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar uma pesquisa publicada?",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Este final é usado em acompanhamentos. Excluí-lo o removerá de todos os acompanhamentos. Tem certeza de que deseja excluí-lo?",
"follow_ups_ending_card_delete_modal_title": "Excluir cartão de final?",
"follow_ups_hidden_field_error": "O campo oculto está sendo usado em um acompanhamento. Por favor, remova-o do acompanhamento primeiro.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Final(is)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar e-mail",
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento",
"follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta da pesquisa",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
@@ -1427,9 +1445,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",
@@ -1476,6 +1494,7 @@
"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",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
"until_they_submit_a_response": "Perguntar até que enviem uma resposta",
"untitled_block": "Bloco sem título",
"update_options": "Atualizar opções",
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
"upload": "Enviar",

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
"read_docs": "Ler Documentos",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "Remover",
"remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"team_role": "Função na equipa",
"teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressão",
"invite_accepted_email_heading": "Olá",
"invite_accepted_email_subject": "Tem um novo membro na organização!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
"number_variable": "Variável numérica",
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅",
"schedule_your_meeting": "Agende a sua reunião",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo do inquérito",
"text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Olá 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Gerir equipas",
"no_teams_found": "Nenhuma equipa encontrada",
"only_organization_owners_and_managers_can_manage_teams": "Apenas os proprietários e gestores da organização podem gerir equipas.",
"permission": "Permissão",
"team_name": "Nome da Equipa",
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
@@ -1166,13 +1173,17 @@
"manage_team": "Gerir equipa",
"manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.",
"manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.",
"member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Adicione os membros que deseja a uma Equipa abaixo. Nesta secção, pode gerir quem tem acesso a cada projeto.",
"organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controlo total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Ler",
"read_write": "Ler e Escrever",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",
@@ -1255,11 +1266,15 @@
"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",
@@ -1271,7 +1286,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 do cartão",
"card_styling": "Estilo de cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar um inquérito publicado?",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Este cartão de encerramento é utilizado em seguimentos. Eliminá-lo irá removê-lo de todos os seguimentos. Tem a certeza de que deseja eliminá-lo?",
"follow_ups_ending_card_delete_modal_title": "Eliminar cartão de encerramento?",
"follow_ups_hidden_field_error": "O campo oculto é usado num seguimento. Por favor, remova-o do seguimento primeiro.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Encerramento(s)",
"follow_ups_item_issue_detected_tag": "Problema detetado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar email",
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta do inquérito ao acompanhamento",
"follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta ao inquérito",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do email",
@@ -1427,9 +1445,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",
@@ -1476,6 +1494,7 @@
"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",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
"until_they_submit_a_response": "Perguntar até que submetam uma resposta",
"untitled_block": "Bloco sem título",
"update_options": "Atualizar opções",
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
"upload": "Carregar",

View File

@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Membru",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
@@ -340,6 +341,7 @@
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"remove": "Șterge",
"remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
@@ -349,10 +351,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",
@@ -407,7 +409,8 @@
"team_access": "Acces echipă",
"team_id": "ID echipă",
"team_name": "Nume echipă",
"teams": "Control acces",
"team_role": "Rol în echipă",
"teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
@@ -422,6 +425,7 @@
"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",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
"hidden_field": "Câmp ascuns",
"imprint": "Amprentă",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Ai un nou membru în organizație!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!",
"new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:",
"number_variable": "Variabilă numerică",
"password_changed_email_heading": "Parola modificată",
"password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.",
"password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată",
"privacy_policy": "Politica de confidențialitate",
"reject": "Respinge",
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
"response_data": "Datele răspunsului",
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
"response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅",
"schedule_your_meeting": "Programați întâlnirea",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Dezactivează notificările pentru acest formular",
"survey_response_finished_email_view_more_responses": "Vizualizați {responseCount} mai multe răspunsuri",
"survey_response_finished_email_view_survey_summary": "Vizualizați sumarul sondajului",
"text_variable": "Variabilă text",
"verification_email_click_on_this_link": "De asemenea, puteți face clic pe acest link:",
"verification_email_heading": "Aproape gata!",
"verification_email_hey": "Salut 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "Gestionați echipele",
"no_teams_found": "Nicio echipă găsită",
"only_organization_owners_and_managers_can_manage_teams": "Doar proprietarii de organizație și managerii pot gestiona echipele.",
"permission": "Permisiune",
"team_name": "Nume echipă",
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
@@ -1166,13 +1173,17 @@
"manage_team": "Gestionați echipa",
"manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.",
"manager_role_description": "Managerii pot accesa toate proiectele și pot adăuga sau elimina membri.",
"member": "Membru",
"member_role_description": "Membrii pot lucra în proiectele selectate.",
"member_role_info_message": "Pentru a oferi membrilor noi acces la un proiect, vă rugăm să-i adăugați la o Echipă mai jos. Cu Echipe puteți gestiona cine are acces la ce proiect.",
"organization_role": "Rol în organizație",
"owner_role_description": "Proprietarii au control total asupra organizației.",
"please_fill_all_member_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou membru.",
"please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.",
"read": "Citește",
"read_write": "Citire & Scriere",
"select_member": "Selectează membrul",
"select_project": "Selectează proiectul",
"team_admin": "Administrator Echipe",
"team_created_successfully": "Echipă creată cu succes",
"team_deleted_successfully": "Echipă ștearsă cu succes.",
@@ -1260,6 +1271,10 @@
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
"bulk_edit": "Editare în bloc",
"bulk_edit_description": "Editați toate opțiunile de mai jos, câte una pe linie. Liniile goale vor fi omise, iar duplicatele vor fi eliminate.",
"bulk_edit_options": "Opțiuni de editare în bloc",
"bulk_edit_options_for": "Editare în bloc a opțiunilor pentru {language}",
"button_external": "Activează link extern",
"button_external_description": "Adaugă un buton care deschide un URL extern într-o filă nouă",
"button_label": "Etichetă buton",
@@ -1327,6 +1342,7 @@
"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.",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în follow-up-uri ulterioare. Ștergerea sa o va elimina din toate follow-up-uri ulterioare. Ești sigur că vrei să o ștergi?",
"follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?",
"follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.",
"follow_ups_include_hidden_fields": "Include valorile câmpurilor ascunse",
"follow_ups_include_variables": "Include valorile variabilelor",
"follow_ups_item_ending_tag": "Finalizare",
"follow_ups_item_issue_detected_tag": "Problemă detectată",
"follow_ups_item_response_tag": "Orice răspuns",
"follow_ups_item_send_email_tag": "Trimite email",
"follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la follow-up",
"follow_ups_modal_action_attach_response_data_description": "Atașează doar întrebările la care s-a răspuns în răspunsul sondajului",
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
"follow_ups_modal_action_body_label": "Corp",
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
@@ -1427,9 +1445,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ă",
@@ -1476,6 +1494,7 @@
"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ă",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
"until_they_submit_a_response": "Întreabă până când trimit un răspuns",
"untitled_block": "Bloc fără titlu",
"update_options": "Actualizați opțiunile",
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
"upload": "Încărcați",

2955
apps/web/locales/sv-SE.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成员",
"members": "成员",
"members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
@@ -337,9 +338,10 @@
"quota": "配额",
"quotas": "配额",
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
"read_docs": "阅读 文档",
"read_docs": "阅读文档",
"recipients": "收件人",
"remove": "移除",
"remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
"report_survey": "报告调查",
"request_pricing": "请求 定价",
@@ -349,10 +351,10 @@
"responses": "反馈",
"restart": "重新启动",
"role": "角色",
"role_organization": "角色 (组织)",
"saas": "SaaS",
"sales": "销售",
"save": "保存",
"save_as_draft": "保存为草稿",
"save_changes": "保存 更改",
"saving": "保存",
"search": "搜索",
@@ -407,7 +409,8 @@
"team_access": "团队 访问",
"team_id": "团队 ID",
"team_name": "团队 名称",
"teams": "访问控制",
"team_role": "团队角色",
"teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
@@ -422,6 +425,7 @@
"updated": "已更新",
"updated_at": "更新 于",
"upload": "上传",
"upload_failed": "上传失败,请重试。",
"upload_input_description": "点击 或 拖动 上传 文件",
"url": "URL",
"user": "用户",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
"imprint": "印记",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "你 有 一个 新 成员 进入 组织 了!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "邀请您加入他们在 Formbricks 。要接受邀请,请点击下面的链接:",
"invite_member_email_subject": "您 被 邀请 来 协作 于 Formbricks",
"new_email_verification_text": "要 验证 您 的 新 邮箱 地址 ,请 点击 下方 的 按钮 ",
"number_variable": "数字变量",
"password_changed_email_heading": "密码 已更改",
"password_changed_email_text": "您的 密码已成功更改",
"password_reset_notify_email_subject": "您的 Formbricks 密码已更改",
"privacy_policy": "隐私政策",
"reject": "拒绝",
"render_email_response_value_file_upload_response_link_not_included": "未包括上传文件的链接 数据隐私原因",
"response_data": "响应数据",
"response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 刚刚完成了你的 {surveyName} 调查 ✅",
"schedule_your_meeting": "安排你的会议",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "关闭 此表单 的通知",
"survey_response_finished_email_view_more_responses": "查看 {responseCount} 更多 响应",
"survey_response_finished_email_view_survey_summary": "查看 问卷 摘要",
"text_variable": "文本变量",
"verification_email_click_on_this_link": "您 也 可以 点击 此 链接:",
"verification_email_heading": "马上就好!",
"verification_email_hey": "嗨 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "管理 团队",
"no_teams_found": "未找到 团队",
"only_organization_owners_and_managers_can_manage_teams": "只有 组织 拥有者 和 经理 可以 管理 团队。",
"permission": "权限",
"team_name": "团队名称",
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
@@ -1166,13 +1173,17 @@
"manage_team": "管理团队",
"manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。",
"manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。",
"member": "成员",
"member_role_description": "成员 可以 在 选定 项目 中 工作。",
"member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队 你 可以 管理 谁 可以 访问 哪个 项目 。",
"organization_role": "组织角色",
"owner_role_description": "所有者拥有对组织的完全控制权。",
"please_fill_all_member_fields": "请 填写 所有 字段 以 添加 新 成员。",
"please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。",
"read": "阅读",
"read_write": "读 & 写",
"select_member": "选择成员",
"select_project": "选择项目",
"team_admin": "团队管理员",
"team_created_successfully": "团队 创建 成功",
"team_deleted_successfully": "团队 删除 成功",
@@ -1255,11 +1266,15 @@
"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": "按钮标签",
@@ -1271,7 +1286,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?",
@@ -1327,6 +1342,7 @@
"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": "在显示此调查之前,需等待的天数。",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "此结束卡片 用于 后续跟踪. 删除 它 将会 从 所有 后续跟踪 中 移除. 确定 要 删除 它 吗?",
"follow_ups_ending_card_delete_modal_title": "删除 结尾卡片?",
"follow_ups_hidden_field_error": "隐藏 字段 用于 后续 。请 先 从 后续 中 移除 它 。",
"follow_ups_include_hidden_fields": "包括隐藏字段值",
"follow_ups_include_variables": "包括变量值",
"follow_ups_item_ending_tag": "结尾",
"follow_ups_item_issue_detected_tag": "问题 检测",
"follow_ups_item_response_tag": "任何 响应",
"follow_ups_item_send_email_tag": "发送 邮件",
"follow_ups_modal_action_attach_response_data_description": "添加 调查 响应 数据 到 跟进",
"follow_ups_modal_action_attach_response_data_description": "仅附加调查响应中已回答的问题",
"follow_ups_modal_action_attach_response_data_label": "附加响应数据",
"follow_ups_modal_action_body_label": "正文",
"follow_ups_modal_action_body_placeholder": "电子邮件正文",
@@ -1427,9 +1445,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": "如果你需要更多,请",
@@ -1476,6 +1494,7 @@
"load_segment": "载入 段落",
"logic_error_warning": "更改 将 导致 逻辑 错误",
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
"logo_settings": "Logo 设置",
"long_answer": "长答案",
"long_answer_toggle_description": "允许受访者填写较长的多行答案。",
"lower_label": "下限标签",
@@ -1504,10 +1523,12 @@
"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}",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
"until_they_submit_a_response": "持续显示直到提交回应",
"untitled_block": "未命名区块",
"update_options": "更新选项",
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
"upload": "上传",

View File

@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成員",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
@@ -340,6 +341,7 @@
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
"remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
@@ -349,10 +351,10 @@
"responses": "回應",
"restart": "重新開始",
"role": "角色",
"role_organization": "角色(組織)",
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
"save_as_draft": "儲存為草稿",
"save_changes": "儲存變更",
"saving": "儲存",
"search": "搜尋",
@@ -407,7 +409,8 @@
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"team_role": "團隊角色",
"teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
@@ -422,6 +425,7 @@
"updated": "已更新",
"updated_at": "更新時間",
"upload": "上傳",
"upload_failed": "上傳失敗。請再試一次。",
"upload_input_description": "點擊或拖曳以上傳檔案。",
"url": "網址",
"user": "使用者",
@@ -469,6 +473,7 @@
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
"imprint": "版本訊息",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "您有一位新的組織成員!",
@@ -480,12 +485,14 @@
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請請點擊以下連結",
"invite_member_email_subject": "您被邀請協作 Formbricks",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
"number_variable": "數字變數",
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
"privacy_policy": "隱私權政策",
"reject": "拒絕",
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
"response_data": "回應資料",
"response_finished_email_subject": "{surveyName} 的回應已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅",
"schedule_your_meeting": "安排你的會議",
@@ -497,6 +504,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "關閉此表單的通知",
"survey_response_finished_email_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
"survey_response_finished_email_view_survey_summary": "檢視問卷摘要",
"text_variable": "文字變數",
"verification_email_click_on_this_link": "您也可以點擊此連結:",
"verification_email_heading": "快完成了!",
"verification_email_hey": "嗨 👋",
@@ -902,7 +910,6 @@
"teams": {
"manage_teams": "管理團隊",
"no_teams_found": "找不到團隊",
"only_organization_owners_and_managers_can_manage_teams": "只有組織擁有者和管理員才能管理團隊。",
"permission": "權限",
"team_name": "團隊名稱",
"team_settings_description": "查看哪些團隊可以存取此專案。"
@@ -1166,13 +1173,17 @@
"manage_team": "管理團隊",
"manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。",
"manager_role_description": "管理員可以存取所有專案,並新增和移除成員。",
"member": "成員",
"member_role_description": "成員可以在選定的專案中工作。",
"member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。",
"organization_role": "組織角色",
"owner_role_description": "擁有者對組織具有完全控制權。",
"please_fill_all_member_fields": "請填寫所有欄位以新增新成員。",
"please_fill_all_project_fields": "請填寫所有欄位以新增新專案。",
"read": "讀取",
"read_write": "讀取和寫入",
"select_member": "選擇成員",
"select_project": "選擇專案",
"team_admin": "團隊管理員",
"team_created_successfully": "團隊已成功建立。",
"team_deleted_successfully": "團隊已成功刪除。",
@@ -1255,11 +1266,15 @@
"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": "按鈕標籤",
@@ -1271,7 +1286,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": "編輯已發佈的調查?",
@@ -1327,6 +1342,7 @@
"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": "在顯示此問卷之前,需等待其他問卷顯示後的天數。",
@@ -1378,11 +1394,13 @@
"follow_ups_ending_card_delete_modal_text": "此結尾卡片用於後續追蹤中。刪除它將會從所有後續追蹤中移除。您確定要刪除它嗎?",
"follow_ups_ending_card_delete_modal_title": "刪除結尾卡片?",
"follow_ups_hidden_field_error": "隱藏欄位在後續追蹤中使用。請先從後續追蹤中移除。",
"follow_ups_include_hidden_fields": "包含隱藏欄位的值",
"follow_ups_include_variables": "包含變數的值",
"follow_ups_item_ending_tag": "結尾",
"follow_ups_item_issue_detected_tag": "偵測到問題",
"follow_ups_item_response_tag": "任何回應",
"follow_ups_item_send_email_tag": "發送電子郵件",
"follow_ups_modal_action_attach_response_data_description": "將調查回應的數據添加到後續",
"follow_ups_modal_action_attach_response_data_description": "僅附加在調查回應中回答過的問題",
"follow_ups_modal_action_attach_response_data_label": "附加 response data",
"follow_ups_modal_action_body_label": "內文",
"follow_ups_modal_action_body_placeholder": "電子郵件內文",
@@ -1427,9 +1445,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": "如果您需要更多,請",
@@ -1476,6 +1494,7 @@
"load_segment": "載入區隔",
"logic_error_warning": "變更將導致邏輯錯誤",
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
"logo_settings": "標誌設定",
"long_answer": "長回答",
"long_answer_toggle_description": "允許受訪者撰寫較長的多行回答。",
"lower_label": "下標籤",
@@ -1504,10 +1523,12 @@
"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'}'",
@@ -1652,6 +1673,7 @@
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
"until_they_submit_a_response": "持續詢問直到提交回應",
"untitled_block": "未命名區塊",
"update_options": "更新選項",
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
"upload": "上傳",

View File

@@ -28,7 +28,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
{enabledLanguages.map((surveyLanguage) => (
<button
key={surveyLanguage.language.code}
className="w-full rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
className="w-full truncate rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
onClick={() => {
setLanguage(surveyLanguage.language.code);
setShowLanguageSelect(false);

View File

@@ -3,7 +3,7 @@ import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { createTeamMembership } from "../team";
import { createTeamMembership, getTeamProjectIds } from "../team";
// Setup all mocks
const setupMocks = () => {
@@ -31,6 +31,7 @@ const setupMocks = () => {
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
@@ -55,7 +56,7 @@ describe("Team Management", () => {
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
@@ -90,7 +91,7 @@ describe("Team Management", () => {
role: "member" as OrganizationRole,
};
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
@@ -110,11 +111,68 @@ describe("Team Management", () => {
describe("error handling", () => {
test("throws error when database operation fails", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
});
describe("when team does not exist", () => {
test("skips membership creation and continues to next team", async () => {
const inviteWithMultipleTeams: CreateMembershipInvite = {
...MOCK_INVITE,
teamIds: ["non-existent-team", MOCK_IDS.teamId],
};
vi.mocked(prisma.team.findUnique)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(inviteWithMultipleTeams, MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledTimes(2);
expect(prisma.teamUser.create).toHaveBeenCalledTimes(1);
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin",
},
});
});
});
});
describe("getTeamProjectIds", () => {
test("returns team with projectTeams when team exists", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toEqual(MOCK_TEAM);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
});
test("returns null when team does not exist", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toBeNull();
});
});
});

View File

@@ -18,15 +18,18 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
for (const teamId of teamIds) {
const team = await getTeamProjectIds(teamId, invite.organizationId);
if (team) {
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
if (!team) {
logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance");
continue;
}
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
}
} catch (error) {
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
@@ -39,7 +42,10 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
};
export const getTeamProjectIds = reactCache(
async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => {
async (
teamId: string,
organizationId: string
): Promise<{ projectTeams: { projectId: string }[] } | null> => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
@@ -55,7 +61,7 @@ export const getTeamProjectIds = reactCache(
});
if (!team) {
throw new Error("Team not found");
return null;
}
return team;

View File

@@ -458,21 +458,15 @@ describe("Contacts Lib", () => {
attributes: [{ attributeKey: { key: "email", id: "key-1" }, value: "john@example.com" }],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([{ key: "email", id: "key-1" } as any])
.mockResolvedValueOnce([
{ key: "userId", id: "key-2" },
{ key: "email", id: "key-1" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([{ key: "email", id: "key-1" }] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
{ key: "name", id: "key-3" },
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
@@ -489,25 +483,15 @@ describe("Contacts Lib", () => {
],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
.mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap);
@@ -525,25 +509,15 @@ describe("Contacts Lib", () => {
],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
.mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
@@ -582,23 +556,16 @@ describe("Contacts Lib", () => {
test("creates missing attribute keys", async () => {
const attributeMap = { email: "email", userId: "userId" };
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
{ key: "name", id: "key-3" },
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 3 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "new-1",
environmentId: mockEnvironmentId,

View File

@@ -200,6 +200,50 @@ 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,
@@ -287,22 +331,36 @@ 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);
});
// Identify missing attribute keys (normalize keys to lowercase)
// Collect all unique CSV keys
const csvKeys = new Set<string>();
csvData.forEach((record) => {
Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase()));
Object.keys(record).forEach((key) => csvKeys.add(key));
});
const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
// Identify missing attribute keys (case-insensitive check)
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
// Create missing attribute keys
// Create missing attribute keys (use original CSV casing for new keys)
if (missingKeys.length > 0) {
// Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
const uniqueMissingKeys = new Map<string, string>();
missingKeys.forEach((key) => {
const lowerKey = key.toLowerCase();
if (!uniqueMissingKeys.has(lowerKey)) {
uniqueMissingKeys.set(lowerKey, key);
}
});
await prisma.contactAttributeKey.createMany({
data: missingKeys.map((key) => ({
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
key,
name: key,
environmentId,
@@ -310,10 +368,10 @@ export const createContactsFromCSV = async (
skipDuplicates: true,
});
// Fetch and update the attributeKeyMap with new keys
// Fetch and update the maps with new keys
const newAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
key: { in: missingKeys },
key: { in: Array.from(uniqueMissingKeys.values()) },
environmentId,
},
select: { key: true, id: true },
@@ -321,6 +379,7 @@ export const createContactsFromCSV = async (
newAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
}
@@ -328,18 +387,23 @@ export const createContactsFromCSV = async (
// Process contacts in parallel
const contactPromises = csvData.map(async (record) => {
// Normalize record keys to lowercase
const normalizedRecord: Record<string, string> = {};
// Map CSV keys to actual DB keys (case-insensitive matching, preserving DB key casing)
const mappedRecord: Record<string, string> = {};
Object.entries(record).forEach(([key, value]) => {
normalizedRecord[key.toLowerCase()] = value;
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
if (!actualKey) {
// This should never happen since we create missing keys above
throw new ValidationError(`Attribute key "${key}" not found in attribute key map`);
}
mappedRecord[actualKey] = value;
});
// Skip records without email
if (!normalizedRecord.email) {
if (!mappedRecord.email) {
throw new ValidationError("Email is required for all contacts");
}
const existingContact = emailToContactMap.get(normalizedRecord.email);
const existingContact = emailToContactMap.get(mappedRecord.email);
if (existingContact) {
// Handle duplicates based on duplicateContactsAction
@@ -348,25 +412,7 @@ export const createContactsFromCSV = async (
return null;
case "update": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
recordToProcess = {
...rest,
...(existingContactUserId && {
userId: existingContactUserId,
}),
};
}
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
where: {
@@ -383,7 +429,7 @@ export const createContactsFromCSV = async (
}));
// Update contact with upserted attributes
const updatedContact = prisma.contact.update({
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
@@ -391,98 +437,40 @@ export const createContactsFromCSV = async (
upsert: attributesToUpsert,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
include: contactAttributesInclude,
});
return updatedContact;
}
case "overwrite": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
recordToProcess = {
...rest,
...(existingContactUserId && {
userId: existingContactUserId,
}),
};
}
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
// Overwrite by deleting existing attributes and creating new ones
await prisma.contactAttribute.deleteMany({
where: { contactId: existingContact.id },
});
const newAttributes = Object.entries(recordToProcess).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
const updatedContact = prisma.contact.update({
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
create: newAttributes,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
create: createAttributeConnections(recordToProcess, environmentId),
},
},
include: contactAttributesInclude,
});
return updatedContact;
}
}
} else {
// Create new contact
const newAttributes = Object.entries(record).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
const newContact = prisma.contact.create({
// Create new contact - use mappedRecord with proper DB key casing
return prisma.contact.create({
data: {
environmentId,
attributes: {
create: newAttributes,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
create: createAttributeConnections(mappedRecord, environmentId),
},
},
include: contactAttributesInclude,
});
return newContact;
}
});

View File

@@ -1,6 +1,7 @@
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,
@@ -55,6 +56,7 @@ vi.mock("@formbricks/database", () => ({
},
organization: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
}));
@@ -70,6 +72,11 @@ 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();
@@ -102,6 +109,15 @@ 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);

View File

@@ -7,8 +7,10 @@ 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,
@@ -260,14 +262,23 @@ 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 responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
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,
},
},
},
});
}),
]);
// 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;
@@ -275,11 +286,17 @@ 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({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
}),
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,

View File

@@ -48,29 +48,33 @@ export function LanguageIndicator({
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"
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"
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"
onClick={toggleDropdown}
tabIndex={-1}
type="button">
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
<ChevronDown className="ml-1 h-4 w-4" />
<span className="max-w-full truncate">
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
</span>
<ChevronDown className="ml-1 h-4 w-4 flex-shrink-0" />
</button>
{showLanguageDropdown ? (
<div
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"
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"
ref={languageDropdownRef}>
{surveyLanguages.map(
(language) =>
language.language.code !== languageToBeDisplayed?.language.code &&
language.enabled && (
<button
className="block w-full rounded-sm p-1 text-left hover:bg-slate-700"
className="flex w-full rounded-sm p-1 text-left hover:bg-slate-700"
key={language.language.id}
onClick={() => {
changeLanguage(language);
}}
type="button">
{getLanguageLabel(language.language.code, locale)}
<span className="min-w-0 flex-1 truncate">
{getLanguageLabel(language.language.code, locale)}
</span>
</button>
)
)}

View File

@@ -60,7 +60,7 @@ export function AddMemberRole({
name="role"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Label>{t("environments.settings.teams.organization_role")}</Label>
<Select
defaultValue={isAccessControlAllowed ? "member" : "owner"}
disabled={!isAccessControlAllowed}

View File

@@ -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 } from "./roles";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId, getTeamsWhereUserIsAdmin } from "./roles";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: { findMany: vi.fn() },
teamUser: { findUnique: vi.fn() },
teamUser: { findUnique: vi.fn(), findMany: vi.fn() },
},
}));
@@ -19,6 +19,7 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockUserId = "user-1";
const mockProjectId = "project-1";
const mockTeamId = "team-1";
const mockOrganizationId = "org-1";
describe("roles lib", () => {
beforeEach(() => {
@@ -90,7 +91,7 @@ describe("roles lib", () => {
});
test("returns role if teamUser exists", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" } as unknown as any);
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBe("member");
});
@@ -110,4 +111,47 @@ describe("roles lib", () => {
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
});
});
describe("getTeamsWhereUserIsAdmin", () => {
test("returns empty array if user is not admin of any team", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([]);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual([]);
expect(validateInputs).toHaveBeenCalledWith(
[mockUserId, expect.anything()],
[mockOrganizationId, expect.anything()]
);
});
test("returns array of team IDs where user is admin", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([
{ teamId: "team-1" },
{ teamId: "team-2" },
{ teamId: "team-3" },
] as unknown as any);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual(["team-1", "team-2", "team-3"]);
});
test("returns single team ID when user is admin of one team", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([{ teamId: "team-1" }] as unknown as any);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual(["team-1"]);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(DatabaseError);
});
test("throws error on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(error);
});
});
});

View File

@@ -83,3 +83,31 @@ 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;
}
}
);

View File

@@ -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} showCopyIconOnHover={true} />
<IdBadge id={team.id} />
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>

View File

@@ -9,10 +9,9 @@ import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
interface AccessViewProps {
teams: TProjectTeam[];
environmentId: string;
isOwnerOrManager: boolean;
}
export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessViewProps) => {
export const AccessView = ({ teams, environmentId }: AccessViewProps) => {
const { t } = useTranslation();
return (
<>
@@ -20,7 +19,7 @@ export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessVie
title={t("common.team_access")}
description={t("environments.project.teams.team_settings_description")}>
<div className="mb-4 flex justify-end">
<ManageTeam environmentId={environmentId} isOwnerOrManager={isOwnerOrManager} />
<ManageTeam environmentId={environmentId} />
</div>
<AccessTable teams={teams} />
</SettingsCard>

View File

@@ -3,14 +3,12 @@
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface ManageTeamProps {
environmentId: string;
isOwnerOrManager: boolean;
}
export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps) => {
export const ManageTeam = ({ environmentId }: ManageTeamProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -19,20 +17,9 @@ export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps)
router.push(`/environments/${environmentId}/settings/teams`);
};
if (isOwnerOrManager) {
return (
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
{t("environments.project.teams.manage_teams")}
</Button>
);
}
return (
<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>
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
{t("environments.project.teams.manage_teams")}
</Button>
);
};

View File

@@ -10,7 +10,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
const t = await getTranslate();
const params = await props.params;
const { project, isOwner, isManager } = await getEnvironmentAuth(params.environmentId);
const { project } = await getEnvironmentAuth(params.environmentId);
const teams = await getTeamsByProjectId(project.id);
@@ -18,14 +18,12 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
throw new Error(t("common.teams_not_found"));
}
const isOwnerOrManager = isOwner || isManager;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="teams" />
</PageHeader>
<AccessView environmentId={params.environmentId} teams={teams} isOwnerOrManager={isOwnerOrManager} />
<AccessView environmentId={params.environmentId} teams={teams} />
</PageContentWrapper>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -80,6 +80,16 @@ export const TeamSettingsModal = ({
const router = useRouter();
// Track initial member IDs to distinguish existing members from newly added ones
const initialMemberIds = useMemo(() => {
return new Set(team.members.map((member) => member.userId));
}, [team.members]);
// Track initial project IDs to distinguish existing projects from newly added ones
const initialProjectIds = useMemo(() => {
return new Set(team.projects.map((project) => project.projectId));
}, [team.projects]);
const initialMembers = useMemo(() => {
const members = team.members.map((member) => ({
userId: member.userId,
@@ -259,34 +269,44 @@ export const TeamSettingsModal = ({
<FormField
control={control}
name={`members.${index}.userId`}
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>
)}
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>
);
}}
/>
<FormField
@@ -328,18 +348,20 @@ export const TeamSettingsModal = ({
{/* Delete Button for Member */}
{watchMembers.length > 1 && (
<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>
<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>
)}
</div>
);
@@ -360,7 +382,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_members_added")
}>
<Button
size="default"
size="sm"
type="button"
variant="secondary"
onClick={handleAddMember}
@@ -396,31 +418,40 @@ export const TeamSettingsModal = ({
<FormField
control={control}
name={`projects.${index}.projectId`}
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>
)}
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>
);
}}
/>
<FormField
@@ -481,7 +512,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_projects_added")
}>
<Button
size="default"
size="sm"
type="button"
variant="secondary"
onClick={handleAddProject}

View File

@@ -54,15 +54,12 @@ export const renderEmailResponseValue = async (
<Container>
<Row className="mb-2 text-sm text-slate-700" dir="auto">
{Array.isArray(response) &&
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>
)
)}
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>
))}
</Row>
</Container>
);

View File

@@ -52,9 +52,17 @@ export async function ResponseFinishedEmail({
</Row>
);
})}
{survey.variables.map((variable) => {
const variableResponse = response.variables[variable.id];
if (variableResponse && ["number", "string"].includes(typeof variable)) {
{survey.variables
.filter((variable) => {
const variableResponse = response.variables[variable.id];
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
const variableResponse = response.variables[variable.id];
return (
<Row key={variable.id}>
<Column className="w-full text-sm font-medium">
@@ -72,12 +80,14 @@ export async function ResponseFinishedEmail({
</Column>
</Row>
);
}
return null;
})}
{survey.hiddenFields.fieldIds?.map((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
if (hiddenFieldResponse && typeof hiddenFieldResponse === "string") {
})}
{survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId] as string;
return (
<Row key={hiddenFieldId}>
<Column className="w-full font-medium">
@@ -90,9 +100,7 @@ export async function ResponseFinishedEmail({
</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={

View File

@@ -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 { ZOrganizationRole } from "@formbricks/types/memberships";
import { TOrganizationRole, 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,6 +16,7 @@ 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,
@@ -195,19 +196,55 @@ 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(),
name: z.string().trim().min(1, "Name is required"),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
teamIds: z.array(ZId),
});
export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserAction).action(
withAuditLogging(
"created",
"invite",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZInviteUserAction>;
}) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
@@ -224,16 +261,41 @@ export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserActi
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const isOrgOwnerOrManager =
currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
// Fetch user's admin teams (empty array if owner/manager to skip unnecessary query)
const userAdminTeams = isOrgOwnerOrManager
? []
: await getTeamsWhereUserIsAdmin(ctx.user.id, parsedInput.organizationId);
const isTeamAdmin = userAdminTeams.length > 0;
if (!isOrgOwnerOrManager && !isTeamAdmin) {
throw new AuthenticationError("Not authorized to invite members");
}
if (isOrgOwnerOrManager) {
// Standard org-level auth check
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
}
// Validate team admin restrictions
validateTeamAdminInvitePermissions(
currentUserMembership.role,
userAdminTeams,
parsedInput.role,
parsedInput.teamIds
);
if (currentUserMembership.role === "manager" && parsedInput.role !== "member") {
throw new OperationNotAllowedError("Managers can only invite users as members");

View File

@@ -37,6 +37,8 @@ interface OrganizationActionsProps {
isMultiOrgEnabled: boolean;
isUserManagementDisabledFromUi: boolean;
isStorageConfigured: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
}
export const OrganizationActions = ({
@@ -52,16 +54,20 @@ export const OrganizationActions = ({
isMultiOrgEnabled,
isUserManagementDisabledFromUi,
isStorageConfigured,
isTeamAdmin,
userAdminTeamIds,
}: OrganizationActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isLeaveOrganizationModalOpen, setLeaveOrganizationModalOpen] = useState(false);
const [isInviteMemberModalOpen, setInviteMemberModalOpen] = useState(false);
const [isLeaveOrganizationModalOpen, setIsLeaveOrganizationModalOpen] = useState(false);
const [isInviteMemberModalOpen, setIsInviteMemberModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const canInvite = isOwnerOrManager || (isAccessControlAllowed && isTeamAdmin);
const handleLeaveOrganization = async () => {
setLoading(true);
try {
@@ -134,18 +140,18 @@ export const OrganizationActions = ({
<>
<div className="mb-4 flex justify-end space-x-2 text-right">
{role !== "owner" && isMultiOrgEnabled && (
<Button variant="secondary" size="sm" onClick={() => setLeaveOrganizationModalOpen(true)}>
<Button variant="destructive" size="sm" onClick={() => setIsLeaveOrganizationModalOpen(true)}>
{t("environments.settings.general.leave_organization")}
<XIcon />
</Button>
)}
{!isInviteDisabled && isOwnerOrManager && !isUserManagementDisabledFromUi && (
{!isInviteDisabled && canInvite && !isUserManagementDisabledFromUi && (
<Button
size="sm"
variant="secondary"
variant="default"
onClick={() => {
setInviteMemberModalOpen(true);
setIsInviteMemberModalOpen(true);
}}>
{t("environments.settings.teams.invite_member")}
</Button>
@@ -153,7 +159,7 @@ export const OrganizationActions = ({
</div>
<InviteMemberModal
open={isInviteMemberModalOpen}
setOpen={setInviteMemberModalOpen}
setOpen={setIsInviteMemberModalOpen}
onSubmit={handleAddMembers}
membershipRole={membershipRole}
isAccessControlAllowed={isAccessControlAllowed}
@@ -161,9 +167,12 @@ export const OrganizationActions = ({
environmentId={environmentId}
teams={teams}
isStorageConfigured={isStorageConfigured}
isOwnerOrManager={isOwnerOrManager}
isTeamAdmin={isTeamAdmin}
userAdminTeamIds={userAdminTeamIds}
/>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setLeaveOrganizationModalOpen}>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setIsLeaveOrganizationModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.settings.general.leave_organization_title")}</DialogTitle>
@@ -177,7 +186,7 @@ export const OrganizationActions = ({
</p>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setLeaveOrganizationModalOpen(false)}>
<Button variant="secondary" onClick={() => setIsLeaveOrganizationModalOpen(false)}>
{t("common.cancel")}
</Button>
<Button

View File

@@ -7,13 +7,14 @@ 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 { FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { FormError, 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";
@@ -27,6 +28,7 @@ interface IndividualInviteTabProps {
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
showTeamAdminRestrictions: boolean;
}
export const IndividualInviteTab = ({
@@ -37,22 +39,32 @@ 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: z.array(z.string()),
teamIds: showTeamAdminRestrictions
? z.array(ZId).min(1, { message: "Team admins must select at least one team" })
: z.array(ZId),
});
const router = useRouter();
type TFormData = z.infer<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: isAccessControlAllowed ? "member" : "owner",
role: defaultRole,
teamIds: [],
},
});
@@ -104,43 +116,61 @@ export const IndividualInviteTab = ({
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>}
</div>
<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>
{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>
)}
</>
)}
</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>
</FormItem>
)}
/>
<>
<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>
</>
)}
{!isAccessControlAllowed && (

View File

@@ -26,6 +26,9 @@ interface InviteMemberModalProps {
environmentId: string;
membershipRole?: TOrganizationRole;
isStorageConfigured: boolean;
isOwnerOrManager: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
}
export const InviteMemberModal = ({
@@ -38,11 +41,21 @@ export const InviteMemberModal = ({
environmentId,
membershipRole,
isStorageConfigured,
isOwnerOrManager,
isTeamAdmin,
userAdminTeamIds,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
const { t } = useTranslation();
const showTeamAdminRestrictions = !isOwnerOrManager && isTeamAdmin;
const filteredTeams =
showTeamAdminRestrictions && userAdminTeamIds
? teams.filter((t) => userAdminTeamIds.includes(t.id))
: teams;
const tabs = {
individual: (
<IndividualInviteTab
@@ -51,8 +64,9 @@ export const InviteMemberModal = ({
onSubmit={onSubmit}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
teams={teams}
teams={filteredTeams}
membershipRole={membershipRole}
showTeamAdminRestrictions={showTeamAdminRestrictions}
/>
),
bulk: (
@@ -75,16 +89,18 @@ export const InviteMemberModal = ({
</DialogHeader>
<DialogBody className="flex flex-col gap-6" unconstrained>
<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]}
{!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]}
</DialogBody>
</DialogContent>
</Dialog>

View File

@@ -5,6 +5,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { EditMemberships } from "@/modules/organization/settings/teams/components/edit-memberships";
@@ -45,6 +46,10 @@ export const MembersView = async ({
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
// Fetch admin teams if they're a team admin
const userAdminTeamIds = await getTeamsWhereUserIsAdmin(currentUserId, organization.id);
const isTeamAdminUser = userAdminTeamIds.length > 0;
let teams: TOrganizationTeam[] = [];
if (isAccessControlAllowed) {
@@ -69,6 +74,8 @@ export const MembersView = async ({
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
isTeamAdmin={isTeamAdminUser}
userAdminTeamIds={userAdminTeamIds}
/>
)}

View File

@@ -3,6 +3,7 @@ import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constan
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
@@ -16,11 +17,21 @@ export const TeamsPage = async (props) => {
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const hasUserManagementAccess = getUserManagementAccess(
// Check if user has standard user management access (owner/manager)
const hasStandardUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
// Also check if user is a team admin (they get limited user management for invites)
const userAdminTeamIds = await getTeamsWhereUserIsAdmin(session.user.id, organization.id);
const isTeamAdminUser = userAdminTeamIds.length > 0;
// Allow user management UI if they're owner/manager OR team admin (when access control is enabled)
const hasUserManagementAccess =
hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>

View File

@@ -154,7 +154,6 @@ export const ThemeStyling = ({
open={cardStylingOpen}
setOpen={setCardStylingOpen}
isSettingsPage
project={project}
surveyType={previewSurveyType}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>

View File

@@ -20,7 +20,8 @@ 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 } from "@/modules/survey/editor/lib/survey";
import { updateSurvey, updateSurveyDraft } from "@/modules/survey/editor/lib/survey";
import { TSurveyDraft, ZSurveyDraft } from "@/modules/survey/editor/types/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";
@@ -46,6 +47,62 @@ 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",

View File

@@ -294,28 +294,28 @@ export const BlockCard = ({
open={!isBlockCollapsed}
onOpenChange={() => setIsBlockCollapsed(!isBlockCollapsed)}
className={cn(isBlockCollapsed ? "h-full" : "")}>
<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>
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
<p className="text-xs text-slate-500">
{blockElementsCount} {blockElementsCountText}
</p>
<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>
<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>
<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>

View File

@@ -0,0 +1,195 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { type JSX, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString } from "@/lib/i18n/utils";
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface BulkEditOptionsModalProps {
isOpen: boolean;
onClose: () => void;
regularChoices: TSurveyMultipleChoiceElement["choices"];
onSave: (updatedChoices: TSurveyMultipleChoiceElement["choices"]) => void;
element: TSurveyMultipleChoiceElement;
localSurvey: TSurvey;
selectedLanguageCode: string;
surveyLanguageCodes: string[];
locale: TUserLocale;
}
const parseUniqueLines = (content: string): string[] => {
return [
...new Set(
content
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
),
];
};
const updateChoiceLabel = (
choice: TSurveyMultipleChoiceElement["choices"][number],
newLabel: string,
selectedLangCode: string,
allLangCodes: string[]
): TSurveyMultipleChoiceElement["choices"][number] => {
const label = Object.fromEntries([
...allLangCodes.map((code) => [code, choice.label[code] ?? ""]),
[selectedLangCode, newLabel],
]) as TI18nString;
return { ...choice, label };
};
export const BulkEditOptionsModal = ({
isOpen,
onClose,
regularChoices,
onSave,
element,
localSurvey,
selectedLanguageCode,
surveyLanguageCodes,
locale,
}: BulkEditOptionsModalProps): JSX.Element => {
const { t } = useTranslation();
const [textareaValue, setTextareaValue] = useState("");
const [validationError, setValidationError] = useState<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>
);
};

View File

@@ -0,0 +1,262 @@
"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>
);
};

View File

@@ -8,12 +8,14 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -49,6 +51,7 @@ export const MultipleChoiceElementForm = ({
const lastChoiceRef = useRef<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);
@@ -90,11 +93,31 @@ export const MultipleChoiceElementForm = ({
[element.choices]
);
// Get the display name for the selected language (for multi-language surveys)
const bulkEditButtonLabel = useMemo(() => {
if (localSurvey.languages.length <= 1) {
return t("environments.surveys.edit.bulk_edit");
}
const languageCode =
selectedLanguageCode === "default"
? localSurvey.languages.find((lang) => lang.default)?.language.code
: selectedLanguageCode;
const languageName = languageCode ? getLanguageLabel(languageCode, locale) : "";
return `${t("environments.surveys.edit.bulk_edit")} (${languageName})`;
}, [localSurvey.languages, selectedLanguageCode, locale, t]);
const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceElement["choices"]) => {
const regularChoicesFromInput = choices.filter((c) => c.id !== "other" && c.id !== "none");
const otherChoice = choices.find((c) => c.id === "other");
const noneChoice = choices.find((c) => c.id === "none");
// [regularChoices, otherChoice, noneChoice]
return [...regularChoices, ...(otherChoice ? [otherChoice] : []), ...(noneChoice ? [noneChoice] : [])];
return [
...regularChoicesFromInput,
...(otherChoice ? [otherChoice] : []),
...(noneChoice ? [noneChoice] : []),
];
};
const addChoice = (choiceIdx?: number) => {
@@ -283,7 +306,7 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, { choices: newChoices });
}}>
<SortableContext items={element.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
<div className="flex max-h-[25dvh] flex-col gap-2 overflow-y-auto py-1 pr-1" ref={parent}>
{element.choices?.map((choice, choiceIdx) => (
<ElementOptionChoice
key={choice.id}
@@ -308,6 +331,9 @@ 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) => {
@@ -323,6 +349,9 @@ export const MultipleChoiceElementForm = ({
</Button>
);
})}
<Button size="sm" variant="secondary" type="button" onClick={() => setIsBulkEditOpen(true)}>
{bulkEditButtonLabel}
</Button>
</div>
<Button
size="sm"
@@ -352,6 +381,23 @@ 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>
);
};

View File

@@ -11,6 +11,7 @@ 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";
@@ -64,6 +65,7 @@ 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);
@@ -88,6 +90,7 @@ export const StylingView = ({
useEffect(() => {
if (!overwriteThemeStyling) {
setFormStylingOpen(false);
setLogoSettingsOpen(false);
setCardStylingOpen(false);
setStylingOpen(false);
}
@@ -198,21 +201,31 @@ 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}
/>
<>
<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}
/>
</>
)}
{!isCxMode && (

View File

@@ -19,11 +19,12 @@ import {
} from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { TSurveyDraft } from "@/modules/survey/editor/types/survey";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { updateSurveyAction } from "../actions";
import { updateSurveyAction, updateSurveyDraftAction } from "../actions";
import { isSurveyValid } from "../lib/validation";
interface SurveyMenuBarProps {
@@ -227,6 +228,38 @@ export const SurveyMenuBar = ({
return true;
};
// Add new handler after handleSurveySave
const handleSurveySaveDraft = async (): Promise<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);
@@ -398,12 +431,11 @@ export const SurveyMenuBar = ({
variant="secondary"
size="sm"
loading={isSurveySaving}
onClick={() => handleSurveySave()}
onClick={() => (localSurvey.status === "draft" ? handleSurveySaveDraft() : handleSurveySave())}
type="submit">
{t("common.save")}
{localSurvey.status === "draft" ? t("common.save_as_draft") : t("common.save")}
</Button>
)}
{localSurvey.status !== "draft" && (
<Button
disabled={disableSave}

View File

@@ -4,10 +4,11 @@ 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 } from "./survey";
import { checkTriggersValidity, handleTriggerUpdates, updateSurvey, updateSurveyDraft } from "./survey";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
@@ -26,6 +27,10 @@ vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
updateSurveyInternal: vi.fn(),
}));
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
@@ -692,4 +697,89 @@ describe("Survey Editor Library Tests", () => {
).toThrow(InvalidInputError);
});
});
describe("updateSurveyDraft", () => {
const mockSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Draft Survey",
type: "app",
environmentId: "env123",
createdBy: "user123",
status: "draft",
displayOption: "displayOnce",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
welcomeCard: {
enabled: false,
timeToFinish: true,
showResponseCount: false,
},
triggers: [],
endings: [],
hiddenFields: { enabled: false },
delay: 0,
autoComplete: null,
projectOverwrites: null,
styling: null,
showLanguageSwitch: false,
segment: null,
surveyClosedMessage: null,
singleUse: null,
isVerifyEmailEnabled: false,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
pin: null,
displayPercentage: null,
languages: [],
variables: [],
followUps: [],
} as unknown as TSurvey;
beforeEach(() => {
vi.mocked(updateSurveyInternal).mockResolvedValue(mockSurvey);
});
test("should call updateSurveyInternal with skipValidation=true", async () => {
await updateSurveyDraft(mockSurvey);
expect(updateSurveyInternal).toHaveBeenCalledWith(mockSurvey, true);
expect(updateSurveyInternal).toHaveBeenCalledTimes(1);
});
test("should return the survey from updateSurveyInternal", async () => {
const result = await updateSurveyDraft(mockSurvey);
expect(result).toEqual(mockSurvey);
});
test("should propagate errors from updateSurveyInternal", async () => {
const error = new Error("Internal update failed");
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(error);
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow("Internal update failed");
});
test("should propagate ResourceNotFoundError from updateSurveyInternal", async () => {
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey123"));
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
});
test("should propagate DatabaseError from updateSurveyInternal", async () => {
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new DatabaseError("Database connection failed"));
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -4,12 +4,18 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { updateSurveyInternal } from "@/lib/survey/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise<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;

View File

@@ -9,6 +9,8 @@ export const ZCreateSurveyFollowUpFormSchema = z.object({
subject: z.string().trim().min(1, "Subject is required"),
body: z.string().trim().min(1, "Body is required"),
attachResponseData: z.boolean(),
includeVariables: z.boolean(),
includeHiddenFields: z.boolean(),
});
export type TCreateSurveyFollowUpForm = z.infer<typeof ZCreateSurveyFollowUpFormSchema>;

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZSurveyType } from "@formbricks/types/surveys/types";
/**
* Lenient schema for draft survey updates.
* Validates essential fields for security/functionality but allows incomplete survey data.
* Full validation (ZSurvey) is enforced when publishing.
*/
export const ZSurveyDraft = z
.object({
// Essential fields - strictly validated
id: ZId,
status: z.literal("draft"),
environmentId: ZId,
type: ZSurveyType,
name: z.string().min(1, "Survey name is required"),
// Required fields for database operations - loosely validated
blocks: z.array(z.record(z.unknown())).optional(),
triggers: z.array(z.record(z.unknown())).optional(),
endings: z.array(z.record(z.unknown())).optional(),
segment: z.record(z.unknown()).nullable().optional(),
})
.passthrough(); // Allow all other fields without validation
export type TSurveyDraft = z.infer<typeof ZSurveyDraft>;

View File

@@ -1,34 +1,21 @@
import {
Body,
Column,
Container,
Hr,
Html,
Img,
Link,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Column, Hr, Row, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "@/modules/email/components/email-template";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
const fbLogoUrl = FB_LOGO_URL;
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface FollowUpEmailProps {
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
readonly includeVariables: boolean;
readonly includeHiddenFields: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
@@ -42,91 +29,92 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<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 (
<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)
}),
}}
/>
<EmailTemplate logoUrl={props.logoUrl} t={t}>
<>
<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 /> : null}
{elements.length > 0 ? (
<>
<Hr />
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
</>
) : null}
{elements.map((e) => {
if (!e.response) return;
{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];
return (
<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)}
<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 whitespace-pre-wrap break-words text-sm text-slate-700">
{variableResponse}
</Text>
</Column>
</Row>
);
})}
</Container>
{/* 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>
{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 whitespace-pre-wrap break-words text-sm text-slate-700">
{hiddenFieldResponse}
</Text>
</Column>
</Row>
);
})}
</>
</EmailTemplate>
);
}

View File

@@ -46,6 +46,11 @@ 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);
@@ -196,6 +201,8 @@ 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}

View File

@@ -31,6 +31,7 @@ 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";
@@ -78,7 +79,7 @@ interface AddFollowUpModalProps {
}
type EmailSendToOption = {
type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user";
type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user" | "verifiedEmail";
label: string;
id: string;
};
@@ -140,7 +141,18 @@ 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]
@@ -164,7 +176,7 @@ export const FollowUpModal = ({
type: "user" as EmailSendToOption["type"],
})),
] satisfies EmailSendToOption[];
}, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail]);
}, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail, t]);
const form = useForm<TCreateSurveyFollowUpForm>({
defaultValues: {
@@ -259,6 +271,8 @@ export const FollowUpModal = ({
subject: data.subject,
body: sanitizedBody,
attachResponseData: data.attachResponseData,
includeVariables: data.includeVariables,
includeHiddenFields: data.includeHiddenFields,
},
},
};
@@ -306,6 +320,8 @@ export const FollowUpModal = ({
subject: data.subject,
body: sanitizedBody,
attachResponseData: data.attachResponseData,
includeVariables: data.includeVariables,
includeHiddenFields: data.includeHiddenFields,
},
},
};
@@ -361,6 +377,8 @@ 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]);
@@ -372,33 +390,50 @@ 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}>
{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>
)}
<div className="flex items-center space-x-2">
{icon}
<span className={textClass}>{option.label}</span>
</div>
</SelectItem>
);
};
@@ -648,7 +683,8 @@ export const FollowUpModal = ({
</SelectTrigger>
<SelectContent>
{emailSendToElementOptions.length > 0 ? (
{emailSendToVerifiedEmailOptions.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">
@@ -656,6 +692,10 @@ export const FollowUpModal = ({
</p>
</div>
{emailSendToVerifiedEmailOptions.map((option) =>
renderSelectItem(option)
)}
{emailSendToElementOptions.map((option) =>
renderSelectItem(option)
)}
@@ -832,27 +872,60 @@ export const FollowUpModal = ({
render={({ field }) => {
return (
<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)}
/>
<FormLabel htmlFor="attachResponseData" className="font-medium">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
<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>
)}
</FormLabel>
</div>
/>
<FormDescription className="text-sm text-slate-500">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
)}
</FormDescription>
</div>
<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>
)}
/>
</div>
</AdvancedOptionToggle>
</FormItem>
);
}}

View File

@@ -12,12 +12,16 @@ 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;
@@ -33,6 +37,8 @@ export const sendFollowUpEmail = async ({
followUp,
logoUrl,
attachResponseData,
includeVariables,
includeHiddenFields,
survey,
response,
})

View File

@@ -40,6 +40,8 @@ const evaluateFollowUp = async (
survey,
response,
attachResponseData: properties.attachResponseData,
includeVariables: properties.includeVariables,
includeHiddenFields: properties.includeHiddenFields,
logoUrl,
});
@@ -71,6 +73,8 @@ const evaluateFollowUp = async (
survey,
response,
attachResponseData: properties.attachResponseData,
includeVariables: properties.includeVariables,
includeHiddenFields: properties.includeHiddenFields,
});
return {
@@ -104,6 +108,8 @@ const evaluateFollowUp = async (
survey,
response,
attachResponseData: properties.attachResponseData,
includeVariables: properties.includeVariables,
includeHiddenFields: properties.includeHiddenFields,
});
return {

View File

@@ -79,7 +79,9 @@ 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 && <ClientLogo projectLogo={project.logo} />}
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
<ClientLogo projectLogo={project.logo} surveyLogo={styling.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">

View File

@@ -1,7 +1,6 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import React from "react";
@@ -11,7 +10,6 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { Badge } from "@/modules/ui/components/badge";
import { CardArrangementTabs } from "@/modules/ui/components/card-arrangement-tabs";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
@@ -24,7 +22,6 @@ type CardStylingSettingsProps = {
isSettingsPage?: boolean;
surveyType?: TSurveyType;
disabled?: boolean;
project: Project;
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
};
@@ -33,14 +30,12 @@ export const CardStylingSettings = ({
surveyType,
disabled,
open,
project,
setOpen,
form,
}: CardStylingSettingsProps) => {
const { t } = useTranslation();
const isAppSurvey = surveyType === "app";
const surveyTypeDerived = isAppSurvey ? "App" : "Link";
const isLogoVisible = !!project.logo?.url;
const linkCardArrangement = form.watch("cardArrangement.linkSurveys") ?? "straight";
const appCardArrangement = form.watch("cardArrangement.appSurveys") ?? "straight";
@@ -222,35 +217,6 @@ export const CardStylingSettings = ({
/>
</div>
{isLogoVisible && (!surveyType || surveyType === "link") && !isSettingsPage && (
<div className="flex items-center space-x-1">
<FormField
control={form.control}
name="isLogoHidden"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
id="isLogoHidden"
checked={!!field.value}
onCheckedChange={(checked) => field.onChange(checked)}
/>
</FormControl>
<div>
<FormLabel>
{t("environments.surveys.edit.hide_logo")}
<Badge type="gray" size="normal" text={t("common.link_surveys")} />
</FormLabel>
<FormDescription>
{t("environments.surveys.edit.hide_the_logo_in_this_specific_survey")}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
)}
{(!surveyType || isAppSurvey) && (
<div className="flex max-w-xs flex-col gap-4">
<div className="flex items-center space-x-1">

View File

@@ -5,20 +5,29 @@ import { ArrowUpRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TLogo } from "@formbricks/types/styling";
import { cn } from "@/lib/cn";
interface ClientLogoProps {
environmentId?: string;
projectLogo: Project["logo"] | null;
surveyLogo?: TLogo | null;
previewSurvey?: boolean;
}
export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }: ClientLogoProps) => {
export const ClientLogo = ({
environmentId,
projectLogo,
surveyLogo,
previewSurvey = false,
}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
return (
<div
className={cn(previewSurvey ? "" : "left-3 top-3 md:left-7 md:top-7", "group absolute z-0 rounded-lg")}
style={{ backgroundColor: projectLogo?.bgColor }}>
style={{ backgroundColor: logoToUse?.bgColor }}>
{previewSurvey && environmentId && (
<Link
href={`/environments/${environmentId}/project/look`}
@@ -30,9 +39,9 @@ export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }
/>
</Link>
)}
{projectLogo?.url ? (
{logoToUse?.url ? (
<Image
src={projectLogo?.url}
src={logoToUse?.url}
className={cn(
previewSurvey ? "max-h-12" : "max-h-16 md:max-h-20",
"w-auto max-w-40 object-contain p-1 md:max-w-56"

View File

@@ -117,7 +117,7 @@ export const ConfirmationModal = ({
<CircleAlert className="h-4 w-4 text-slate-500" />
)}
<div className="flex flex-col">
<DialogTitle className="w-full text-left">{title}</DialogTitle>
<DialogTitle className="w-full truncate text-left">{title}</DialogTitle>
<DialogDescription className="w-full text-left">
<span className="mt-2 whitespace-pre-wrap">
{description ?? t("environments.project.general.this_action_cannot_be_undone")}

View File

@@ -263,7 +263,12 @@ export const PreviewSurvey = ({
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
<ClientLogo
environmentId={environment.id}
projectLogo={project.logo}
surveyLogo={styling.logo}
previewSurvey
/>
)}
</div>
<div className="z-10 w-full rounded-lg border border-transparent">
@@ -363,7 +368,12 @@ export const PreviewSurvey = ({
isEditorView>
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
<ClientLogo
environmentId={environment.id}
projectLogo={project.logo}
surveyLogo={styling.logo}
previewSurvey
/>
)}
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>

View File

@@ -15,7 +15,8 @@
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
"generate-api-specs": "./scripts/openapi/generate.sh",
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints"
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints",
"i18n:generate": "npx lingo.dev@latest i18n"
},
"dependencies": {
"@aws-sdk/client-s3": "3.879.0",
@@ -105,7 +106,7 @@
"next-auth": "4.24.12",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",
"nodemailer": "7.0.9",
"nodemailer": "7.0.11",
"otplib": "12.0.1",
"papaparse": "5.5.2",
"prismjs": "1.30.0",

View File

@@ -20,7 +20,7 @@ test.describe("Invite, accept and remove organization member", async () => {
await page.locator('[data-testid="members-loading-card"]:first-child').waitFor({ state: "hidden" });
await page.getByRole("link", { name: "Access Control" }).click();
await page.getByRole("link", { name: "Members & Teams" }).click();
// Add member button
await expect(page.getByRole("button", { name: "Invite member" })).toBeVisible();
@@ -131,8 +131,8 @@ test.describe("Create, update and delete team", async () => {
await page.waitForURL(/\/environments\/[^/]+\/settings\/general/);
await page.waitForTimeout(2000);
await expect(page.getByText("Access Control")).toBeVisible();
await page.getByText("Access Control").click();
await expect(page.getByText("Members & Teams")).toBeVisible();
await page.getByText("Members & Teams").click();
await page.waitForURL(/\/environments\/[^/]+\/settings\/teams/);
await expect(page.getByRole("button", { name: "Create new team" })).toBeVisible();
await page.getByRole("button", { name: "Create new team" }).click();

View File

@@ -62,7 +62,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder)).toBeVisible();
await page
.getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder)
.fill("This is my Open Text answer");
.fill("Open Text answer");
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
// Single Select Question
@@ -116,7 +116,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await page.locator("path").nth(3).click();
await page.getByRole("radio", { name: "Rate 3 out of" }).check();
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -212,11 +212,9 @@ test.describe("Survey Create & Submit Response without logic", async () => {
// Address Question
await expect(page.getByText(surveys.createAndSubmit.address.question)).toBeVisible();
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)).toBeVisible();
await page
.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)
.fill("This is my Address");
await page.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1).fill("Address");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.city)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.city).fill("This is my city");
await page.getByLabel(surveys.createAndSubmit.address.placeholder.city).fill("city");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.zip).fill("12345");
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
@@ -785,7 +783,7 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await page
.getByPlaceholder(surveys.createWithLogicAndSubmit.openTextQuestion.placeholder)
.fill("This is my Open Text answer");
.fill("Open Text answer");
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
// Single Select Question
@@ -858,10 +856,9 @@ test.describe("Testing Survey with advanced logic", async () => {
await expect(
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
await page.getByRole("radio", { name: "Rate 4 out of" }).check();
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -972,11 +969,9 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await page
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.addressLine1)
.fill("This is my Address");
.fill("Address");
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)).toBeVisible();
await page
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)
.fill("This is my city");
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city).fill("city");
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip).fill("12345");
await page.locator("#questionCard-13").getByRole("button", { name: "Finish" }).click();
@@ -997,13 +992,26 @@ test.describe("Testing Survey with advanced logic", async () => {
const updatedUrl = currentUrl.replace("summary?share=true", "responses");
await page.goto(updatedUrl);
await page.waitForSelector("#response-table");
await page.waitForSelector("table#response-table");
await expect(page.getByRole("cell", { name: "score" })).toBeVisible();
await page.waitForLoadState("networkidle");
await page.waitForTimeout(5000);
await expect(page.getByRole("cell", { name: "32", exact: true })).toBeVisible();
await page.pause();
// Look for any cell containing "32" or a score-related value
const scoreCell = page.getByRole("cell").filter({ hasText: /^32/ });
await expect(scoreCell).toBeVisible({
timeout: 15000,
});
// Look for the secret message in the table
const secretCell = page.getByRole("cell").filter({ hasText: /This is a secret message for e2e tests/ });
await expect(secretCell).toBeVisible({
timeout: 15000,
});
});
});
});

View File

@@ -656,7 +656,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Assign =" }).click();
await page.locator("#action-2-value-input").click();
await page.locator("#action-2-value-input").fill("1");
await page.locator("#action-2-value-input").fill("This ");
// Close Block 1 settings before moving to Block 2
await page
.locator("div")

View File

@@ -106,51 +106,51 @@ export const surveys = {
createAndSubmit: {
welcomeCard: {
headline: "Welcome to My Testing Survey Welcome Card!",
description: "This is the description of my Welcome Card!",
description: "the description of my Welcome Card!",
},
openTextQuestion: {
question: "This is my Open Text Question",
description: "This is my Open Text Description",
placeholder: "This is my Placeholder",
question: "Open Text Question",
description: "Open Text Description",
placeholder: "Placeholder",
},
singleSelectQuestion: {
question: "This is my Single Select Question",
description: "This is my Single Select Description",
question: "Single Select Question",
description: "Single Select Description",
options: ["Option 1", "Option 2"],
},
multiSelectQuestion: {
question: "This is my Multi Select Question",
description: "This is Multi Select Description",
question: "Multi Select Question",
description: "Multi Select Description",
options: ["Option 1", "Option 2", "Option 3"],
},
ratingQuestion: {
question: "This is my Rating Question",
description: "This is Rating Description",
question: "Rating Question",
description: "Rating Description",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
npsQuestion: {
question: "This is my NPS Question",
question: "NPS Question",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
ctaQuestion: {
question: "This is my CTA Question",
question: "CTA Question",
buttonLabel: "My Button Label",
},
consentQuestion: {
question: "This is my Consent Question",
question: "Consent Question",
checkboxLabel: "My Checkbox Label",
},
pictureSelectQuestion: {
question: "This is my Picture Select Question",
description: "This is Picture Select Description",
question: "Picture Select Question",
description: "Picture Select Description",
},
dateQuestion: {
question: "This is my Date Question",
question: "Date Question",
},
fileUploadQuestion: {
question: "This is my File Upload Question",
question: "File Upload Question",
},
matrix: {
question: "How much do you love these flowers?",
@@ -178,57 +178,57 @@ export const surveys = {
createWithLogicAndSubmit: {
welcomeCard: {
headline: "Welcome to My Testing Survey Welcome Card!",
description: "This is the description of my Welcome Card!",
description: "the description of my Welcome Card!",
},
openTextQuestion: {
question: "This is my Open Text Question",
description: "This is my Open Text Description",
placeholder: "This is my Placeholder",
question: "Open Text Question",
description: "Open Text Description",
placeholder: "Placeholder",
},
singleSelectQuestion: {
question: "This is my Single Select Question",
description: "This is my Single Select Description",
question: "Single Select Question",
description: "Single Select Description",
options: ["Option 1", "Option 2"],
},
multiSelectQuestion: {
question: "This is my Multi Select Question",
description: "This is Multi Select Description",
question: "Multi Select Question",
description: "Multi Select Description",
options: ["Option 1", "Option 2", "Option 3"],
},
ratingQuestion: {
question: "This is my Rating Question",
description: "This is Rating Description",
question: "Rating Question",
description: "Rating Description",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
npsQuestion: {
question: "This is my NPS Question",
question: "NPS Question",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
ctaQuestion: {
question: "This is my CTA Question",
question: "CTA Question",
buttonLabel: "My Button Label",
},
consentQuestion: {
question: "This is my Consent Question",
question: "Consent Question",
checkboxLabel: "My Checkbox Label",
},
pictureSelectQuestion: {
question: "This is my Picture Select Question",
description: "This is Picture Select Description",
question: "Picture Select Question",
description: "Picture Select Description",
},
fileUploadQuestion: {
question: "This is my File Upload Question",
question: "File Upload Question",
},
date: {
question: "This is my Date Question",
question: "Date Question",
},
cal: {
question: "This is my cal Question",
question: "cal Question",
},
matrix: {
question: "This is my Matrix Question",
question: "Matrix Question",
description: "0: Not at all, 3: Love it",
rows: ["Roses", "Trees", "Ocean"],
columns: ["0", "1", "2", "3"],
@@ -242,7 +242,7 @@ export const surveys = {
},
},
ranking: {
question: "This is my Ranking Question",
question: "Ranking Question",
choices: ["Work", "Money", "Travel", "Family", "Friends"],
},
endingCard: {
@@ -342,12 +342,12 @@ export const actions = {
noCode: {
click: {
name: "Create Click Action (CSS Selector)",
description: "This is my Create Action (click, CSS Selector)",
description: "Create Action (click, CSS Selector)",
selector: ".my-custom-class",
},
pageView: {
name: "Create Page view Action (specific Page URL)",
description: "This is my Create Action (Page view)",
description: "Create Action (Page view)",
matcher: {
label: "Contains",
value: "custom-url",
@@ -355,16 +355,16 @@ export const actions = {
},
exitIntent: {
name: "Create Exit Intent Action",
description: "This is my Create Action (Exit Intent)",
description: "Create Action (Exit Intent)",
},
fiftyPercentScroll: {
name: "Create 50% Scroll Action",
description: "This is my Create Action (50% Scroll)",
description: "Create Action (50% Scroll)",
},
},
code: {
name: "Create Action (Code)",
description: "This is my Create Action (Code)",
description: "Create Action (Code)",
key: "Create Action (Code)",
},
},
@@ -372,12 +372,12 @@ export const actions = {
noCode: {
click: {
name: "Edit Click Action (CSS Selector)",
description: "This is my Edit Action (click, CSS Selector)",
description: "Edit Action (click, CSS Selector)",
selector: ".my-custom-class-edited",
},
pageView: {
name: "Edit Page view Action (specific Page URL)",
description: "This is my Edit Action (Page view)",
description: "Edit Action (Page view)",
matcher: {
label: "Starts with",
value: "custom-url0-edited",
@@ -386,26 +386,26 @@ export const actions = {
},
exitIntent: {
name: "Edit Exit Intent Action",
description: "This is my Edit Action (Exit Intent)",
description: "Edit Action (Exit Intent)",
},
fiftyPercentScroll: {
name: "Edit 50% Scroll Action",
description: "This is my Edit Action (50% Scroll)",
description: "Edit Action (50% Scroll)",
},
},
code: {
description: "This is my Edit Action (Code)",
description: "Edit Action (Code)",
},
},
delete: {
noCode: {
name: "Delete click Action (CSS Selector)",
description: "This is my Delete Action (CSS Selector)",
description: "Delete Action (CSS Selector)",
selector: ".my-custom-class-deleted",
},
code: {
name: "Delete Action (Code)",
description: "This is my Delete Action (Code)",
description: "Delete Action (Code)",
},
},
};

View File

@@ -220,6 +220,7 @@ vi.mock("@/lib/constants", () => ({
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
],
DEFAULT_LOCALE: "en-US",
BREVO_API_KEY: "mock-brevo-api-key",

View File

@@ -1,4 +1,3 @@
version: "3.3"
x-environment: &environment
environment:
######################################################## REQUIRED ########################################################

View File

@@ -496,7 +496,7 @@ EOF
if [[ $insert_traefik == "y" ]]; then
cat >> "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.11.29"
image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:
@@ -525,7 +525,7 @@ EOF
cat > "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.11.29"
image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:

View File

@@ -234,6 +234,7 @@
"self-hosting/configuration/smtp",
"self-hosting/configuration/file-uploads",
"self-hosting/configuration/domain-configuration",
"self-hosting/configuration/custom-subpath",
{
"group": "Auth & SSO",
"icon": "lock",

View File

@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
<<: *environment
traefik:
image: "traefik:v2.7"
image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:

View File

@@ -0,0 +1,90 @@
---
title: "Custom Subpath"
description: "Serve Formbricks from a custom URL prefix when you cannot expose it on the root domain."
icon: "link"
---
<Note>
Custom subpath deployments are currently under internal review. If you need early access, please reach out via
[GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
</Note>
### When to use a custom subpath
Use a custom subpath (also called a Next.js base path) when your reverse proxy reserves the root domain for another
service, but you still want Formbricks to live under the same hostname—for example `https://example.com/feedback`.
Support for a build-time `BASE_PATH` variable is available in the Formbricks web app so that all internal routes,
assets, and sign-in redirects honor the prefix.
### Requirements and limitations
- `BASE_PATH` must be present during `pnpm build`; changing it afterward requires a rebuild.
- Official Formbricks Docker images do **not** accept this flag for technical reasons, so you must build your own image.
- All public URLs (`WEBAPP_URL`, `NEXTAUTH_URL`, webhook targets, OAuth callbacks, etc.) need the same prefix.
- Your proxy must rewrite `/custom-path/*` to the Formbricks container while keeping the prefix visible to clients.
### Configure environment variables
Add the following variables to the environment you use for builds (local, CI, or Docker build args):
```bash
BASE_PATH="/custom-path"
WEBAPP_URL="https://yourdomain.com/custom-path"
NEXTAUTH_URL="https://yourdomain.com/custom-path/api/auth"
```
If you use email links, webhooks, or third-party OAuth providers, ensure every URL you register includes the prefix.
### Build a Docker image with a custom subpath
<Steps>
<Step title="Clone Formbricks and prepare secrets">
Make sure you have the repository checked out and create temporary files (or use <code>--secret</code>) for the
required build-time secrets such as <code>DATABASE_URL</code>, <code>ENCRYPTION_KEY</code>, <code>REDIS_URL</code>,
and optional telemetry tokens.
</Step>
<Step title="Pass BASE_PATH as a build argument">
Use the Formbricks web Dockerfile and supply the custom subpath via <code>--build-arg</code>. Example:
```bash
docker build \
--progress=plain \
--no-cache \
--build-arg BASE_PATH=/custom-path \
--secret id=database_url,src=<(printf "postgresql://user:password@localhost:5432/formbricks?schema=public") \
--secret id=encryption_key,src=<(printf "your-32-character-encryption-key-here") \
--secret id=redis_url,src=<(printf "redis://localhost:6379") \
--secret id=sentry_auth_token,src=<(printf "") \
-f apps/web/Dockerfile \
-t formbricks-web \
.
```
During the build logs you should see <code>BASE PATH /custom-path</code>, confirming that Next.js picked up the
prefix.
</Step>
<Step title="Run the container behind your proxy">
Start the resulting image with the same runtime environment variables you normally use (database credentials,
mailing provider, etc.). Point your reverse proxy so that <code>/custom-path</code> requests forward to
<code>http://formbricks-web:3000/custom-path</code> without stripping the prefix.
</Step>
</Steps>
### Verify the deployment
1. Open `https://yourdomain.com/custom-path` and complete the onboarding flow.
2. Create a survey and preview it—embedded scripts now load assets relative to the subpath.
3. Sign out and confirm the login page still includes `/custom-path`.
### Troubleshooting checklist
- Confirm your build pipeline actually passes `BASE_PATH` (and, if needed, `WEBAPP_URL`/`NEXTAUTH_URL`) into the build
stage—check CI logs for the `BASE PATH /your-prefix` line and make sure custom Dockerfiles or wrappers forward
`--build-arg BASE_PATH=...` correctly.
- If you cannot log in, double-check that `NEXTAUTH_URL` includes the prefix and uses the full route to the API as stated above. NextAuth rejects callbacks that do not
match exactly.
- Re-run the Docker build when changing `BASE_PATH`; simply editing the container environment is not sufficient.
- Inspect your proxy configuration to ensure it does not rewrite paths internally (e.g., `strip_prefix` needs to stay
disabled).
- When in doubt, rebuild locally with `--progress=plain` and verify that the `BASE PATH` line reflects your prefix.

View File

@@ -81,6 +81,13 @@ Example of Response Created webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
@@ -125,6 +132,13 @@ Example of Response Updated webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
@@ -170,6 +184,13 @@ Example of Response Finished webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {

View File

@@ -37,6 +37,7 @@ Email followups allow you to automatically send customized emails to respondents
The "To" field can be configured to use:
<ul>
<li><strong>Verified Email:</strong> The email address entered in the email verification step (when email verification is enabled)</li>
<li><strong>Email Questions:</strong> Responses to question type `Open Text` of type `email`</li>
<li><strong>Contact Info:</strong> Responses to question type `Contact`</li>
<li><strong>Hidden Fields:</strong> Values from hidden fields</li>

View File

@@ -17,36 +17,36 @@ Integrate the **Formbricks App Survey SDK** into your app using multiple options
<CardGroup cols={2}>
<Card title="HTML" icon="html5" color="orange" href="#html">
[All you need to do is add three lines of code to your HTML script, and that's it!](https://formbricks.com/docs/app-surveys/framework-guides#html)
All you need to do is add three lines of code to your HTML script, and that's it!
</Card>
<Card title="React.js" icon="react" color="lightblue" href="#react-js">
[Load our JavaScript library with your environment ID, and you're ready to
go!](https://formbricks.com/docs/app-surveys/framework-guides#react-js)
Load our JavaScript library with your environment ID, and you're ready to
go!
</Card>
<Card title="Next.js" icon="react" href="#next-js">
[Natively add us to your Next.js project, with support for both App and Pages project
structure.](https://formbricks.com/docs/app-surveys/framework-guides#next-js)
Natively add us to your Next.js project, with support for both App and Pages project
structure.
</Card>
<Card title="Vue.js" icon="vuejs" href="#vue-js">
Learn how to use Formbricks' React Native SDK to integrate your surveys into React Native applications.
Learn how to use Formbricks' Vue.js SDK to integrate your surveys into Vue.js applications.
</Card>
<Card title="React Native" icon="react" color="lightblue" href="#react-native">
[Easily integrate our SDK with your React Native app for seamless survey
support.](https://formbricks.com/docs/app-surveys/framework-guides#react-native)
Easily integrate our SDK with your React Native app for seamless survey
support.
</Card>
<Card title="Swift" icon="swift" color="orange" href="#swift">
[Use our iOS SDK to quickly integrate surveys into your iOS
applications.](https://formbricks.com/docs/app-surveys/framework-guides#swift)
Use our iOS SDK to quickly integrate surveys into your iOS
applications.
</Card>
<Card title="Android" icon="android" color="green" href="#android">
[Integrate surveys into your Android applications using our native Kotlin
SDK.](https://formbricks.com/docs/app-surveys/framework-guides#android)
Integrate surveys into your Android applications using our native Kotlin
SDK.
</Card>
</CardGroup>
@@ -345,6 +345,8 @@ Now, visit the [Validate Your Setup](#validate-your-setup) section to verify you
## Swift
<Info>**Minimum iOS Version:** The Formbricks iOS SDK requires **iOS 16.4** or higher.</Info>
Install the Formbricks iOS SDK using the following steps:
**Swift Package Manager**
@@ -363,7 +365,7 @@ Install the Formbricks iOS SDK using the following steps:
1. Add the following to your `Podfile`:
```ruby
platform :ios, '16.6'
platform :ios, '16.4'
use_frameworks! :linkage => :static
target 'YourTargetName' do
@@ -429,6 +431,10 @@ Now, visit the [Validate Your Setup](#validate-your-setup) section to verify you
## Android
<Info>
**Minimum Android Version:** The Formbricks Android SDK requires **Android 10 (API level 29)** or higher.
</Info>
Install the Formbricks Android SDK using the following steps:
### Installation

View File

@@ -34,18 +34,20 @@
"prepare": "husky install",
"storybook": "turbo run storybook",
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
"i18n:generate": " pnpm --filter @formbricks/surveys i18n:generate",
"generate-translations": "cd apps/web && npx lingo.dev@latest i18n",
"i18n:surveys:generate": "pnpm --filter @formbricks/surveys i18n:generate",
"i18n:web:generate": "pnpm --filter @formbricks/web i18n:generate",
"generate-translations": "pnpm i18n:web:generate && pnpm i18n:surveys:generate",
"scan-translations": "pnpm --filter @formbricks/i18n-utils scan-translations",
"i18n": "pnpm generate-translations && pnpm scan-translations",
"i18n:validate": "pnpm scan-translations"
},
"dependencies": {
"react": "19.1.4",
"react-dom": "19.1.4"
"react": "19.1.2",
"react-dom": "19.1.2"
},
"devDependencies": {
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
"@azure/identity": "4.13.0",
"@azure/playwright": "1.0.0",
"@formbricks/eslint-config": "workspace:*",
"@playwright/test": "1.56.1",
"eslint": "8.57.0",
@@ -83,11 +85,12 @@
},
"overrides": {
"axios": ">=1.12.2",
"node-forge": ">=1.3.2",
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
}
}
}

View File

@@ -0,0 +1,99 @@
import { Prisma } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
import { type SurveyRecord } from "./types";
export const removeEmptyImageAndVideoUrlsFromElements: MigrationScript = {
type: "data",
id: "ohw7fb1f64yfh2vax294agp0",
name: "20251208033316_remove_empty_image_and_video_urls_from_elements",
run: async ({ tx }) => {
// Find all surveys with empty imageUrl or videoUrl
const surveysFindQuery = `
SELECT s.id, s.blocks, s."welcomeCard", s.endings
FROM "Survey" AS s
WHERE EXISTS (
SELECT 1
FROM unnest(s.blocks) AS block
CROSS JOIN jsonb_array_elements(block->'elements') AS element
WHERE element->>'imageUrl' = ''
OR element->>'videoUrl' = ''
) OR s."welcomeCard"->>'fileUrl' = ''
OR s."welcomeCard"->>'videoUrl' = ''
OR EXISTS (
SELECT 1
FROM unnest(s.endings) AS ending
WHERE ending->>'imageUrl' = ''
OR ending->>'videoUrl' = ''
)
`;
const surveysWithEmptyUrls: SurveyRecord[] = await tx.$queryRaw`${Prisma.raw(surveysFindQuery)}`;
logger.info(`Found ${surveysWithEmptyUrls.length.toString()} surveys with empty imageUrl or videoUrl`);
// Process in batches to avoid overwhelming the connection pool
const BATCH_SIZE = 1000;
for (let i = 0; i < surveysWithEmptyUrls.length; i += BATCH_SIZE) {
const batch = surveysWithEmptyUrls.slice(i, i + BATCH_SIZE);
const batchPromises = batch.map((survey) => {
// Clean the blocks
const cleanedBlocks = survey.blocks.map((block) => {
const cleanedElements = block.elements.map((element) => {
const cleanedElement = { ...element };
if (cleanedElement.imageUrl === "") {
delete cleanedElement.imageUrl;
}
if (cleanedElement.videoUrl === "") {
delete cleanedElement.videoUrl;
}
return cleanedElement;
});
return { ...block, elements: cleanedElements };
});
const cleanedWelcomeCard = { ...survey.welcomeCard };
if (cleanedWelcomeCard.fileUrl === "") {
delete cleanedWelcomeCard.fileUrl;
}
if (cleanedWelcomeCard.videoUrl === "") {
delete cleanedWelcomeCard.videoUrl;
}
const cleanedEndings = survey.endings.map((ending) => {
const cleanedEnding = { ...ending };
if (cleanedEnding.imageUrl === "") {
delete cleanedEnding.imageUrl;
}
if (cleanedEnding.videoUrl === "") {
delete cleanedEnding.videoUrl;
}
return cleanedEnding;
});
// Convert JSON arrays to PostgreSQL jsonb[] using array_agg + jsonb_array_elements
const blocksJson = JSON.stringify(cleanedBlocks);
const endingsJson = JSON.stringify(cleanedEndings);
const welcomeCardJson = JSON.stringify(cleanedWelcomeCard);
return tx.$executeRaw`
UPDATE "Survey"
SET
blocks = (SELECT array_agg(elem) FROM jsonb_array_elements(${blocksJson}::jsonb) AS elem),
endings = (SELECT array_agg(elem) FROM jsonb_array_elements(${endingsJson}::jsonb) AS elem),
"welcomeCard" = ${welcomeCardJson}::jsonb
WHERE id = ${survey.id}
`;
});
await Promise.all(batchPromises);
logger.info(
`Processed batch ${(Math.floor(i / BATCH_SIZE) + 1).toString()}/${Math.ceil(surveysWithEmptyUrls.length / BATCH_SIZE).toString()}`
);
}
logger.info(`Successfully cleaned ${surveysWithEmptyUrls.length.toString()} surveys`);
},
};

View File

@@ -0,0 +1,22 @@
export interface SurveyElement {
id: string;
imageUrl?: string;
videoUrl?: string;
}
export interface Block {
id: string;
elements: SurveyElement[];
}
export interface SurveyRecord {
id: string;
blocks: Block[];
welcomeCard: {
fileUrl?: string;
videoUrl?: string;
};
endings: {
imageUrl?: string;
videoUrl?: string;
}[];
}

View File

@@ -40,6 +40,8 @@ export const ZSurveyFollowUpAction = z.object({
subject: z.string(),
body: z.string(),
attachResponseData: z.boolean(),
includeVariables: z.boolean().optional(),
includeHiddenFields: z.boolean().optional(),
}),
});

View File

@@ -2,6 +2,8 @@
import { SurveyStatus, SurveyType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
import { ZLogo } from "../../types/styling";
import { ZSurveyBlocks } from "../../types/surveys/blocks";
import {
ZSurveyEnding,
@@ -172,6 +174,7 @@ const ZSurveyBase = z.object({
background: ZSurveyStylingBackground.nullish(),
hideProgressBar: z.boolean().nullish(),
isLogoHidden: z.boolean().nullish(),
logo: ZLogo.nullish(),
})
.nullable()
.openapi({

View File

@@ -1,5 +1,5 @@
export const JS_LOCAL_STORAGE_KEY = "formbricks-js";
export const LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website";
export const LEGACY_JS_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
export const CONTAINER_ID = "formbricks-app-container";
export const CONTAINER_ID = "formbricks-modal-container";
export const RECAPTCHA_SCRIPT_ID = "formbricks-recaptcha-script";

View File

@@ -7,7 +7,7 @@ import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { checkPageUrl } from "@/lib/survey/no-code-action";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "@/lib/survey/widget";
import { closeSurvey } from "@/lib/survey/widget";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import {
@@ -142,9 +142,6 @@ export const setup = async (
});
}
logger.debug("Adding widget container to DOM");
addWidgetContainer();
if (
existingConfig?.environment &&
existingConfig.environmentId === configInput.environmentId &&
@@ -344,10 +341,7 @@ export const tearDown = (): void => {
filteredSurveys,
});
// remove container element from DOM
removeWidgetContainer();
addWidgetContainer();
setIsSurveyRunning(false);
closeSurvey();
};
export const handleErrorOnFirstSetup = (e: { code: string; responseMessage: string }): Promise<never> => {

View File

@@ -290,12 +290,13 @@ describe("setup.ts", () => {
test("resets user state to default", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environment: { data: { surveys: [] } },
user: { data: { userId: "XYZ" } },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as Config);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
tearDown();

View File

@@ -174,9 +174,8 @@ export const renderWidget = async (
export const closeSurvey = (): void => {
const config = Config.getInstance();
// remove container element from DOM
// remove the survey modal container from DOM
removeWidgetContainer();
addWidgetContainer();
const { environment, user } = config.get();
const filteredSurveys = filterSurveys(environment, user);

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