Compare commits

..

7 Commits

Author SHA1 Message Date
Bhagya Amarasinghe
972407eb93 fix: optimize license check flow (backport to 4.6) (#7193) 2026-02-02 19:21:11 +05:30
Dhruwang Jariwala
fcbb99c43d fix: (backport) jerky animation behaviour (#7158) (#7163) 2026-01-26 10:24:19 +05:30
Dhruwang Jariwala
ec415a7aa1 fix: (backport) nps & rating rtl UI (#7154) (#7162) 2026-01-23 18:29:09 +05:30
Anshuman Pandey
a1e53c9051 fix: [Backport] fixes response card UI for cta question (#7161) 2026-01-23 17:58:53 +05:30
Anshuman Pandey
680295c63e fix: [Backport] fixes the cta element survey not found error (#7160) 2026-01-23 17:58:31 +05:30
Anshuman Pandey
73b40469f7 fix: (BACKPORT) language variants not working for app surveys (#7151) (#7159) 2026-01-23 17:32:00 +05:30
Dhruwang Jariwala
282e061606 fix: language variants not working for app surveys (#7151) 2026-01-23 17:10:33 +05:30
513 changed files with 12116 additions and 26482 deletions

View File

@@ -184,13 +184,8 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# OTEL_SERVICE_NAME=formbricks
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=1
# OpenTelemetry URL for tracing
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
# Unsplash API Key
UNSPLASH_ACCESS_KEY=

View File

@@ -65,8 +65,8 @@ jobs:
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
@@ -77,7 +77,7 @@ jobs:
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./charts/formbricks
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"

View File

@@ -9,7 +9,6 @@ on:
merge_group:
permissions:
contents: read
pull-requests: read
jobs:
sonarqube:
name: SonarQube
@@ -51,9 +50,6 @@ jobs:
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
with:
args: >
-Dsonar.verbose=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -6,9 +6,19 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push:
branches:
- main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs:
validate-translations:
@@ -22,39 +32,32 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
node-version: 18
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
version: 9.15.9
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
run: pnpm run scan-translations
run: |
echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Skip (no translation-related changes)
if: steps.changes.outputs.translations != 'true'
run: echo "No translation-related files changed — skipping validation."
- name: Summary
if: success()
run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""

3
.gitignore vendored
View File

@@ -13,7 +13,6 @@
**/.next/
**/out/
**/build
**/next-env.d.ts
# node
**/dist/
@@ -64,5 +63,3 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
.cursorrules
i18n.cache
stats.html
# next-agents-md
.next-docs/

View File

@@ -1 +1,40 @@
pnpm lint-staged
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi

File diff suppressed because one or more lines are too long

View File

@@ -1,50 +0,0 @@
# Fix Summary: Custom Script Error Handling (FORMBRICKS-P5)
## Problem
User-provided custom scripts injected via `customHeadScripts` were calling deprecated MetaMask internal APIs (`window.ethereum._handleChainChanged`), causing unhandled TypeErrors that:
- Were reported to Sentry as production errors
- Could potentially break the survey experience for users
- Occurred when the survey page loaded with certain browser extensions (MetaMask)
## Root Cause
The `CustomScriptsInjector` component was correctly injecting user-provided scripts, but had no protection against runtime errors that occurred *after* the scripts were executed. The existing try-catch block only caught errors during the injection process itself, not runtime errors from the injected code.
## Solution
Enhanced error handling in the `CustomScriptsInjector` component to wrap all user-provided scripts in defensive error handling:
### Changes Made
1. **Inline Script Wrapping** (lines 64-72):
- All inline script content is now wrapped in try-catch blocks
- Runtime errors are caught and logged to console with `[Formbricks]` prefix
- Errors don't break the survey or propagate to error tracking
2. **External Script Error Handling** (lines 75-77):
- Added `onerror` event handlers to all external scripts loaded via `src` attribute
- Loading errors are logged to console but don't break functionality
3. **Documentation**:
- Updated JSDoc to clearly state that user scripts are error-isolated
- Added inline comments explaining the defensive programming approach
## Benefits
- ✅ Prevents third-party script errors from breaking surveys
- ✅ Reduces noise in error tracking (Sentry)
- ✅ Maintains debuggability via console warnings
- ✅ No breaking changes to existing functionality
- ✅ Works for both inline and external scripts
## Testing Considerations
- User scripts with errors will execute up to the point of failure, then stop gracefully
- Error messages are visible in browser console for debugging by self-hosted admins
- Survey functionality remains intact even if custom scripts fail completely
## Example
**Before**: User script calling `window.ethereum._handleChainChanged()` would throw an unhandled TypeError
**After**: The error is caught, logged to console as a warning, and the survey continues to function normally
## Files Modified
- `apps/web/modules/survey/link/components/custom-scripts-injector.tsx`
## Commit
- `fix: wrap custom scripts in try-catch to prevent runtime errors` (Fixes FORMBRICKS-P5)

View File

@@ -23,7 +23,7 @@
"@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"esbuild": "0.27.2",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.1.11",
"prop-types": "15.8.1",

View File

@@ -1,4 +1,20 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["locales/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "locales", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},
],
},
},
],
};

View File

@@ -1,4 +1,4 @@
FROM node:24-alpine3.23 AS base
FROM node:22-alpine3.22 AS base
#
## step 1: Prune monorepo
@@ -20,7 +20,7 @@ FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate
RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -69,14 +69,20 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=sentry_auth_token \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
#
## step 3: setup production runner
#
FROM base AS runner
# Update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -107,13 +113,15 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
# Copy prisma client packages
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
@@ -126,25 +134,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
# Pino loads transport code in worker threads via dynamic require().
# Next.js file tracing only traces static imports, missing runtime-loaded files
# (e.g. pino/lib/transport-stream.js, transport targets).
# Copy the full packages to ensure all runtime files are available.
COPY --from=installer /app/node_modules/pino ./node_modules/pino
RUN chmod -R 755 ./node_modules/pino
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
RUN chmod -R 755 ./node_modules/pino-abstract-transport
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
RUN chmod -R 755 ./node_modules/otlp-logger
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
RUN npm install -g prisma@6
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -154,8 +144,10 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/

View File

@@ -25,7 +25,7 @@ const mockProject: TProject = {
},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
logo: null,

View File

@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -17,7 +17,6 @@ import {
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -65,17 +64,10 @@ export const ProjectSettings = ({
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
// Without this, only brandColor is saved and the look-and-feel page falls
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
organizationId,
data: {
...data,
styling: fullStyling,
config: { channel, industry },
teamIds: data.teamIds,
},
@@ -120,7 +112,6 @@ export const ProjectSettings = ({
const projectName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
const { isSubmitting } = form.formState;
const organizationTeamsOptions = organizationTeams.map((team) => ({
@@ -235,7 +226,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
@@ -244,7 +235,7 @@ export const ProjectSettings = ({
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={previewStyling}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}

View File

@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -138,7 +138,7 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
// Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
throw new Error("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);

View File

@@ -109,10 +109,7 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
},
{
name: t("common.configuration"),

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,11 +49,8 @@ export const NotificationSwitch = ({
];
}
} else {
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -81,7 +78,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(

View File

@@ -1,142 +0,0 @@
"use client";
import { TFunction } from "i18next";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps {
status: LicenseStatus;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: LicenseStatus,
t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) {
case "active":
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license":
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
default:
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
}
};
export const EnterpriseLicenseStatus = ({
status,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
const handleRecheck = async () => {
setIsRechecking(true);
try {
const result = await recheckLicenseAction({ environmentId });
if (result?.serverError) {
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
return;
}
if (result?.data) {
if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else {
toast.success(t("environments.settings.enterprise.recheck_license_success"));
}
router.refresh();
} else {
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
);
} finally {
setIsRechecking(false);
}
};
const badgeConfig = getBadgeConfig(status, t);
return (
<SettingsCard
title={t("environments.settings.enterprise.license_status")}
description={t("environments.settings.enterprise.license_status_description")}>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecheck}
disabled={isRechecking}
className="shrink-0">
{isRechecking ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
{t("environments.settings.enterprise.rechecking")}
</>
) : (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
{t("environments.settings.enterprise.recheck_license")}
</>
)}
</Button>
</div>
{status === "unreachable" && gracePeriodEnd && (
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</AlertDescription>
</Alert>
)}
{status === "invalid_license" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_invalid_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</SettingsCard>
);
};

View File

@@ -2,10 +2,9 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -26,8 +25,7 @@ const Page = async (props) => {
return notFound();
}
const licenseState = await getEnterpriseLicense();
const hasLicense = licenseState.status !== "no-license";
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const paidFeatures = [
{
@@ -92,22 +90,35 @@ const Page = async (props) => {
activeId="enterprise"
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{isEnterpriseEdition ? (
<div>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="space-y-4 p-8">
<div className="flex items-center gap-x-2">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<p className="text-slate-800">
{t(
"environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked"
)}
</p>
</div>
<p className="text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a className="font-semibold underline" href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
</p>
</div>
</div>
</div>
) : (
<div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}
@@ -142,8 +153,8 @@ const Page = async (props) => {
{t("environments.settings.enterprise.enterprise_features")}
</h2>
<ul className="my-4 space-y-4">
{paidFeatures.map((feature) => (
<li key={feature.title} className="flex items-center">
{paidFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>

View File

@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -33,12 +32,7 @@ export const DeleteOrganization = ({
setIsDeleting(true);
try {
const result = await deleteOrganizationAction({ organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
return;
}
await deleteOrganizationAction({ organizationId: organization.id });
toast.success(t("environments.settings.general.organization_deleted_successfully"));
if (typeof localStorage !== "undefined") {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);

View File

@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
});
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.number().int().min(1).max(100),
offset: z.number().int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.schema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});

View File

@@ -316,14 +316,6 @@ export const generateResponseTableColumns = (
},
};
const responseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "responseId",
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
cell: ({ row }) => {
return <IdBadge id={row.original.responseId} />;
},
};
const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota",
header: t("common.quota"),
@@ -422,7 +414,6 @@ export const generateResponseTableColumns = (
const baseColumns = [
personColumn,
singleUseIdColumn,
responseIdColumn,
dateColumn,
...(showQuotasColumn ? [quotasColumn] : []),
statusColumn,

View File

@@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";

View File

@@ -1,125 +0,0 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean;
tab: "dropOffs" | "quotas" | "impressions" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean;
}
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val;
setTab(change);
};
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}>
<InteractiveCard
key="impressions"
tab="impressions"
<StatCard
label={t("environments.surveys.summary.impressions")}
percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/>
<StatCard
label={t("environments.surveys.summary.starts")}

View File

@@ -1,31 +1,21 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
@@ -61,76 +51,17 @@ export const SummaryPage = ({
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
@@ -190,18 +121,6 @@ export const SummaryPage = ({
setTab={setTab}
isQuotasAllowed={isQuotasAllowed}
/>
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5">

View File

@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps {
tab: "dropOffs" | "quotas" | "impressions";
tab: "dropOffs" | "quotas";
label: string;
percentage: number | null;
percentage: number;
value: React.ReactNode;
tooltipText: string;
isLoading: boolean;

View File

@@ -21,7 +21,6 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -269,14 +268,7 @@ export const AddIntegrationModal = ({
airtableIntegrationData.config?.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: airtableIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: airtableIntegrationData });
if (isEditMode) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -312,11 +304,7 @@ export const AddIntegrationModal = ({
const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1);
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData });
handleClose();
router.refresh();

View File

@@ -1,49 +1,12 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),

View File

@@ -20,10 +20,6 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -122,17 +118,6 @@ export const AddIntegrationModal = ({
resetForm();
}, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -144,7 +129,6 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
@@ -153,11 +137,13 @@ export const AddIntegrationModal = ({
});
if (!spreadsheetNameResponse?.data) {
showErrorMessageToast(spreadsheetNameResponse);
return;
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;
@@ -179,14 +165,7 @@ export const AddIntegrationModal = ({
// create action
googleSheetIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -226,14 +205,7 @@ export const AddIntegrationModal = ({
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
@@ -294,7 +266,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -8,11 +8,9 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -37,23 +35,10 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
if (url) {
@@ -79,8 +64,6 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale}
/>
</>

View File

@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,19 +12,15 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale;
}
@@ -33,8 +29,6 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
@@ -74,17 +68,7 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
@@ -93,19 +77,6 @@ export const ManageIntegration = ({
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setSelectedIntegration(null);

View File

@@ -22,7 +22,6 @@ import {
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
@@ -218,14 +217,7 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -244,14 +236,7 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -17,7 +17,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -145,14 +144,7 @@ export const AddChannelMappingModal = ({
// create action
slackIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -189,14 +181,7 @@ export const AddChannelMappingModal = ({
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -257,16 +256,10 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.map((choice) => choice.imageUrl)
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.map((choice) => choice.imageUrl);
}
});

View File

@@ -18,7 +18,6 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -31,10 +30,7 @@ export const POST = async (request: Request) => {
}
const jsonInput = await request.json();
const convertedJsonInput = convertDatesInObject(
jsonInput,
new Set(["contactAttributes", "variables", "data", "meta"])
);
const convertedJsonInput = convertDatesInObject(jsonInput);
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
@@ -96,15 +92,12 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,

View File

@@ -1,6 +1,4 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -8,29 +6,18 @@ import {
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { createOrUpdateIntegration } from "@/lib/integration/service";
export const GET = async (req: Request) => {
const url = new URL(req.url);
const environmentId = url.searchParams.get("state");
const code = url.searchParams.get("code");
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
const session = await getServerSession(authOptions);
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
@@ -43,39 +30,33 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
if (!code) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {
type: integrationType,
type: "googleSheets" as "googleSheets",
environment: environmentId,
config: {
key,
data: existingConfig?.data ?? [],
data: [],
email: userEmail,
},
};

View File

@@ -0,0 +1,180 @@
// Deprecated: This api route is deprecated now and will be removed in the future.
// Deprecated: This is currently only being used for the older react native SDKs. Please upgrade to the latest SDKs.
import { NextRequest, userAgent } from "next/server";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getContactByUserId } from "@/app/api/v1/client/[environmentId]/app/sync/lib/contact";
import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib/survey";
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getActionClasses } from "@/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
const validateInput = (
environmentId: string,
userId: string
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
if (!inputValidation.success) {
return {
isValid: false,
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
return { isValid: true, data: inputValidation.data };
};
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
logger.error({ environmentId }, "Organization does not exist");
// fail closed if the organization does not exist
return true;
}
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
return isLimitReached;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
const params = await props.params;
try {
const { device } = userAgent(req);
// validate using zod
const validation = validateInput(params.environmentId, params.userId);
if (!validation.isValid) {
return { response: validation.error };
}
const { environmentId, userId } = validation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await updateEnvironment(environment.id, { appSetupCompleted: true });
}
// check organization subscriptions and response limits
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
},
},
},
value: userId,
},
},
environment: { connect: { id: environmentId } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return {
response: responses.successResponse({ ...state }, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
return {
response: responses.internalServerErrorResponse(
"Unable to handle the request: " + error.message,
true
),
};
}
},
});

View File

@@ -0,0 +1,83 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContact } from "@/modules/ee/contacts/types/contact";
import { getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const contactMock: Partial<TContact> & {
attributes: { value: string; attributeKey: { key: string } }[];
} = {
id: contactId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
describe("getContactByUserId", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return contact if found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toEqual(contactMock);
});
test("should return null if contact not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toBeNull();
});
});

View File

@@ -0,0 +1,42 @@
import "server-only";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const getContactByUserId = reactCache(
async (
environmentId: string,
userId: string
): Promise<{
attributes: {
value: string;
attributeKey: {
key: string;
};
}[];
id: string;
} | null> => {
const contact = await prisma.contact.findFirst({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
if (!contact) {
return null;
}
return contact;
}
);

View File

@@ -0,0 +1,323 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { getSyncSurveys } from "./survey";
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
anySurveyHasFilters: vi.fn(),
}));
vi.mock("@/lib/utils/datetime", () => ({
diffInDays: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
evaluateSegment: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findMany: vi.fn(),
},
response: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-env-id";
const contactId = "test-contact-id";
const contactAttributes = { userId: "user1", email: "test@example.com" };
const deviceType = "desktop";
const mockProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
environments: [],
recontactDays: 10,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
languages: [],
} as unknown as TProject;
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey 1",
environmentId: environmentId,
type: "app",
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
surveyClosedMessage: null,
singleUse: null,
styling: null,
pin: null,
displayLimit: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],
triggers: [],
languages: [],
variables: [],
hiddenFields: { enabled: false },
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
};
// Helper function to create mock display objects
const createMockDisplay = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
id,
createdAt: createdAt || new Date(),
updatedAt: new Date(),
surveyId,
contactId,
responseId: null,
status: null,
});
// Helper function to create mock response objects
const createMockResponse = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
id,
createdAt: createdAt || new Date(),
updatedAt: new Date(),
finished: false,
surveyId,
contactId,
endingId: null,
data: {},
variables: {},
ttc: {},
meta: {},
contactAttributes: null,
singleUseId: null,
language: null,
displayId: null,
});
describe("getSyncSurveys", () => {
beforeEach(() => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
vi.mocked(evaluateSegment).mockResolvedValue(true);
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
});
afterEach(() => {
vi.resetAllMocks();
});
test("should throw error if product not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
"Project not found"
);
});
test("should return empty array if no surveys found", async () => {
vi.mocked(getSurveys).mockResolvedValue([]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should return empty array if no 'app' type surveys in progress", async () => {
const surveys: TSurvey[] = [
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should filter by displayOption 'displayOnce'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Already displayed
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displayMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); // Already responded
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displaySome'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([
createMockDisplay("d1", "s1", contactId),
createMockDisplay("d2", "s1", contactId),
]); // Display limit reached
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Within limit
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
// Test with response already submitted
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result3).toEqual([]);
});
test("should not filter by displayOption 'respondMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]);
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
});
test("should filter by product recontactDays if survey recontactDays is null", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const displayDate = new Date();
vi.mocked(prisma.display.findMany).mockResolvedValue([
createMockDisplay("d1", "s2", contactId, displayDate), // Display for another survey
]);
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should return surveys if no segment filters exist", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
expect(evaluateSegment).not.toHaveBeenCalled();
});
test("should evaluate segment filters if they exist", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
// Case 1: Segment evaluation matches
vi.mocked(evaluateSegment).mockResolvedValue(true);
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result1).toEqual(surveys);
expect(evaluateSegment).toHaveBeenCalledWith(
{
attributes: contactAttributes,
deviceType,
environmentId,
contactId,
userId: contactAttributes.userId,
},
segment.filters
);
// Case 2: Segment evaluation does not match
vi.mocked(evaluateSegment).mockResolvedValue(false);
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual([]);
});
test("should handle Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(getSurveys).mockRejectedValue(prismaError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
DatabaseError
);
expect(logger.error).toHaveBeenCalledWith(prismaError);
});
test("should handle general errors", async () => {
const generalError = new Error("Something went wrong");
vi.mocked(getSurveys).mockRejectedValue(generalError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
generalError
);
});
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
// Let's assume the filter logic works correctly and test the intended path.
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]); // Expect empty array, not an error in this case.
});
});

View File

@@ -0,0 +1,148 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
export const getSyncSurveys = reactCache(
async (
environmentId: string,
contactId: string,
contactAttributes: Record<string, string | number>,
deviceType: "phone" | "desktop" = "desktop"
): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]);
try {
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
const displays = await prisma.display.findMany({
where: {
contactId,
},
});
const responses = await prisma.response.findMany({
where: {
contactId,
},
});
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
switch (survey.displayOption) {
case "respondMultiple":
return true;
case "displayOnce":
return displays.filter((display) => display.surveyId === survey.id).length === 0;
case "displayMultiple":
if (!responses) return true;
else {
return responses.filter((response) => response.surveyId === survey.id).length === 0;
}
case "displaySome":
if (survey.displayLimit === null) {
return true;
}
if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
return false;
}
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
default:
throw Error("Invalid displayOption");
}
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = surveys.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (project.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= project.recontactDays;
} else {
return true;
}
});
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
// if no surveys have segment filters, return the surveys
if (!anySurveyHasFilters(surveys)) {
return surveys;
}
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
const { segment } = survey;
// if the survey has no segment, or the segment has no filters, we return the survey
if (!segment || !segment.filters?.length) {
return survey;
}
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: contactAttributes ?? {},
deviceType,
environmentId,
contactId,
userId: String(contactAttributes.userId),
},
segment.filters
);
return result ? survey : null;
});
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -0,0 +1,245 @@
import { describe, expect, test, vi } from "vitest";
import { TAttributes } from "@formbricks/types/attributes";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyEnding,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { parseRecallInfo } from "@/lib/utils/recall";
import { replaceAttributeRecall } from "./utils";
vi.mock("@/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((text, attributes) => {
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
const match = text.match(recallPattern);
if (match && match[1]) {
const recallKey = match[1];
const attributeValue = attributes[recallKey];
if (attributeValue !== undefined) {
return text.replace(recallPattern, `parsed-${attributeValue}`);
}
}
return text; // Return original text if no match or attribute not found
}),
}));
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
],
triggers: [],
recontactDays: null,
displayLimit: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
variables: [],
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
displayOption: "displayOnce",
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
pin: null,
metadata: {},
};
const attributes: TAttributes = {
name: "John Doe",
email: "john.doe@example.com",
plan: "premium",
};
describe("replaceAttributeRecall", () => {
test("should replace recall info in question headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!" },
subheader: { default: "Your email is recall:email" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
});
test("should replace recall info in welcome card headline", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome, recall:name!" },
subheader: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
});
test("should replace recall info in end screen headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
endings: [
{
type: "endScreen",
headline: { default: "Thank you, recall:name!" },
subheader: { default: "Your plan: recall:plan" },
buttonLabel: { default: "Finish" },
buttonLink: "https://example.com",
} as unknown as TSurveyEnding,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.endings[0].type).toBe("endScreen");
if (result.endings[0].type === "endScreen") {
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
}
});
test("should handle multiple languages", () => {
const surveyMultiLang: TSurvey = {
...baseSurvey,
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
],
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
required: true,
buttonLabel: { default: "Next", es: "Siguiente" },
placeholder: { default: "Type here...", es: "Escribe aquí..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyMultiLang, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
});
test("should not replace if recall key is not in attributes", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Your company: recall:company" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
});
test("should handle surveys with no recall information", async () => {
const surveyNoRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Just a regular question" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
subheader: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
endings: [
{
type: "endScreen",
headline: { default: "Thank you!" },
buttonLabel: { default: "Finish" },
} as unknown as TSurveyEnding,
],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyNoRecall, attributes);
expect(result).toEqual(surveyNoRecall); // Should be unchanged
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
const surveyEmpty: TSurvey = {
...baseSurvey,
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyEmpty, attributes);
expect(result).toEqual(surveyEmpty);
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
});

View File

@@ -0,0 +1,55 @@
import { TAttributes } from "@formbricks/types/attributes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { parseRecallInfo } from "@/lib/utils/recall";
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
const surveyTemp = structuredClone(survey);
const languages = surveyTemp.languages
.map((surveyLanguage) => {
if (surveyLanguage.default) {
return "default";
}
if (surveyLanguage.enabled) {
return surveyLanguage.language.code;
}
return null;
})
.filter((language): language is string => language !== null);
surveyTemp.questions.forEach((question) => {
languages.forEach((language) => {
if (question.headline[language]?.includes("recall:")) {
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
}
if (question.subheader && question.subheader[language]?.includes("recall:")) {
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
}
});
});
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
languages.forEach((language) => {
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language]?.includes("recall:")) {
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
surveyTemp.welcomeCard.headline[language],
attributes
);
}
});
}
surveyTemp.endings.forEach((ending) => {
if (ending.type === "endScreen") {
languages.forEach((language) => {
if (ending.headline && ending.headline[language]?.includes("recall:")) {
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
if (ending.subheader && ending.subheader[language]?.includes("recall:")) {
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
}
}
});
}
});
return surveyTemp;
};

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -1,314 +0,0 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentStateData } from "./data";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/modules/survey/lib/utils", () => ({
transformPrismaSurvey: vi.fn((survey) => survey),
}));
const environmentId = "cjld2cjxh0000qzrmn831i7rn";
const mockEnvironmentData = {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
},
actionClasses: [
{
id: "action-1",
type: "code",
name: "Test Action",
key: "test-action",
noCodeConfig: null,
},
],
surveys: [
{
id: "survey-1",
name: "Test Survey",
type: "app",
status: "inProgress",
welcomeCard: { enabled: false },
questions: [],
blocks: null,
variables: [],
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
recaptcha: { enabled: false },
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
},
],
};
describe("getEnvironmentStateData", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return environment state data when environment exists", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
const result = await getEnvironmentStateData(environmentId);
expect(result).toEqual({
environment: {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
},
},
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
surveys: mockEnvironmentData.surveys,
actionClasses: mockEnvironmentData.actionClasses,
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: environmentId },
select: expect.objectContaining({
id: true,
type: true,
appSetupCompleted: true,
project: expect.any(Object),
actionClasses: expect.any(Object),
surveys: expect.any(Object),
}),
});
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
});
test("should throw ResourceNotFoundError when project is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: null,
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: null,
},
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma database errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
code: "P2024",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalled();
});
test("should rethrow unexpected errors", async () => {
const unexpectedError = new Error("Unexpected error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(unexpectedError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("Unexpected error");
expect(logger.error).toHaveBeenCalled();
});
test("should handle empty surveys array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toEqual([]);
});
test("should handle empty actionClasses array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
actionClasses: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.actionClasses).toEqual([]);
});
test("should transform surveys using transformPrismaSurvey", async () => {
const multipleSurveys = [
...mockEnvironmentData.surveys,
{
...mockEnvironmentData.surveys[0],
id: "survey-2",
name: "Second Survey",
},
];
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: multipleSurveys,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toHaveLength(2);
});
test("should correctly map project properties to environment.project", async () => {
const customProject = {
...mockEnvironmentData.project,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: customProject,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.project).toEqual({
id: "project-123",
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
});
});
test("should validate environmentId input", async () => {
// Invalid CUID should throw validation error
await expect(getEnvironmentStateData("invalid-id")).rejects.toThrow();
});
test("should handle different environment types", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
type: "development",
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.type).toBe("development");
});
test("should handle appSetupCompleted false", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
appSetupCompleted: false,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.appSetupCompleted).toBe(false);
});
test("should correctly extract organization billing data", async () => {
const customBilling = {
plan: "enterprise",
stripeCustomerId: "cus_123",
limits: {
monthly: { responses: 10000, miu: 50000 },
projects: 100,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: {
id: "org-enterprise",
billing: customBilling,
},
},
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.organization).toEqual({
id: "org-enterprise",
billing: customBilling,
});
});
});

View File

@@ -10,7 +10,6 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -55,7 +54,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
@@ -175,17 +174,17 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: environmentData.project.id,
recontactDays: environmentData.project.recontactDays,
clickOutsideClose: environmentData.project.clickOutsideClose,
overlay: environmentData.project.overlay,
darkOverlay: environmentData.project.darkOverlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
styling: environmentData.project.styling,
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
surveys: transformedSurveys,
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -58,7 +58,7 @@ const mockProject: TJsEnvironmentStateProject = {
inAppSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {
allowStyleOverwrite: false,
},

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -1,15 +1,13 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -33,35 +31,6 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
@@ -144,11 +113,6 @@ export const PUT = withV1ApiWrapper({
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {

View File

@@ -6,14 +6,12 @@ import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -35,26 +33,6 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
@@ -145,11 +123,6 @@ export const POST = withV1ApiWrapper({
};
}
const validationResult = validateResponse(responseInputData, survey);
if (validationResult) {
return validationResult;
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -1,4 +1,4 @@
import { ImageResponse } from "next/og";
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {

View File

@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
ENCRYPTION_KEY,
NOTION_OAUTH_CLIENT_ID,
@@ -10,17 +10,10 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -33,13 +26,6 @@ export const GET = withV1ApiWrapper({
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),

View File

@@ -5,19 +5,12 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
@@ -30,13 +23,6 @@ export const GET = withV1ApiWrapper({
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),

View File

@@ -8,9 +8,12 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,10 +60,7 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
response: responses.successResponse(result.response),
};
} catch (error) {
return {
@@ -192,7 +192,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
response: responses.successResponse(updated),
};
} catch (error) {
return {

View File

@@ -7,9 +7,12 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,9 +57,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
return {

View File

@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
response: responses.successResponse(surveysWithQuestions),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -11,7 +11,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -107,22 +106,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -3,9 +3,8 @@
// Error components must be Client components
import * as Sentry from "@sentry/nextjs";
import { TFunction } from "i18next";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { type ClientErrorType, getClientErrorData, isExpectedError } from "@formbricks/types/errors";
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
@@ -31,13 +30,11 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
const errorData = getClientErrorData(error);
const { title, description } = getErrorMessages(errorData.type, t);
useEffect(() => {
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else if (!isExpectedError(error)) {
Sentry.captureException(error);
}
}, [error]);
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else {
Sentry.captureException(error);
}
return (
<div className="flex h-full w-full flex-col items-center justify-center">

View File

@@ -68,6 +68,7 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
isIntegrationRoute: vi.fn().mockReturnValue(false),
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
};
});
@@ -81,6 +82,7 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
syncUserIdentification: { windowMs: 60000, max: 50 },
},
},
}));
@@ -461,6 +463,45 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("handles sync user identification rate limiting", async () => {
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
const {
isClientSideApiRoute,
isManagementApiRoute,
isIntegrationRoute,
isSyncWithUserIdentificationEndpoint,
} = await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
userId: "user-123",
environmentId: "env-123",
});
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const rateLimitError = new Error("Sync rate limit exceeded");
rateLimitError.message = "Sync rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(applyRateLimit).toHaveBeenCalledWith(
expect.objectContaining({ windowMs: 60000, max: 50 }),
"user-123"
);
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"

View File

@@ -10,6 +10,7 @@ import {
isClientSideApiRoute,
isIntegrationRoute,
isManagementApiRoute,
isSyncWithUserIdentificationEndpoint,
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -47,16 +48,23 @@ enum ApiV1RouteTypeEnum {
}
/**
* Apply client-side API rate limiting (IP-based)
* Apply client-side API rate limiting (IP-based or sync-specific)
*/
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
if (syncEndpoint) {
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
} else {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
}
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
url: string,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
@@ -76,7 +84,7 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(customRateLimitConfig);
await applyClientRateLimit(url, customRateLimitConfig);
}
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
@@ -247,6 +255,7 @@ const getRouteType = (
* Features:
* - Performs authentication once and passes result to handler
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
* - Includes additional sync user identification rate limiting for client-side sync endpoints
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
* - System and Sentry logs are always called for non-success responses
* - Uses function overloads to provide type safety without requiring type guards
@@ -319,7 +328,12 @@ export const withV1ApiWrapper: {
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req.nextUrl.pathname,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}

View File

@@ -4848,14 +4848,12 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
t("templates.preview_survey_question_2_choice_2_label"),
],
headline: t("templates.preview_survey_question_2_headline"),
subheader: t("templates.preview_survey_question_2_subheader"),
required: true,
shuffleOption: "none",
}),
isDraft: true,
},
],
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
},
{

View File

@@ -8,6 +8,7 @@ import {
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
isSyncWithUserIdentificationEndpoint,
} from "./endpoint-validator";
describe("endpoint-validator", () => {
@@ -257,7 +258,6 @@ describe("endpoint-validator", () => {
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
@@ -270,6 +270,58 @@ describe("endpoint-validator", () => {
});
});
describe("isSyncWithUserIdentificationEndpoint", () => {
test("should return environmentId and userId for valid sync URLs", () => {
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
expect(result2).toEqual({
environmentId: "abc-123",
userId: "xyz-789",
});
const result3 = isSyncWithUserIdentificationEndpoint(
"/api/v1/client/env_123_test/app/sync/user_456_test"
);
expect(result3).toEqual({
environmentId: "env_123_test",
userId: "user_456_test",
});
});
test("should handle optional trailing slash", () => {
// Test both with and without trailing slash
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
expect(result2).toEqual({
environmentId: "env123",
userId: "user456",
});
});
test("should return false for invalid sync URLs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
});
test("should handle empty or malformed IDs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
});
});
describe("isPublicDomainRoute", () => {
test("should return true for health endpoint", () => {
expect(isPublicDomainRoute("/health")).toBe(true);
@@ -313,19 +365,6 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
});
test("should return false for malformed pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/")).toBe(false);
expect(isPublicDomainRoute("/p")).toBe(false);
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
@@ -389,8 +428,6 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
@@ -406,7 +443,6 @@ describe("endpoint-validator", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
@@ -443,8 +479,6 @@ describe("endpoint-validator", () => {
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
@@ -459,8 +493,6 @@ describe("endpoint-validator", () => {
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
@@ -471,7 +503,6 @@ describe("endpoint-validator", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
@@ -480,14 +511,12 @@ describe("endpoint-validator", () => {
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
@@ -502,9 +531,6 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
@@ -556,7 +582,12 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
expect(
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
).toEqual({
environmentId: "env-123_test",
userId: "user-456_test",
});
});
});
@@ -564,7 +595,6 @@ describe("endpoint-validator", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
@@ -572,12 +602,10 @@ describe("endpoint-validator", () => {
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
@@ -587,7 +615,6 @@ describe("endpoint-validator", () => {
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
@@ -601,6 +628,15 @@ describe("endpoint-validator", () => {
const longSurveyId = "a".repeat(1000);
const longPath = `s/${longSurveyId}`;
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
const longEnvironmentId = "env" + "a".repeat(1000);
const longUserId = "user" + "b".repeat(1000);
expect(
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
).toEqual({
environmentId: longEnvironmentId,
userId: longUserId,
});
});
test("should handle empty and minimal inputs", () => {
@@ -615,6 +651,7 @@ describe("endpoint-validator", () => {
});
expect(isIntegrationRoute("")).toBe(false);
expect(isAuthProtectedRoute("")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
});
});
@@ -623,7 +660,6 @@ describe("endpoint-validator", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,

View File

@@ -43,6 +43,14 @@ export const isAuthProtectedRoute = (url: string): boolean => {
return protectedRoutes.some((route) => url.startsWith(route));
};
export const isSyncWithUserIdentificationEndpoint = (
url: string
): { environmentId: string; userId: string } | false => {
const regex = /\/api\/v1\/client\/(?<environmentId>[^/]+)\/app\/sync\/(?<userId>[^/]+)/;
const match = url.match(regex);
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
};
/**
* Check if the route should be accessible on the public domain (PUBLIC_URL)
* Uses whitelist approach - only explicitly allowed routes are accessible

View File

@@ -7,7 +7,6 @@ const PUBLIC_ROUTES = {
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
],
// API routes accessible from public domain

View File

@@ -8,7 +8,7 @@ import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessT
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { deleteFile, getFileStreamForDownload } from "@/modules/storage/service";
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { logFileDeletion } from "./lib/audit-logs";
@@ -39,25 +39,21 @@ export const GET = async (
}
}
// Stream the file directly
const streamResult = await getFileStreamForDownload(fileName, environmentId, accessType);
const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType);
if (!streamResult.ok) {
const errorResponse = getErrorResponseFromStorageError(streamResult.error, { fileName });
if (!signedUrlResult.ok) {
const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName });
return errorResponse;
}
const { body, contentType, contentLength } = streamResult.data;
return new Response(body, {
status: 200,
return new Response(null, {
status: 302,
headers: {
"Content-Type": contentType,
...(contentLength > 0 && { "Content-Length": String(contentLength) }),
Location: signedUrlResult.data,
"Cache-Control":
accessType === "private"
? "no-store, no-cache, must-revalidate"
: "public, max-age=31536000, immutable",
: "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
};

View File

@@ -9,18 +9,17 @@
"source": "en-US",
"targets": [
"de-DE",
"es-ES",
"fr-FR",
"hu-HU",
"ja-JP",
"nl-NL",
"pt-BR",
"pt-PT",
"ro-RO",
"ru-RU",
"sv-SE",
"zh-Hans-CN",
"zh-Hant-TW"
"zh-Hant-TW",
"nl-NL",
"es-ES",
"sv-SE",
"ru-RU"
]
},
"version": 1.8

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +1,59 @@
// OpenTelemetry instrumentation for Next.js - loaded via instrumentation.ts hook
// Pattern based on: ee/src/opentelemetry.ts (license server)
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
// instrumentation-node.ts
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { HostMetrics } from "@opentelemetry/host-metrics";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
import {
AlwaysOffSampler,
AlwaysOnSampler,
BatchSpanProcessor,
ParentBasedSampler,
type Sampler,
TraceIdRatioBasedSampler,
} from "@opentelemetry/sdk-trace-base";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
import { PrismaInstrumentation } from "@prisma/instrumentation";
detectResources,
envDetector,
hostDetector,
processDetector,
resourceFromAttributes,
} from "@opentelemetry/resources";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
// --- Configuration from environment ---
const serviceName = process.env.OTEL_SERVICE_NAME || "formbricks";
const serviceVersion = process.env.npm_package_version || "0.0.0";
const environment = process.env.ENVIRONMENT || process.env.NODE_ENV || "development";
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const prometheusEnabled = process.env.PROMETHEUS_ENABLED === "1";
const prometheusPort = process.env.PROMETHEUS_EXPORTER_PORT
? Number.parseInt(process.env.PROMETHEUS_EXPORTER_PORT)
: 9464;
// --- Configure OTLP exporters (conditional on endpoint being set) ---
let traceExporter: OTLPTraceExporter | undefined;
let otlpMetricExporter: OTLPMetricExporter | undefined;
if (otlpEndpoint) {
try {
// OTLPTraceExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
// and appends /v1/traces for HTTP transport
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively (W3C OTel format: key=value,key2=value2)
traceExporter = new OTLPTraceExporter();
// OTLPMetricExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
// and appends /v1/metrics for HTTP transport
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively
otlpMetricExporter = new OTLPMetricExporter();
} catch (error) {
logger.error(error, "Failed to create OTLP exporters. Telemetry will not be exported.");
}
}
// --- Configure Prometheus exporter (pull-based metrics for ServiceMonitor) ---
let prometheusExporter: PrometheusExporter | undefined;
if (prometheusEnabled) {
prometheusExporter = new PrometheusExporter({
port: prometheusPort,
endpoint: "/metrics",
host: "0.0.0.0",
});
}
// --- Build metric readers array ---
const metricReaders: (PeriodicExportingMetricReader | PrometheusExporter)[] = [];
if (otlpMetricExporter) {
metricReaders.push(
new PeriodicExportingMetricReader({
exporter: otlpMetricExporter,
exportIntervalMillis: 60000, // Export every 60 seconds
})
);
}
if (prometheusExporter) {
metricReaders.push(prometheusExporter);
}
// --- Resource attributes ---
const resourceAttributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: serviceVersion,
"deployment.environment": environment,
};
// --- Configure sampler ---
const samplerType = process.env.OTEL_TRACES_SAMPLER || "always_on";
const parsedSamplerArg = process.env.OTEL_TRACES_SAMPLER_ARG
? Number.parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG)
: undefined;
const samplerArg =
parsedSamplerArg !== undefined && !Number.isNaN(parsedSamplerArg) ? parsedSamplerArg : undefined;
let sampler: Sampler;
switch (samplerType) {
case "always_on":
sampler = new AlwaysOnSampler();
break;
case "always_off":
sampler = new AlwaysOffSampler();
break;
case "traceidratio":
sampler = new TraceIdRatioBasedSampler(samplerArg ?? 1);
break;
case "parentbased_traceidratio":
sampler = new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(samplerArg ?? 1),
});
break;
case "parentbased_always_on":
sampler = new ParentBasedSampler({
root: new AlwaysOnSampler(),
});
break;
case "parentbased_always_off":
sampler = new ParentBasedSampler({
root: new AlwaysOffSampler(),
});
break;
default:
logger.warn(`Unknown sampler type: ${samplerType}. Using always_on.`);
sampler = new AlwaysOnSampler();
}
// --- Initialize NodeSDK ---
const sdk = new NodeSDK({
sampler,
resource: resourceFromAttributes(resourceAttributes),
// When no OTLP endpoint is configured (e.g. Prometheus-only setups), pass an empty
// spanProcessors array to prevent the SDK from falling back to its default OTLP exporter
// which would attempt connections to localhost:4318 and cause noisy errors.
spanProcessors: traceExporter
? [
new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
}),
]
: [],
metricReaders: metricReaders.length > 0 ? metricReaders : undefined,
instrumentations: [
getNodeAutoInstrumentations({
// Disable noisy/unnecessary instrumentations
"@opentelemetry/instrumentation-fs": {
enabled: false,
},
"@opentelemetry/instrumentation-dns": {
enabled: false,
},
"@opentelemetry/instrumentation-net": {
enabled: false,
},
// Disable pg instrumentation - PrismaInstrumentation handles DB tracing
"@opentelemetry/instrumentation-pg": {
enabled: false,
},
"@opentelemetry/instrumentation-http": {
// Ignore health/metrics endpoints to reduce noise
ignoreIncomingRequestHook: (req) => {
const url = req.url || "";
return url === "/health" || url.startsWith("/metrics") || url === "/api/v2/health";
},
},
// Enable runtime metrics for Node.js process monitoring
"@opentelemetry/instrumentation-runtime-node": {
enabled: true,
},
}),
// Prisma instrumentation for database query tracing
new PrismaInstrumentation(),
],
const exporter = new PrometheusExporter({
port: env.PROMETHEUS_EXPORTER_PORT ? parseInt(env.PROMETHEUS_EXPORTER_PORT) : 9464,
endpoint: "/metrics",
host: "0.0.0.0", // Listen on all network interfaces
});
// Start the SDK
sdk.start();
const detectedResources = detectResources({
detectors: [envDetector, processDetector, hostDetector],
});
// --- Log initialization status ---
const enabledFeatures: string[] = [];
if (traceExporter) enabledFeatures.push("traces");
if (otlpMetricExporter) enabledFeatures.push("otlp-metrics");
if (prometheusExporter) enabledFeatures.push("prometheus-metrics");
const customResources = resourceFromAttributes({});
const samplerArgStr = process.env.OTEL_TRACES_SAMPLER_ARG || "";
const samplerArgMsg = samplerArgStr ? `, samplerArg=${samplerArgStr}` : "";
const resources = detectedResources.merge(customResources);
if (enabledFeatures.length > 0) {
logger.info(
`OpenTelemetry initialized: service=${serviceName}, version=${serviceVersion}, environment=${environment}, exporters=${enabledFeatures.join("+")}, sampler=${samplerType}${samplerArgMsg}`
);
} else {
logger.info(
`OpenTelemetry initialized (no exporters): service=${serviceName}, version=${serviceVersion}, environment=${environment}`
);
}
const meterProvider = new MeterProvider({
readers: [exporter],
resource: resources,
});
// --- Graceful shutdown ---
// Run before other SIGTERM listeners (logger flush, etc.) so spans are drained first.
process.prependListener("SIGTERM", async () => {
const hostMetrics = new HostMetrics({
name: `otel-metrics`,
meterProvider,
});
registerInstrumentations({
meterProvider,
instrumentations: [new HttpInstrumentation(), new RuntimeNodeInstrumentation()],
});
hostMetrics.start();
process.on("SIGTERM", async () => {
try {
await sdk.shutdown();
// Stop collecting metrics or flush them if needed
await meterProvider.shutdown();
// Possibly close other instrumentation resources
} catch (e) {
logger.error(e, "Error during OpenTelemetry shutdown");
logger.error(e, "Error during graceful shutdown");
} finally {
process.exit(0);
}
});

View File

@@ -5,13 +5,10 @@ export const onRequestError = Sentry.captureRequestError;
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Load OpenTelemetry instrumentation when Prometheus metrics or OTLP export is enabled
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
if (PROMETHEUS_ENABLED) {
await import("./instrumentation-node");
}
}
// Sentry init loads after OTEL to avoid TracerProvider conflicts
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) {
await import("./sentry.server.config");
}

View File

@@ -165,20 +165,19 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = [
"de-DE",
"en-US",
"es-ES",
"fr-FR",
"hu-HU",
"ja-JP",
"nl-NL",
"de-DE",
"pt-BR",
"fr-FR",
"nl-NL",
"zh-Hant-TW",
"pt-PT",
"ro-RO",
"ru-RU",
"sv-SE",
"ja-JP",
"zh-Hans-CN",
"zh-Hant-TW",
"es-ES",
"sv-SE",
"ru-RU",
];
// Billing constants

View File

@@ -1,10 +1,9 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
@@ -24,12 +23,13 @@ export const getDisplayCountBySurveyId = reactCache(
const displayCount = await prisma.display.count({
where: {
surveyId: surveyId,
...(filters?.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
...(filters &&
filters.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
},
});
return displayCount;
@@ -42,97 +42,6 @@ export const getDisplayCountBySurveyId = reactCache(
}
);
export const getDisplaysByContactId = reactCache(
async (contactId: string): Promise<Pick<TDisplay, "id" | "createdAt" | "surveyId">[]> => {
validateInputs([contactId, ZId]);
try {
const displays = await prisma.display.findMany({
where: { contactId },
select: {
id: true,
createdAt: true,
surveyId: true,
},
orderBy: { createdAt: "desc" },
});
return displays;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getDisplaysBySurveyIdWithContact = reactCache(
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
validateInputs(
[surveyId, ZId],
[limit, z.number().int().min(1).optional()],
[offset, z.number().int().nonnegative().optional()]
);
try {
const displays = await prisma.display.findMany({
where: {
surveyId,
contactId: { not: null },
},
select: {
id: true,
createdAt: true,
surveyId: true,
contact: {
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: { in: ["email", "userId"] },
},
},
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return displays.map((display) => ({
id: display.id,
createdAt: display.createdAt,
surveyId: display.surveyId,
contact: display.contact
? {
id: display.contact.id,
attributes: display.contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
),
}
: null,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {

View File

@@ -1,219 +0,0 @@
import { mockDisplayId, mockSurveyId } from "./__mocks__/data.mock";
import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockDisplaysForContact = [
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
},
];
const mockDisplaysWithContact = [
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: {
id: mockContactId,
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "userId" }, value: "user-123" },
],
},
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
contact: {
id: "clqnj99r9000008lebgf8734k",
attributes: [{ attributeKey: { key: "userId" }, value: "user-456" }],
},
},
];
describe("getDisplaysByContactId", () => {
describe("Happy Path", () => {
test("returns displays for a contact ordered by createdAt desc", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysForContact as any);
const result = await getDisplaysByContactId(mockContactId);
expect(result).toEqual(mockDisplaysForContact);
expect(prisma.display.findMany).toHaveBeenCalledWith({
where: { contactId: mockContactId },
select: {
id: true,
createdAt: true,
surveyId: true,
},
orderBy: { createdAt: "desc" },
});
});
test("returns empty array when contact has no displays", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
const result = await getDisplaysByContactId(mockContactId);
expect(result).toEqual([]);
});
});
describe("Sad Path", () => {
test("throws a ValidationError if the contactId is invalid", async () => {
await expect(getDisplaysByContactId("not-a-cuid")).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(DatabaseError);
});
test("throws generic Error for other exceptions", async () => {
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(Error);
});
});
});
describe("getDisplaysBySurveyIdWithContact", () => {
describe("Happy Path", () => {
test("returns displays with contact attributes transformed", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysWithContact as any);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
expect(result).toEqual([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: {
id: mockContactId,
attributes: { email: "test@example.com", userId: "user-123" },
},
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
contact: {
id: "clqnj99r9000008lebgf8734k",
attributes: { userId: "user-456" },
},
},
]);
});
test("calls prisma with correct where clause and pagination", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
expect(prisma.display.findMany).toHaveBeenCalledWith({
where: {
surveyId: mockSurveyId,
contactId: { not: null },
},
select: {
id: true,
createdAt: true,
surveyId: true,
contact: {
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: { in: ["email", "userId"] },
},
},
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
take: 15,
skip: 0,
});
});
test("returns empty array when no displays found", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
expect(result).toEqual([]);
});
test("handles display with null contact", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: null,
},
] as any);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
expect(result).toEqual([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: null,
},
]);
});
});
describe("Sad Path", () => {
test("throws a ValidationError if the surveyId is invalid", async () => {
await expect(getDisplaysBySurveyIdWithContact("not-a-cuid")).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(DatabaseError);
});
test("throws generic Error for other exceptions", async () => {
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(Error);
});
});
});

View File

@@ -55,6 +55,7 @@ export const env = createEnv({
OIDC_DISPLAY_NAME: z.string().optional(),
OIDC_ISSUER: z.string().optional(),
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL:
process.env.NODE_ENV === "test"
? z.string().optional()
@@ -173,6 +174,7 @@ export const env = createEnv({
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,

View File

@@ -167,12 +167,6 @@ export const createEnvironment = async (
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
],
},
},

View File

@@ -1,6 +0,0 @@
/**
* Error codes returned by Google Sheets integration.
* Use these constants when comparing error responses to avoid typos and enable reuse.
*/
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";

View File

@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZString } from "@formbricks/types/common";
import {
AuthenticationError,
DatabaseError,
OperationNotAllowedError,
UnknownError,
} from "@formbricks/types/errors";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
@@ -16,12 +11,8 @@ import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
GOOGLE_SHEET_MESSAGE_LIMIT,
} from "@/lib/constants";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { truncateText } from "../utils/strings";
import { validateInputs } from "../utils/validate";
@@ -90,17 +81,6 @@ export const writeData = async (
}
};
export const validateGoogleSheetsConnection = async (
googleSheetIntegrationData: TIntegrationGoogleSheets
): Promise<void> => {
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
const integrationData = structuredClone(googleSheetIntegrationData);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
await authorize(integrationData);
};
export const getSpreadsheetNameById = async (
googleSheetIntegrationData: TIntegrationGoogleSheets,
spreadsheetId: string
@@ -114,17 +94,7 @@ export const getSpreadsheetNameById = async (
return new Promise((resolve, reject) => {
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
if (err) {
const msg = err.message?.toLowerCase() ?? "";
const isPermissionError =
msg.includes("permission") ||
msg.includes("caller does not have") ||
msg.includes("insufficient permission") ||
msg.includes("access denied");
if (isPermissionError) {
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
} else {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
}
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
return;
}
const spreadsheetTitle = response.data.properties.title;
@@ -139,70 +109,26 @@ export const getSpreadsheetNameById = async (
}
};
const isInvalidGrantError = (error: unknown): boolean => {
const err = error as { message?: string; response?: { data?: { error?: string } } };
return (
typeof err?.message === "string" &&
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
);
};
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
/**
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
* Returns true if token is valid, false if invalid/revoked.
*/
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
try {
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
return res.ok;
} catch {
return false;
}
};
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const key = googleSheetIntegrationData.config.key;
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
oAuth2Client.setCredentials({
refresh_token,
});
const { credentials } = await oAuth2Client.refreshAccessToken();
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: credentials,
},
});
const hasStoredCredentials =
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
oAuth2Client.setCredentials(credentials);
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
oAuth2Client.setCredentials(key);
return oAuth2Client;
}
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
try {
const { credentials } = await oAuth2Client.refreshAccessToken();
const mergedCredentials = {
...credentials,
refresh_token: credentials.refresh_token ?? key.refresh_token,
};
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: mergedCredentials,
},
});
oAuth2Client.setCredentials(mergedCredentials);
return oAuth2Client;
} catch (error) {
if (isInvalidGrantError(error)) {
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
}
throw error;
}
return oAuth2Client;
};

View File

@@ -126,12 +126,6 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
};
export const appLanguages = [
{
code: "de-DE",
label: {
"en-US": "German",
},
},
{
code: "en-US",
label: {
@@ -139,9 +133,15 @@ export const appLanguages = [
},
},
{
code: "es-ES",
code: "de-DE",
label: {
"en-US": "Spanish",
"en-US": "German",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
},
},
{
@@ -151,27 +151,9 @@ export const appLanguages = [
},
},
{
code: "hu-HU",
code: "zh-Hant-TW",
label: {
"en-US": "Hungarian",
},
},
{
code: "ja-JP",
label: {
"en-US": "Japanese",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
"en-US": "Chinese (Traditional)",
},
},
{
@@ -187,15 +169,9 @@ export const appLanguages = [
},
},
{
code: "ru-RU",
code: "ja-JP",
label: {
"en-US": "Russian",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
"en-US": "Japanese",
},
},
{
@@ -205,9 +181,27 @@ export const appLanguages = [
},
},
{
code: "zh-Hant-TW",
code: "nl-NL",
label: {
"en-US": "Chinese (Traditional)",
"en-US": "Dutch",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
},
},
{
code: "ru-RU",
label: {
"en-US": "Russian",
},
},
];

View File

@@ -48,7 +48,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -106,7 +106,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -171,7 +171,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -196,7 +196,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -250,7 +250,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -324,7 +324,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -378,7 +378,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -403,7 +403,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -448,7 +448,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,

View File

@@ -22,7 +22,7 @@ const selectProject = {
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,

View File

@@ -22,7 +22,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -409,10 +408,9 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
resolvedResponses,
responses,
elements,
userAttributes,
hiddenFields,

View File

@@ -1,6 +1,5 @@
// https://github.com/airbnb/javascript/#naming--uppercase
import { TProjectStyling } from "@formbricks/types/project";
import { isLight, mixColor } from "@/lib/utils/colors";
export const COLOR_DEFAULTS = {
brandColor: "#64748b",
@@ -12,210 +11,32 @@ export const COLOR_DEFAULTS = {
highlightBorderColor: "#64748b",
} as const;
const DEFAULT_BRAND_COLOR = "#64748b";
/**
* Derives a complete set of suggested color values from a single brand color.
*
* Used by the project-level "Suggest Colors" button **and** to build
* `STYLE_DEFAULTS` so that a fresh install always has colours that are
* visually cohesive with the default brand.
*
* The returned object is a flat map of form-field paths to values so it
* can be spread directly into form defaults or applied via `form.setValue`.
*/
export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) => {
// Question / dark text: brand darkened with black (visible brand tint)
const questionColor = mixColor(brandColor, "#000000", 0.35);
// Input / option background: white with noticeable brand tint
const inputBg = mixColor(brandColor, "#ffffff", 0.92);
// Input border: visible brand-tinted border
const inputBorder = mixColor(brandColor, "#ffffff", 0.6);
// Card tones
const cardBg = mixColor(brandColor, "#ffffff", 0.97);
const cardBorder = mixColor(brandColor, "#ffffff", 0.8);
// Page background
const pageBg = mixColor(brandColor, "#ffffff", 0.855);
return {
// General
"brandColor.light": brandColor,
"questionColor.light": questionColor,
// Headlines & Descriptions — use questionColor to match the legacy behaviour
// where all text elements derived their color from questionColor.
"elementHeadlineColor.light": questionColor,
"elementDescriptionColor.light": questionColor,
"elementUpperLabelColor.light": questionColor,
// Buttons — use the brand color so the button matches the user's intent.
"buttonBgColor.light": brandColor,
"buttonTextColor.light": isLight(brandColor) ? "#0f172a" : "#ffffff",
// Inputs
"inputColor.light": inputBg,
"inputBorderColor.light": inputBorder,
"inputTextColor.light": questionColor,
// Options (Radio / Checkbox)
"optionBgColor.light": inputBg,
"optionLabelColor.light": questionColor,
// Card
"cardBackgroundColor.light": cardBg,
"cardBorderColor.light": cardBorder,
// Highlight / Focus
"highlightBorderColor.light": mixColor(brandColor, "#ffffff", 0.25),
// Progress Bar — indicator uses the brand color; track is a lighter tint.
"progressIndicatorBgColor.light": brandColor,
"progressTrackBgColor.light": mixColor(brandColor, "#ffffff", 0.8),
// Background
background: { bg: pageBg, bgType: "color" as const, brightness: 100 },
};
};
// Pre-compute colors derived from the default brand color.
const _colors = getSuggestedColors(DEFAULT_BRAND_COLOR);
/**
* Single source of truth for every styling default.
*
* Color values are derived from the default brand color (#64748b) via
* `getSuggestedColors()`. Non-color values (dimensions, weights, sizes)
* are hardcoded here and must be kept in sync with globals.css.
*
* Used everywhere: form defaults, preview rendering, email templates,
* and as the reset target for "Restore defaults".
*/
export const STYLE_DEFAULTS: TProjectStyling = {
export const defaultStyling: TProjectStyling = {
allowStyleOverwrite: true,
brandColor: { light: _colors["brandColor.light"] },
questionColor: { light: _colors["questionColor.light"] },
inputColor: { light: _colors["inputColor.light"] },
inputBorderColor: { light: _colors["inputBorderColor.light"] },
cardBackgroundColor: { light: _colors["cardBackgroundColor.light"] },
cardBorderColor: { light: _colors["cardBorderColor.light"] },
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
isLogoHidden: false,
highlightBorderColor: { light: _colors["highlightBorderColor.light"] },
highlightBorderColor: undefined,
isDarkModeEnabled: false,
roundness: 8,
cardArrangement: { linkSurveys: "simple", appSurveys: "simple" },
// Headlines & Descriptions
elementHeadlineColor: { light: _colors["elementHeadlineColor.light"] },
elementHeadlineFontSize: 16,
elementHeadlineFontWeight: 600,
elementDescriptionColor: { light: _colors["elementDescriptionColor.light"] },
elementDescriptionFontSize: 14,
elementDescriptionFontWeight: 400,
elementUpperLabelColor: { light: _colors["elementUpperLabelColor.light"] },
elementUpperLabelFontSize: 12,
elementUpperLabelFontWeight: 400,
// Inputs
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 20,
inputFontSize: 14,
inputPaddingX: 8,
inputPaddingY: 8,
inputPlaceholderOpacity: 0.5,
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
// Buttons
buttonBgColor: { light: _colors["buttonBgColor.light"] },
buttonTextColor: { light: _colors["buttonTextColor.light"] },
buttonBorderRadius: 8,
buttonHeight: "auto",
buttonFontSize: 16,
buttonFontWeight: 500,
buttonPaddingX: 12,
buttonPaddingY: 12,
// Options
optionBgColor: { light: _colors["optionBgColor.light"] },
optionLabelColor: { light: _colors["optionLabelColor.light"] },
optionBorderRadius: 8,
optionPaddingX: 16,
optionPaddingY: 16,
optionFontSize: 14,
// Progress Bar
progressTrackHeight: 8,
progressTrackBgColor: { light: _colors["progressTrackBgColor.light"] },
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
/**
* Builds a complete TProjectStyling object from a single brand color.
*
* Uses STYLE_DEFAULTS for all non-color properties (dimensions, weights, etc.)
* and derives every color from the given brand color via getSuggestedColors().
*
* Useful when only a brand color is known (e.g. onboarding) and a fully
* coherent styling object is needed for both preview rendering and persistence.
*/
export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_COLOR): TProjectStyling => {
const colors = getSuggestedColors(brandColor);
return {
...STYLE_DEFAULTS,
brandColor: { light: colors["brandColor.light"] },
questionColor: { light: colors["questionColor.light"] },
elementHeadlineColor: { light: colors["elementHeadlineColor.light"] },
elementDescriptionColor: { light: colors["elementDescriptionColor.light"] },
elementUpperLabelColor: { light: colors["elementUpperLabelColor.light"] },
buttonBgColor: { light: colors["buttonBgColor.light"] },
buttonTextColor: { light: colors["buttonTextColor.light"] },
inputColor: { light: colors["inputColor.light"] },
inputBorderColor: { light: colors["inputBorderColor.light"] },
inputTextColor: { light: colors["inputTextColor.light"] },
optionBgColor: { light: colors["optionBgColor.light"] },
optionLabelColor: { light: colors["optionLabelColor.light"] },
cardBackgroundColor: { light: colors["cardBackgroundColor.light"] },
cardBorderColor: { light: colors["cardBorderColor.light"] },
highlightBorderColor: { light: colors["highlightBorderColor.light"] },
progressIndicatorBgColor: { light: colors["progressIndicatorBgColor.light"] },
progressTrackBgColor: { light: colors["progressTrackBgColor.light"] },
background: colors.background,
};
cardArrangement: {
linkSurveys: "straight",
appSurveys: "straight",
},
};

View File

@@ -85,7 +85,7 @@ export const mockProject: TProject = {
inAppSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
config: {
@@ -168,7 +168,6 @@ export const mockContactAttributeKey: TContactAttributeKey = {
type: "custom",
description: "mock action class",
isUnique: false,
dataType: "string",
...commonMockProperties,
};

View File

@@ -141,68 +141,5 @@ describe("Time Utilities", () => {
expect(convertDatesInObject("string")).toBe("string");
expect(convertDatesInObject(123)).toBe(123);
});
test("should not convert dates in ignored keys when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
email: "test@example.com",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
expect(result.contactAttributes.email).toBe("test@example.com");
});
test("should not convert dates in variables when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
updatedAt: "2024-03-20T15:30:00",
variables: {
createdAt: "2024-03-20T16:30:00",
userId: "123",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
expect(result.variables.userId).toBe("123");
});
test("should not convert dates in data or meta when keysToIgnore is provided", () => {
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
const input = {
createdAt: "2024-03-20T15:30:00",
data: {
createdAt: "2024-03-20T16:30:00",
},
meta: {
updatedAt: "2024-03-20T17:30:00",
},
};
const result = convertDatesInObject(input, keysToIgnore);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
});
test("should recurse into all keys when keysToIgnore is not provided", () => {
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
},
};
const result = convertDatesInObject(input);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBeInstanceOf(Date);
});
});
});

View File

@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -87,30 +87,28 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return de;
case "en-US":
return enUS;
case "es-ES":
return es;
case "fr-FR":
return fr;
case "hu-HU":
return hu;
case "ja-JP":
return ja;
case "nl-NL":
return nl;
case "pt-BR":
return ptBR;
case "fr-FR":
return fr;
case "nl-NL":
return nl;
case "sv-SE":
return sv;
case "zh-Hant-TW":
return zhTW;
case "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "ja-JP":
return ja;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
case "es-ES":
return es;
case "ru-RU":
return ru;
}
};
@@ -151,20 +149,16 @@ export const getTodaysDateTimeFormatted = (seperator: string) => {
return [formattedDate, formattedTime].join(seperator);
};
export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T => {
export const convertDatesInObject = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") {
return obj; // Return if obj is not an object
}
if (Array.isArray(obj)) {
// Handle arrays by mapping each element through the function
return obj.map((item) => convertDatesInObject(item, keysToIgnore)) as unknown as T;
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
}
const newObj: Record<string, unknown> = {};
const newObj: any = {};
for (const key in obj) {
if (keysToIgnore?.has(key)) {
newObj[key] = obj[key];
continue;
}
if (
(key === "createdAt" || key === "updatedAt") &&
typeof obj[key] === "string" &&
@@ -172,10 +166,10 @@ export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T =
) {
newObj[key] = new Date(obj[key] as unknown as string);
} else if (typeof obj[key] === "object" && obj[key] !== null) {
newObj[key] = convertDatesInObject(obj[key], keysToIgnore);
newObj[key] = convertDatesInObject(obj[key]);
} else {
newObj[key] = obj[key];
}
}
return newObj as T;
return newObj;
};

View File

@@ -1,261 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE } from "next-safe-action";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
AuthenticationError,
AuthorizationError,
EXPECTED_ERROR_NAMES,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UnknownError,
ValidationError,
isExpectedError,
} from "@formbricks/types/errors";
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Mock logger — use plain functions for chained calls so vi.resetAllMocks() doesn't break them
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: () => ({ error: vi.fn() }),
warn: vi.fn(),
},
}));
// Mock next-auth
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
// Mock authOptions
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
// Mock user service
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
// Mock client IP
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: vi.fn(),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
}));
// Mock audit log types
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
// ── shared helper tests (pure logic, no action client needed) ──────────
describe("isExpectedError (shared helper)", () => {
test("EXPECTED_ERROR_NAMES contains exactly the right error names", () => {
const expected = [
"ResourceNotFoundError",
"AuthorizationError",
"InvalidInputError",
"ValidationError",
"AuthenticationError",
"OperationNotAllowedError",
"TooManyRequestsError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
for (const name of expected) {
expect(EXPECTED_ERROR_NAMES.has(name)).toBe(true);
}
});
test.each([
{ ErrorClass: AuthorizationError, args: ["Not authorized"] },
{ ErrorClass: AuthenticationError, args: ["Not authenticated"] },
{ ErrorClass: TooManyRequestsError, args: ["Rate limit exceeded"] },
{ ErrorClass: ResourceNotFoundError, args: ["Survey", "123"] },
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
});
test("returns true for serialised errors that only have a matching name", () => {
const serialisedError = new Error("Auth failed");
serialisedError.name = "AuthorizationError";
expect(isExpectedError(serialisedError)).toBe(true);
});
test.each([
{ error: new Error("Something broke"), label: "Error" },
{ error: new TypeError("Cannot read properties"), label: "TypeError" },
{ error: new RangeError("Maximum call stack"), label: "RangeError" },
{ error: new UnknownError("Unknown"), label: "UnknownError" },
])("returns false for $label", ({ error }) => {
expect(isExpectedError(error)).toBe(false);
});
});
// ── integration tests against the real actionClient / authenticatedActionClient ──
describe("actionClient handleServerError", () => {
// Lazily import so mocks are in place first
let actionClient: (typeof import("./index"))["actionClient"];
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import("./index");
actionClient = mod.actionClient;
});
afterEach(() => {
vi.clearAllMocks();
});
// Helper: create and execute an action that throws the given error
const executeThrowingAction = async (error: Error) => {
const action = actionClient.action(async () => {
throw error;
});
return action();
};
describe("expected errors should NOT be reported to Sentry", () => {
test("AuthorizationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new AuthorizationError("Not authorized"));
expect(result?.serverError).toBe("Not authorized");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("AuthenticationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new AuthenticationError("Not authenticated"));
expect(result?.serverError).toBe("Not authenticated");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("TooManyRequestsError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new TooManyRequestsError("Rate limit exceeded"));
expect(result?.serverError).toBe("Rate limit exceeded");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ResourceNotFoundError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ResourceNotFoundError("Survey", "123"));
expect(result?.serverError).toContain("Survey");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("InvalidInputError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new InvalidInputError("Invalid input"));
expect(result?.serverError).toBe("Invalid input");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ValidationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ValidationError("Invalid data"));
expect(result?.serverError).toBe("Invalid data");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("OperationNotAllowedError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new OperationNotAllowedError("Not allowed"));
expect(result?.serverError).toBe("Not allowed");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe("unexpected errors SHOULD be reported to Sentry", () => {
test("generic Error is sent to Sentry and returns default message", async () => {
const error = new Error("Something broke");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
test("TypeError is sent to Sentry and returns default message", async () => {
const error = new TypeError("Cannot read properties of undefined");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
test("UnknownError is sent to Sentry (not an expected business-logic error)", async () => {
const error = new UnknownError("Unknown error");
const result = await executeThrowingAction(error);
expect(result?.serverError).toBe(DEFAULT_SERVER_ERROR_MESSAGE);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({ extra: expect.any(Object) })
);
});
});
});
describe("authenticatedActionClient", () => {
let authenticatedActionClient: (typeof import("./index"))["authenticatedActionClient"];
let getUser: (typeof import("@/lib/user/service"))["getUser"];
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import("./index");
authenticatedActionClient = mod.authenticatedActionClient;
const userService = await import("@/lib/user/service");
getUser = userService.getUser;
});
afterEach(() => {
vi.clearAllMocks();
});
test("throws AuthenticationError when there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const action = authenticatedActionClient.action(async () => "ok");
const result = await action();
// handleServerError catches AuthenticationError and returns its message
expect(result?.serverError).toBe("Not authenticated");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("throws AuthorizationError when user is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
vi.mocked(getUser).mockResolvedValue(null as any);
const action = authenticatedActionClient.action(async () => "ok");
const result = await action();
expect(result?.serverError).toBe("User not found");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("executes action successfully when session and user exist", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
vi.mocked(getUser).mockResolvedValue({ id: "user-1", name: "Test" } as any);
const action = authenticatedActionClient.action(async () => "success");
const result = await action();
expect(result?.data).toBe("success");
expect(result?.serverError).toBeUndefined();
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});

View File

@@ -3,7 +3,15 @@ import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
import { AuthenticationError, AuthorizationError, isExpectedError } from "@formbricks/types/errors";
import {
AuthenticationError,
AuthorizationError,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UnknownError,
} from "@formbricks/types/errors";
import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
@@ -14,18 +22,24 @@ import { ActionClientCtx } from "./types/context";
export const actionClient = createSafeActionClient({
handleServerError(e, utils) {
const eventId = (utils.ctx as Record<string, any>)?.auditLoggingCtx?.eventId ?? undefined; // keep explicit fallback
if (isExpectedError(e)) {
return e.message;
}
// Only capture unexpected errors to Sentry
Sentry.captureException(e, {
extra: {
eventId,
},
});
if (
e instanceof ResourceNotFoundError ||
e instanceof AuthorizationError ||
e instanceof InvalidInputError ||
e instanceof UnknownError ||
e instanceof AuthenticationError ||
e instanceof OperationNotAllowedError ||
e instanceof TooManyRequestsError
) {
return e.message;
}
// eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors
logger.withContext({ eventId }).error(e, "SERVER ERROR");
return DEFAULT_SERVER_ERROR_MESSAGE;

View File

@@ -11,16 +11,3 @@ export const isSafeIdentifier = (value: string): boolean => {
// Can only contain lowercase letters, numbers, and underscores
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
* "api_key" -> "Api Key"
* "signup_date" -> "Signup Date"
*/
export const formatSnakeCaseToTitleCase = (key: string): string => {
return key
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};

View File

@@ -12,18 +12,11 @@ export function validateInputs<T extends ValidationPair<any>[]>(
for (const [value, schema] of pairs) {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
const zodDetails = inputValidation.error.issues
.map((issue) => {
const path = issue?.path?.join(".") ?? "";
return `${path}${issue.message}`;
})
.join("; ");
logger.error(
inputValidation.error,
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
);
throw new ValidationError(`Validation failed: ${zodDetails}`);
throw new ValidationError("Validation failed");
}
parsedData.push(inputValidation.data);
}

View File

@@ -188,7 +188,6 @@
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"date": "Datum",
"days": "Tage",
"default": "Standard",
"delete": "Löschen",
"description": "Beschreibung",
@@ -218,7 +217,6 @@
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
"error_loading_data": "Fehler beim Laden der Daten",
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern",
@@ -277,7 +275,6 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
"months": "Monate",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
@@ -288,7 +285,6 @@
"no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen",
"no_overlay": "Kein Overlay",
"no_quotas_found": "Keine Kontingente gefunden",
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
@@ -315,7 +311,6 @@
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
"paused": "Pausiert",
@@ -355,7 +350,6 @@
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
@@ -396,7 +390,6 @@
"status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"string": "Text",
"styling": "Styling",
"submit": "Abschicken",
"summary": "Zusammenfassung",
@@ -429,7 +422,6 @@
"top_right": "Oben rechts",
"try_again": "Versuch's nochmal",
"type": "Typ",
"unknown_survey": "Unbekannte Umfrage",
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
"update": "Aktualisierung",
"updated": "Aktualisiert",
@@ -453,7 +445,6 @@
"website_and_app_connection": "Website & App Verbindung",
"website_app_survey": "Website- & App-Umfrage",
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
@@ -464,7 +455,6 @@
"workspace_not_found": "Projekt nicht gefunden",
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
@@ -636,45 +626,28 @@
"attribute_updated_successfully": "Attribut erfolgreich aktualisiert",
"attribute_value": "Wert",
"attribute_value_placeholder": "Attributwert",
"attributes_msg_attribute_limit_exceeded": "Es konnten {count} neue Attribute nicht erstellt werden, da dies das maximale Limit von {limit} Attributklassen überschreiten würde. Bestehende Attribute wurden erfolgreich aktualisiert.",
"attributes_msg_attribute_type_validation_error": "{error} (Attribut '{key}' hat dataType: {dataType})",
"attributes_msg_email_already_exists": "Die E-Mail existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder userId ist erforderlich. Die bestehenden Werte wurden beibehalten.",
"attributes_msg_new_attribute_created": "Neues Attribut '{key}' mit Typ '{dataType}' erstellt",
"attributes_msg_userid_already_exists": "Die userId existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen",
"create_key": "Schlüssel erstellen",
"create_new_attribute": "Neues Attribut erstellen",
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
"custom_attributes": "Benutzerdefinierte Attribute",
"data_type": "Datentyp",
"data_type_cannot_be_changed": "Der Datentyp kann nach der Erstellung nicht mehr geändert werden",
"data_type_description": "Wähle aus, wie dieses Attribut gespeichert und gefiltert werden soll",
"date_value_required": "Ein Datumswert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen, wenn du kein Datum festlegen möchtest.",
"delete_attribute_confirmation": "{value, plural, one {Dadurch wird das ausgewählte Attribut gelöscht. Alle mit diesem Attribut verknüpften Kontaktdaten gehen verloren.} other {Dadurch werden die ausgewählten Attribute gelöscht. Alle mit diesen Attributen verknüpften Kontaktdaten gehen verloren.}}",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.} other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesen Kontakten verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn diesen Kontakten Antworten haben, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
"displays": "Anzeigen",
"edit_attribute": "Attribut bearbeiten",
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
"edit_attribute_values": "Attribute bearbeiten",
"edit_attribute_values_description": "Ändern Sie die Werte für bestimmte Attribute dieses Kontakts.",
"edit_attributes": "Attribute bearbeiten",
"edit_attributes_success": "Kontaktattribute erfolgreich aktualisiert",
"generate_personal_link": "Persönlichen Link generieren",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_activity_yet": "Noch keine Aktivität",
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
"no_published_surveys": "Keine veröffentlichten Umfragen",
"no_responses_found": "Keine Antworten gefunden",
"not_provided": "Nicht angegeben",
"number_value_required": "Zahlenwert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen.",
"personal_link_generated": "Persönlicher Link erfolgreich generiert",
"personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}",
"personal_survey_link": "Link zur persönlichen Umfrage",
@@ -683,24 +656,13 @@
"search_contact": "Kontakt suchen",
"select_a_survey": "Wähle eine Umfrage aus",
"select_attribute": "Attribut auswählen",
"select_attribute_key": "Attributschlüssel auswählen",
"survey_viewed": "Umfrage angesehen",
"survey_viewed_at": "Angesehen am",
"system_attributes": "Systemattribute",
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
"upload_contacts_error_attribute_type_mismatch": "Attribut \"{key}\" ist als \"{dataType}\" definiert, aber die CSV-Datei enthält ungültige Werte: {values}",
"upload_contacts_error_duplicate_mappings": "Doppelte Zuordnungen für folgende Attribute gefunden: {attributes}",
"upload_contacts_error_file_too_large": "Dateigröße überschreitet das maximale Limit von 800KB",
"upload_contacts_error_generic": "Beim Hochladen der Kontakte ist ein Fehler aufgetreten. Bitte versuche es später erneut.",
"upload_contacts_error_invalid_file_type": "Bitte lade eine CSV-Datei hoch",
"upload_contacts_error_no_valid_contacts": "Die hochgeladene CSV-Datei enthält keine gültigen Kontakte. Bitte schaue dir die Beispiel-CSV-Datei für das richtige Format an.",
"upload_contacts_modal_attribute_header": "Formbricks-Attribut",
"upload_contacts_modal_attributes_description": "Ordne die Spalten in deiner CSV den Attributen in Formbricks zu.",
"upload_contacts_modal_attributes_new": "Neues Attribut",
"upload_contacts_modal_attributes_search_or_add": "Attribut suchen oder hinzufügen",
"upload_contacts_modal_attributes_should_be_mapped_to": "sollte zugeordnet werden zu",
"upload_contacts_modal_attributes_title": "Attribute",
"upload_contacts_modal_csv_column_header": "CSV-Spalte",
"upload_contacts_modal_description": "Lade eine CSV hoch, um Kontakte mit Attributen schnell zu importieren",
"upload_contacts_modal_download_example_csv": "Beispiel-CSV herunterladen",
"upload_contacts_modal_duplicates_description": "Wie sollen wir vorgehen, wenn ein Kontakt bereits existiert?",
@@ -757,12 +719,7 @@
"link_google_sheet": "Tabelle verlinken",
"link_new_sheet": "Neues Blatt verknüpfen",
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Google Sheets-Verbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"spreadsheet_permission_error": "Du hast keine Berechtigung, auf diese Tabelle zuzugreifen. Bitte stelle sicher, dass die Tabelle mit deinem Google-Konto geteilt ist und du Schreibzugriff auf die Tabelle hast.",
"spreadsheet_url": "Tabellen-URL",
"token_expired_error": "Das Google Sheets-Aktualisierungstoken ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut."
"spreadsheet_url": "Tabellen-URL"
},
"include_created_at": "Erstellungsdatum einbeziehen",
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
@@ -886,40 +843,6 @@
"no_attributes_yet": "Noch keine Attribute",
"no_filters_yet": "Es gibt noch keine Filter",
"no_segments_yet": "Du hast momentan keine gespeicherten Segmente.",
"operator_contains": "enthält",
"operator_does_not_contain": "enthält nicht",
"operator_ends_with": "endet mit",
"operator_is_after": "ist nach",
"operator_is_before": "ist vor",
"operator_is_between": "ist zwischen",
"operator_is_newer_than": "ist neuer als",
"operator_is_not_set": "ist nicht festgelegt",
"operator_is_older_than": "ist älter als",
"operator_is_same_day": "ist am selben Tag",
"operator_is_set": "ist festgelegt",
"operator_starts_with": "fängt an mit",
"operator_title_contains": "Enthält",
"operator_title_does_not_contain": "Enthält nicht",
"operator_title_ends_with": "Endet mit",
"operator_title_equals": "Gleich",
"operator_title_greater_equal": "Größer als oder gleich",
"operator_title_greater_than": "Größer als",
"operator_title_is_after": "Ist nach",
"operator_title_is_before": "Ist vor",
"operator_title_is_between": "Ist zwischen",
"operator_title_is_newer_than": "Ist neuer als",
"operator_title_is_not_set": "Ist nicht festgelegt",
"operator_title_is_older_than": "Ist älter als",
"operator_title_is_same_day": "Ist am selben Tag",
"operator_title_is_set": "Ist festgelegt",
"operator_title_less_equal": "Kleiner oder gleich",
"operator_title_less_than": "Kleiner als",
"operator_title_not_equals": "Ist nicht gleich",
"operator_title_starts_with": "Fängt an mit",
"operator_title_user_is_in": "Nutzer ist in",
"operator_title_user_is_not_in": "Nutzer ist nicht in",
"operator_user_is_in": "Nutzer ist in",
"operator_user_is_not_in": "Nutzer ist nicht in",
"person_and_attributes": "Person & Attribute",
"phone": "Handy",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Bitte entferne das Segment aus diesen Umfragen, um es zu löschen.",
@@ -944,7 +867,6 @@
"user_targeting_is_currently_only_available_when": "Benutzerzielgruppen sind derzeit nur verfügbar, wenn",
"value_cannot_be_empty": "Wert darf nicht leer sein.",
"value_must_be_a_number": "Wert muss eine Zahl sein.",
"value_must_be_positive": "Wert muss eine positive Zahl sein.",
"view_filters": "Filter anzeigen",
"where": "Wo",
"with_the_formbricks_sdk": "mit dem Formbricks SDK"
@@ -1031,32 +953,19 @@
"enterprise_features": "Unternehmensfunktionen",
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
"license_status": "Lizenzstatus",
"license_status_active": "Aktiv",
"license_status_description": "Status deiner Enterprise-Lizenz.",
"license_status_expired": "Abgelaufen",
"license_status_invalid": "Ungültige Lizenz",
"license_status_unreachable": "Nicht erreichbar",
"license_unreachable_grace_period": "Der Lizenzserver ist nicht erreichbar. Deine Enterprise-Funktionen bleiben während einer 3-tägigen Kulanzfrist bis zum {gracePeriodEnd} aktiv.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Ganz unkompliziert: Fordere eine kostenlose 30-Tage-Testlizenz an, um alle Funktionen zu testen, indem Du dieses Formular ausfüllst:",
"no_credit_card_no_sales_call_just_test_it": "Keine Kreditkarte. Kein Verkaufsgespräch. Einfach testen :)",
"on_request": "Auf Anfrage",
"organization_roles": "Organisationsrollen (Admin, Editor, Entwickler, etc.)",
"questions_please_reach_out_to": "Fragen? Bitte melde Dich bei",
"recheck_license": "Lizenz erneut prüfen",
"recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.",
"recheck_license_invalid": "Der Lizenzschlüssel ist ungültig. Bitte überprüfe deinen ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Lizenzprüfung erfolgreich",
"recheck_license_unreachable": "Lizenzserver ist nicht erreichbar. Bitte versuche es später erneut.",
"rechecking": "Wird erneut geprüft...",
"request_30_day_trial_license": "30-Tage-Testlizenz anfordern",
"saml_sso": "SAML-SSO",
"service_level_agreement": "Service-Level-Vereinbarung",
"soc2_hipaa_iso_27001_compliance_check": "SOC2-, HIPAA- und ISO 27001-Konformitätsprüfung",
"sso": "SSO (Google, Microsoft, OpenID Connect)",
"teams": "Teams & Zugriffskontrolle (Lesen, Lesen & Schreiben, Verwalten)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos.",
"your_enterprise_license_is_active_all_features_unlocked": "Deine Unternehmenslizenz ist aktiv. Alle Funktionen freigeschaltet."
},
"general": {
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
@@ -1080,7 +989,7 @@
"from_your_organization": "von deiner Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invite_expires_on": "Einladung läuft ab am {date}",
"invited_on": "Eingeladen am {date}",
"invites_failed": "Einladungen fehlgeschlagen",
"leave_organization": "Organisation verlassen",
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",
@@ -1193,6 +1102,8 @@
"please_fill_all_workspace_fields": "Bitte füllen Sie alle Felder aus, um einen neuen Workspace hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"select_member": "Mitglied auswählen",
"select_workspace": "Workspace auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",
@@ -1242,6 +1153,7 @@
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
"add_logic": "Logik hinzufügen",
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
"add_option": "Option hinzufügen",
@@ -1280,7 +1192,6 @@
"block_duplicated": "Block dupliziert.",
"bold": "Fett",
"brand_color": "Markenfarbe",
"brand_color_description": "Wird auf Buttons, Links und Hervorhebungen angewendet.",
"brightness": "Helligkeit",
"bulk_edit": "Massenbearbeitung",
"bulk_edit_description": "Bearbeiten Sie alle Optionen unten, eine pro Zeile. Leere Zeilen werden übersprungen und Duplikate entfernt.",
@@ -1298,9 +1209,7 @@
"capture_new_action": "Neue Aktion erfassen",
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
"card_background_color_description": "Füllt den Bereich der Umfragekarte.",
"card_border_color": "Farbe des Kartenrandes",
"card_border_color_description": "Umrandet die Umfragekarte.",
"card_styling": "Kartengestaltung",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
@@ -1311,12 +1220,20 @@
"caution_explanation_responses_are_safe": "Ältere und neuere Antworten vermischen sich, was zu irreführenden Datensummen führen kann.",
"caution_recommendation": "Dies kann im Umfrageübersicht zu Dateninkonsistenzen führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_question_type": "Fragetyp ändern",
"change_survey_type": "Die Änderung des Umfragetypen kann vorhandenen Zugriff beeinträchtigen",
"change_the_background_color_of_the_card": "Hintergrundfarbe der Karte ändern.",
"change_the_background_color_of_the_input_fields": "Hintergrundfarbe der Eingabefelder ändern.",
"change_the_background_to_a_color_image_or_animation": "Hintergrund zu einer Farbe, einem Bild oder einer Animation ändern.",
"change_the_border_color_of_the_card": "Randfarbe der Karte ändern.",
"change_the_border_color_of_the_input_fields": "Randfarbe der Eingabefelder ändern.",
"change_the_border_radius_of_the_card_and_the_inputs": "Radius der Ränder der Karte und der Eingabefelder ändern.",
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"changes_saved": "Änderungen gespeichert.",
"changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"",
"checkbox_label": "Checkbox-Beschriftung",
@@ -1356,7 +1273,6 @@
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
"display_type": "Anzeigetyp",
"divide": "Teilen /",
"does_not_contain": "Enthält nicht",
"does_not_end_with": "Endet nicht mit",
@@ -1364,7 +1280,6 @@
"does_not_include_all_of": "Enthält nicht alle von",
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"dropdown": "Dropdown",
"duplicate_block": "Block duplizieren",
"duplicate_question": "Frage duplizieren",
"edit_link": "Bearbeitungslink",
@@ -1456,6 +1371,7 @@
"hide_progress_bar": "Fortschrittsbalken ausblenden",
"hide_question_settings": "Frageeinstellungen ausblenden",
"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 Sie mehr benötigen, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
"ignore_global_waiting_time": "Abkühlphase ignorieren",
@@ -1466,9 +1382,7 @@
"initial_value": "Anfangswert",
"inner_text": "Innerer Text",
"input_border_color": "Randfarbe des Eingabefelds",
"input_border_color_description": "Umrandet Texteingaben und Textbereiche.",
"input_color": "Farbe des Eingabefelds",
"input_color_description": "Füllt das Innere von Texteingaben.",
"insert_link": "Link einfügen",
"invalid_targeting": "Ungültiges Targeting: Bitte überprüfe deine Zielgruppenfilter",
"invalid_video_url_warning": "Bitte gib eine gültige YouTube-, Vimeo- oder Loom-URL ein. Andere Video-Plattformen werden derzeit nicht unterstützt.",
@@ -1498,7 +1412,6 @@
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
"list": "Liste",
"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",
@@ -1552,12 +1465,13 @@
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
"publish": "Veröffentlichen",
"question": "Frage",
"question_color": "Fragefarbe",
"question_deleted": "Frage gelöscht.",
"question_duplicated": "Frage dupliziert.",
"question_id_updated": "Frage-ID aktualisiert",
"question_used_in_logic_warning_text": "Elemente aus diesem Block werden in einer Logikregel verwendet. Möchten Sie ihn wirklich löschen?",
"question_used_in_logic_warning_title": "Logikinkonsistenz",
"question_used_in_quota": "Diese Frage wird in der {quotaName} Quote verwendet",
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
"quotas": {
@@ -1613,7 +1527,6 @@
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Kartenecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"rows": "Zeilen",
"save_and_close": "Speichern & Schließen",
@@ -1655,6 +1568,7 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -",
"suggest_colors": "Farben vorschlagen",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
@@ -1731,7 +1645,7 @@
"validation_rules": "Validierungsregeln",
"validation_rules_description": "Nur Antworten akzeptieren, die die folgenden Kriterien erfüllen",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable {variableName} wird in der {quotaName} Quote verwendet",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_conflicts_with_hidden_field": "Der Variablenname steht im Konflikt mit einer vorhandenen Hidden-Field-ID.",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
@@ -1957,7 +1871,6 @@
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
"generating_qr_code": "QR-Code wird generiert",
"impressions": "Eindrücke",
"impressions_identified_only": "Zeigt nur Impressionen von identifizierten Kontakten",
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
"in_app": {
"connection_description": "Die Umfrage wird den Nutzern Ihrer Website angezeigt, die den unten aufgeführten Kriterien entsprechen",
@@ -2000,7 +1913,6 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"limit": "Limit",
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
@@ -2140,71 +2052,9 @@
"look": {
"add_background_color": "Hintergrundfarbe hinzufügen",
"add_background_color_description": "Füge dem Logo-Container eine Hintergrundfarbe hinzu.",
"advanced_styling_field_border_radius": "Rahmenradius",
"advanced_styling_field_button_bg": "Button-Hintergrund",
"advanced_styling_field_button_bg_description": "Füllt den Weiter-/Absenden-Button.",
"advanced_styling_field_button_border_radius_description": "Rundet die Button-Ecken ab.",
"advanced_styling_field_button_font_size_description": "Skaliert den Text der Button-Beschriftung.",
"advanced_styling_field_button_font_weight_description": "Macht den Button-Text heller oder fetter.",
"advanced_styling_field_button_height_description": "Steuert die Button-Höhe.",
"advanced_styling_field_button_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_button_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_button_text": "Button-Text",
"advanced_styling_field_button_text_description": "Färbt die Beschriftung innerhalb von Buttons.",
"advanced_styling_field_description_color": "Beschreibungsfarbe",
"advanced_styling_field_description_color_description": "Färbt den Text unterhalb jeder Überschrift.",
"advanced_styling_field_description_size": "Schriftgröße der Beschreibung",
"advanced_styling_field_description_size_description": "Skaliert den Beschreibungstext.",
"advanced_styling_field_description_weight": "Schriftstärke der Beschreibung",
"advanced_styling_field_description_weight_description": "Macht den Beschreibungstext heller oder fetter.",
"advanced_styling_field_font_size": "Schriftgröße",
"advanced_styling_field_font_weight": "Schriftstärke",
"advanced_styling_field_headline_color": "Überschriftsfarbe",
"advanced_styling_field_headline_color_description": "Färbt den Hauptfragetext.",
"advanced_styling_field_headline_size": "Schriftgröße der Überschrift",
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
"advanced_styling_field_height": "Mindesthöhe",
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
"advanced_styling_field_input_shadow_description": "Fügt einen Schlagschatten um Eingabefelder hinzu.",
"advanced_styling_field_input_text": "Eingabetext",
"advanced_styling_field_input_text_description": "Färbt den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_option_bg": "Hintergrund",
"advanced_styling_field_option_bg_description": "Füllt die Optionselemente.",
"advanced_styling_field_option_border_radius_description": "Rundet die Ecken der Optionen ab.",
"advanced_styling_field_option_font_size_description": "Skaliert den Text der Optionsbeschriftung.",
"advanced_styling_field_option_label": "Label-Farbe",
"advanced_styling_field_option_label_description": "Färbt den Text der Optionsbeschriftung.",
"advanced_styling_field_option_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_option_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_padding_x": "Innenabstand X",
"advanced_styling_field_padding_y": "Innenabstand Y",
"advanced_styling_field_placeholder_opacity": "Platzhalter-Deckkraft",
"advanced_styling_field_shadow": "Schatten",
"advanced_styling_field_track_bg": "Track-Hintergrund",
"advanced_styling_field_track_bg_description": "Färbt den nicht ausgefüllten Teil des Balkens.",
"advanced_styling_field_track_height": "Track-Höhe",
"advanced_styling_field_track_height_description": "Steuert die Dicke des Fortschrittsbalkens.",
"advanced_styling_field_upper_label_color": "Farbe des oberen Labels",
"advanced_styling_field_upper_label_color_description": "Färbt die kleine Beschriftung über Eingabefeldern.",
"advanced_styling_field_upper_label_size": "Schriftgröße des oberen Labels",
"advanced_styling_field_upper_label_size_description": "Skaliert die kleine Beschriftung über Eingabefeldern.",
"advanced_styling_field_upper_label_weight": "Schriftstärke des oberen Labels",
"advanced_styling_field_upper_label_weight_description": "Macht die Beschriftung leichter oder fetter.",
"advanced_styling_section_buttons": "Buttons",
"advanced_styling_section_headlines": "Überschriften & Beschreibungen",
"advanced_styling_section_inputs": "Eingabefelder",
"advanced_styling_section_options": "Optionen (Radio/Checkbox)",
"app_survey_placement": "Platzierung der App-Umfrage",
"app_survey_placement_settings_description": "Ändere, wo Umfragen in deiner Web-App oder Website angezeigt werden.",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"email_customization": "E-Mail-Anpassung",
"email_customization_description": "Ändere das Aussehen und die Gestaltung von E-Mails, die Formbricks in deinem Namen versendet.",
"enable_custom_styling": "Benutzerdefiniertes Styling aktivieren",
@@ -2215,9 +2065,6 @@
"formbricks_branding_hidden": "Formbricks-Branding ist ausgeblendet.",
"formbricks_branding_settings_description": "Wir freuen uns über deine Unterstützung, haben aber Verständnis, wenn du es ausschaltest.",
"formbricks_branding_shown": "Formbricks-Branding wird angezeigt.",
"generate_theme_btn": "Generieren",
"generate_theme_confirmation": "Möchtest du ein passendes Farbschema basierend auf deiner Markenfarbe generieren? Dies überschreibt deine aktuellen Farbeinstellungen.",
"generate_theme_header": "Farbschema generieren?",
"logo_removed_successfully": "Logo erfolgreich entfernt",
"logo_settings_description": "Lade dein Firmenlogo hoch, um Umfragen und Link-Vorschauen zu branden.",
"logo_updated_successfully": "Logo erfolgreich aktualisiert",
@@ -2232,8 +2079,6 @@
"show_formbricks_branding_in": "Formbricks-Branding in {type}-Umfragen anzeigen",
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
"suggest_colors": "Farben vorschlagen",
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
"theme": "Theme",
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
},
@@ -2997,7 +2842,6 @@
"preview_survey_question_2_choice_1_label": "Ja, halte mich auf dem Laufenden.",
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_welcome_card_headline": "Willkommen!",
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",
"prioritize_features_name": "Funktionen priorisieren",

File diff suppressed because it is too large Load Diff

View File

@@ -188,7 +188,6 @@
"customer_success": "Éxito del cliente",
"dark_overlay": "Superposición oscura",
"date": "Fecha",
"days": "días",
"default": "Predeterminado",
"delete": "Eliminar",
"description": "Descripción",
@@ -218,7 +217,6 @@
"error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
"error_component_title": "Error al cargar recursos",
"error_loading_data": "Error al cargar los datos",
"error_rate_limit_description": "Número máximo de solicitudes alcanzado. Por favor, inténtalo de nuevo más tarde.",
"error_rate_limit_title": "Límite de frecuencia excedido",
"expand_rows": "Expandir filas",
@@ -277,7 +275,6 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
"months": "meses",
"move_down": "Mover hacia abajo",
"move_up": "Mover hacia arriba",
"multiple_languages": "Múltiples idiomas",
@@ -288,7 +285,6 @@
"no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos",
"no_overlay": "Sin superposición",
"no_quotas_found": "No se encontraron cuotas",
"no_result_found": "No se encontró resultado",
"no_results": "Sin resultados",
@@ -315,7 +311,6 @@
"organization_teams_not_found": "Equipos de la organización no encontrados",
"other": "Otro",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
"paused": "Pausado",
@@ -355,7 +350,6 @@
"request_trial_license": "Solicitar licencia de prueba",
"reset_to_default": "Restablecer a valores predeterminados",
"response": "Respuesta",
"response_id": "ID de respuesta",
"responses": "Respuestas",
"restart": "Reiniciar",
"role": "Rol",
@@ -396,7 +390,6 @@
"status": "Estado",
"step_by_step_manual": "Manual paso a paso",
"storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas",
"string": "Texto",
"styling": "Estilo",
"submit": "Enviar",
"summary": "Resumen",
@@ -429,7 +422,6 @@
"top_right": "Superior derecha",
"try_again": "Intentar de nuevo",
"type": "Tipo",
"unknown_survey": "Encuesta desconocida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
"update": "Actualizar",
"updated": "Actualizado",
@@ -453,7 +445,6 @@
"website_and_app_connection": "Conexión de sitio web y aplicación",
"website_app_survey": "Encuesta de sitio web y aplicación",
"website_survey": "Encuesta de sitio web",
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
@@ -464,7 +455,6 @@
"workspace_not_found": "Proyecto no encontrado",
"workspace_permission_not_found": "Permiso del proyecto no encontrado",
"workspaces": "Proyectos",
"years": "años",
"you": "Tú",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
@@ -636,45 +626,28 @@
"attribute_updated_successfully": "Atributo actualizado con éxito",
"attribute_value": "Valor",
"attribute_value_placeholder": "Valor del atributo",
"attributes_msg_attribute_limit_exceeded": "No se pudieron crear {count} atributo(s) nuevo(s) ya que se excedería el límite máximo de {limit} clases de atributos. Los atributos existentes se actualizaron correctamente.",
"attributes_msg_attribute_type_validation_error": "{error} (el atributo '{key}' tiene dataType: {dataType})",
"attributes_msg_email_already_exists": "El email ya existe para este entorno y no se actualizó.",
"attributes_msg_email_or_userid_required": "Se requiere email o userId. Se conservaron los valores existentes.",
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo '{key}' con tipo '{dataType}'",
"attributes_msg_userid_already_exists": "El userId ya existe para este entorno y no se actualizó.",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"contact_not_found": "No se ha encontrado dicho contacto",
"contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
"create_attribute": "Crear atributo",
"create_key": "Crear clave",
"create_new_attribute": "Crear atributo nuevo",
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
"custom_attributes": "Atributos personalizados",
"data_type": "Tipo de dato",
"data_type_cannot_be_changed": "El tipo de dato no se puede cambiar después de la creación",
"data_type_description": "Elige cómo debe almacenarse y filtrarse este atributo",
"date_value_required": "Se requiere un valor de fecha. Usa el botón de eliminar para quitar este atributo si no quieres establecer una fecha.",
"delete_attribute_confirmation": "{value, plural, one {Esto eliminará el atributo seleccionado. Se perderán todos los datos de contacto asociados con este atributo.} other {Esto eliminará los atributos seleccionados. Se perderán todos los datos de contacto asociados con estos atributos.}}",
"delete_contact_confirmation": "Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá. Si este contacto tiene respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.} other {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con estos contactos. Cualquier segmentación y personalización basada en los datos de estos contactos se perderá. Si estos contactos tienen respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.}}",
"displays": "Visualizaciones",
"edit_attribute": "Editar atributo",
"edit_attribute_description": "Actualiza la etiqueta y la descripción de este atributo.",
"edit_attribute_values": "Editar atributos",
"edit_attribute_values_description": "Cambia los valores de atributos específicos para este contacto.",
"edit_attributes": "Editar atributos",
"edit_attributes_success": "Atributos del contacto actualizados correctamente",
"generate_personal_link": "Generar enlace personal",
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
"no_activity_yet": "Aún no hay actividad",
"no_published_link_surveys_available": "No hay encuestas de enlace publicadas disponibles. Por favor, publica primero una encuesta de enlace.",
"no_published_surveys": "No hay encuestas publicadas",
"no_responses_found": "No se encontraron respuestas",
"not_provided": "No proporcionado",
"number_value_required": "Se requiere un valor numérico. Usa el botón de eliminar para quitar este atributo.",
"personal_link_generated": "Enlace personal generado correctamente",
"personal_link_generated_but_clipboard_failed": "Enlace personal generado pero falló al copiar al portapapeles: {url}",
"personal_survey_link": "Enlace personal de encuesta",
@@ -683,24 +656,13 @@
"search_contact": "Buscar contacto",
"select_a_survey": "Selecciona una encuesta",
"select_attribute": "Seleccionar atributo",
"select_attribute_key": "Seleccionar clave de atributo",
"survey_viewed": "Encuesta vista",
"survey_viewed_at": "Vista el",
"system_attributes": "Atributos del sistema",
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
"upload_contacts_error_attribute_type_mismatch": "El atributo \"{key}\" está tipado como \"{dataType}\" pero el CSV contiene valores no válidos: {values}",
"upload_contacts_error_duplicate_mappings": "Se encontraron mapeos duplicados para los siguientes atributos: {attributes}",
"upload_contacts_error_file_too_large": "El tamaño del archivo supera el límite máximo de 800 KB",
"upload_contacts_error_generic": "Se produjo un error al cargar los contactos. Por favor, inténtalo de nuevo más tarde.",
"upload_contacts_error_invalid_file_type": "Por favor, carga un archivo CSV",
"upload_contacts_error_no_valid_contacts": "El archivo CSV cargado no contiene ningún contacto válido, por favor consulta el archivo CSV de ejemplo para ver el formato correcto.",
"upload_contacts_modal_attribute_header": "Atributo de Formbricks",
"upload_contacts_modal_attributes_description": "Asigna las columnas de tu CSV a los atributos en Formbricks.",
"upload_contacts_modal_attributes_new": "Nuevo atributo",
"upload_contacts_modal_attributes_search_or_add": "Buscar o añadir atributo",
"upload_contacts_modal_attributes_should_be_mapped_to": "debe asignarse a",
"upload_contacts_modal_attributes_title": "Atributos",
"upload_contacts_modal_csv_column_header": "Columna CSV",
"upload_contacts_modal_description": "Sube un CSV para importar rápidamente contactos con atributos",
"upload_contacts_modal_download_example_csv": "Descargar CSV de ejemplo",
"upload_contacts_modal_duplicates_description": "¿Cómo deberíamos manejar si un contacto ya existe en tus contactos?",
@@ -757,12 +719,7 @@
"link_google_sheet": "Vincular Google Sheet",
"link_new_sheet": "Vincular nueva hoja",
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión con Google Sheets ha caducado. Reconecta para continuar sincronizando respuestas. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"spreadsheet_permission_error": "No tienes permiso para acceder a esta hoja de cálculo. Asegúrate de que la hoja de cálculo esté compartida con tu cuenta de Google y de que tengas acceso de escritura a la hoja de cálculo.",
"spreadsheet_url": "URL de la hoja de cálculo",
"token_expired_error": "El token de actualización de Google Sheets ha caducado o ha sido revocado. Reconecta la integración."
"spreadsheet_url": "URL de la hoja de cálculo"
},
"include_created_at": "Incluir fecha de creación",
"include_hidden_fields": "Incluir campos ocultos",
@@ -886,40 +843,6 @@
"no_attributes_yet": "¡Aún no hay atributos!",
"no_filters_yet": "¡Aún no hay filtros!",
"no_segments_yet": "Actualmente no tienes segmentos guardados.",
"operator_contains": "contiene",
"operator_does_not_contain": "no contiene",
"operator_ends_with": "termina con",
"operator_is_after": "es después de",
"operator_is_before": "es antes de",
"operator_is_between": "está entre",
"operator_is_newer_than": "es más reciente que",
"operator_is_not_set": "no está establecido",
"operator_is_older_than": "es más antiguo que",
"operator_is_same_day": "es el mismo día",
"operator_is_set": "está establecido",
"operator_starts_with": "comienza con",
"operator_title_contains": "Contiene",
"operator_title_does_not_contain": "No contiene",
"operator_title_ends_with": "Termina con",
"operator_title_equals": "Es igual a",
"operator_title_greater_equal": "Mayor o igual que",
"operator_title_greater_than": "Mayor que",
"operator_title_is_after": "Es después de",
"operator_title_is_before": "Es antes de",
"operator_title_is_between": "Está entre",
"operator_title_is_newer_than": "Es más reciente que",
"operator_title_is_not_set": "No está establecido",
"operator_title_is_older_than": "Es más antiguo que",
"operator_title_is_same_day": "Es el mismo día",
"operator_title_is_set": "Está establecido",
"operator_title_less_equal": "Menor o igual que",
"operator_title_less_than": "Menor que",
"operator_title_not_equals": "No es igual a",
"operator_title_starts_with": "Comienza con",
"operator_title_user_is_in": "El usuario está en",
"operator_title_user_is_not_in": "El usuario no está en",
"operator_user_is_in": "El usuario está en",
"operator_user_is_not_in": "El usuario no está en",
"person_and_attributes": "Persona y atributos",
"phone": "Teléfono",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, elimina el segmento de estas encuestas para poder borrarlo.",
@@ -944,7 +867,6 @@
"user_targeting_is_currently_only_available_when": "La segmentación de usuarios actualmente solo está disponible cuando",
"value_cannot_be_empty": "El valor no puede estar vacío.",
"value_must_be_a_number": "El valor debe ser un número.",
"value_must_be_positive": "El valor debe ser un número positivo.",
"view_filters": "Ver filtros",
"where": "Donde",
"with_the_formbricks_sdk": "con el SDK de Formbricks"
@@ -1031,32 +953,19 @@
"enterprise_features": "Características empresariales",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
"license_status": "Estado de la licencia",
"license_status_active": "Activa",
"license_status_description": "Estado de tu licencia enterprise.",
"license_status_expired": "Caducada",
"license_status_invalid": "Licencia no válida",
"license_status_unreachable": "Inaccesible",
"license_unreachable_grace_period": "No se puede acceder al servidor de licencias. Tus funciones empresariales permanecen activas durante un período de gracia de 3 días que finaliza el {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Sin necesidad de llamadas, sin compromisos: solicita una licencia de prueba gratuita de 30 días para probar todas las características rellenando este formulario:",
"no_credit_card_no_sales_call_just_test_it": "Sin tarjeta de crédito. Sin llamada de ventas. Solo pruébalo :)",
"on_request": "Bajo petición",
"organization_roles": "Roles de organización (administrador, editor, desarrollador, etc.)",
"questions_please_reach_out_to": "¿Preguntas? Por favor, contacta con",
"recheck_license": "Volver a comprobar licencia",
"recheck_license_failed": "Error al comprobar la licencia. Es posible que el servidor de licencias no esté disponible.",
"recheck_license_invalid": "La clave de licencia no es válida. Por favor, verifica tu ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Comprobación de licencia correcta",
"recheck_license_unreachable": "El servidor de licencias no está disponible. Inténtalo de nuevo más tarde.",
"rechecking": "Comprobando...",
"request_30_day_trial_license": "Solicitar licencia de prueba de 30 días",
"saml_sso": "SAML SSO",
"service_level_agreement": "Acuerdo de nivel de servicio",
"soc2_hipaa_iso_27001_compliance_check": "Verificación de cumplimiento SOC2, HIPAA, ISO 27001",
"sso": "SSO (Google, Microsoft, OpenID Connect)",
"teams": "Equipos y roles de acceso (lectura, lectura y escritura, gestión)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días.",
"your_enterprise_license_is_active_all_features_unlocked": "Tu licencia empresarial está activa. Todas las características desbloqueadas."
},
"general": {
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
@@ -1080,7 +989,7 @@
"from_your_organization": "de tu organización",
"invitation_sent_once_more": "Invitación enviada una vez más.",
"invite_deleted_successfully": "Invitación eliminada correctamente",
"invite_expires_on": "La invitación expira el {date}",
"invited_on": "Invitado el {date}",
"invites_failed": "Las invitaciones fallaron",
"leave_organization": "Abandonar organización",
"leave_organization_description": "Abandonarás esta organización y perderás acceso a todas las encuestas y respuestas. Solo podrás volver a unirte si te invitan de nuevo.",
@@ -1193,6 +1102,8 @@
"please_fill_all_workspace_fields": "Por favor, rellena todos los campos para añadir un proyecto nuevo.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"select_member": "Seleccionar miembro",
"select_workspace": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",
@@ -1242,6 +1153,7 @@
"add_fallback_placeholder": "Añadir un marcador de posición para mostrar si no hay valor que recuperar.",
"add_hidden_field_id": "Añadir ID de campo oculto",
"add_highlight_border": "Añadir borde destacado",
"add_highlight_border_description": "Añadir un borde exterior a tu tarjeta de encuesta.",
"add_logic": "Añadir lógica",
"add_none_of_the_above": "Añadir \"Ninguna de las anteriores\"",
"add_option": "Añadir opción",
@@ -1280,7 +1192,6 @@
"block_duplicated": "Bloque duplicado.",
"bold": "Negrita",
"brand_color": "Color de marca",
"brand_color_description": "Se aplica a botones, enlaces y resaltados.",
"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.",
@@ -1298,9 +1209,7 @@
"capture_new_action": "Capturar nueva acción",
"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_background_color_description": "Rellena el área de la tarjeta de encuesta.",
"card_border_color": "Color del borde de la tarjeta",
"card_border_color_description": "Delinea la tarjeta de encuesta.",
"card_styling": "Estilo de la tarjeta",
"casual": "Informal",
"caution_edit_duplicate": "Duplicar y editar",
@@ -1311,12 +1220,20 @@
"caution_explanation_responses_are_safe": "Las respuestas antiguas y nuevas se mezclan, lo que puede llevar a resúmenes de datos engañosos.",
"caution_recommendation": "Esto puede causar inconsistencias de datos en el resumen de la encuesta. Recomendamos duplicar la encuesta en su lugar.",
"caution_text": "Los cambios provocarán inconsistencias",
"centered_modal_overlay_color": "Color de superposición del modal centrado",
"change_anyway": "Cambiar de todos modos",
"change_background": "Cambiar fondo",
"change_question_type": "Cambiar tipo de pregunta",
"change_survey_type": "Cambiar el tipo de encuesta afecta al acceso existente",
"change_the_background_color_of_the_card": "Cambiar el color de fondo de la tarjeta.",
"change_the_background_color_of_the_input_fields": "Cambiar el color de fondo de los campos de entrada.",
"change_the_background_to_a_color_image_or_animation": "Cambiar el fondo a un color, imagen o animación.",
"change_the_border_color_of_the_card": "Cambiar el color del borde de la tarjeta.",
"change_the_border_color_of_the_input_fields": "Cambiar el color del borde de los campos de entrada.",
"change_the_border_radius_of_the_card_and_the_inputs": "Cambiar el radio del borde de la tarjeta y las entradas.",
"change_the_brand_color_of_the_survey": "Cambiar el color de marca de la encuesta.",
"change_the_placement_of_this_survey": "Cambiar la ubicación de esta encuesta.",
"change_the_question_color_of_the_survey": "Cambiar el color de las preguntas de la encuesta.",
"changes_saved": "Cambios guardados.",
"changing_survey_type_will_remove_existing_distribution_channels": "Cambiar el tipo de encuesta afectará a cómo se puede compartir. Si los encuestados ya tienen enlaces de acceso para el tipo actual, podrían perder el acceso después del cambio.",
"checkbox_label": "Etiqueta de casilla de verificación",
@@ -1356,7 +1273,6 @@
"disable_the_visibility_of_survey_progress": "Desactivar la visibilidad del progreso de la encuesta.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar una estimación del tiempo de finalización de la encuesta",
"display_number_of_responses_for_survey": "Mostrar número de respuestas para la encuesta",
"display_type": "Tipo de visualización",
"divide": "Dividir /",
"does_not_contain": "No contiene",
"does_not_end_with": "No termina con",
@@ -1364,7 +1280,6 @@
"does_not_include_all_of": "No incluye todos los",
"does_not_include_one_of": "No incluye uno de",
"does_not_start_with": "No comienza con",
"dropdown": "Desplegable",
"duplicate_block": "Duplicar bloque",
"duplicate_question": "Duplicar pregunta",
"edit_link": "Editar enlace",
@@ -1456,6 +1371,7 @@
"hide_progress_bar": "Ocultar barra de progreso",
"hide_question_settings": "Ocultar ajustes de la pregunta",
"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",
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cuando se active hasta que se envíe una respuesta.",
"ignore_global_waiting_time": "Ignorar periodo de espera",
@@ -1466,9 +1382,7 @@
"initial_value": "Valor inicial",
"inner_text": "Texto interior",
"input_border_color": "Color del borde de entrada",
"input_border_color_description": "Delinea los campos de texto y áreas de texto.",
"input_color": "Color de entrada",
"input_color_description": "Rellena el interior de los campos de texto.",
"insert_link": "Insertar enlace",
"invalid_targeting": "Segmentación no válida: por favor, comprueba tus filtros de audiencia",
"invalid_video_url_warning": "Por favor, introduce una URL válida de YouTube, Vimeo o Loom. Actualmente no admitimos otros proveedores de alojamiento de vídeos.",
@@ -1498,7 +1412,6 @@
"limit_the_maximum_file_size": "Limita el tamaño máximo de archivo para las subidas.",
"limit_upload_file_size_to": "Limitar el tamaño de archivo de subida a",
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
"list": "Lista",
"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",
@@ -1552,12 +1465,13 @@
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
"publish": "Publicar",
"question": "Pregunta",
"question_color": "Color de la pregunta",
"question_deleted": "Pregunta eliminada.",
"question_duplicated": "Pregunta duplicada.",
"question_id_updated": "ID de pregunta actualizado",
"question_used_in_logic_warning_text": "Los elementos de este bloque se usan en una regla de lógica, ¿estás seguro de que quieres eliminarlo?",
"question_used_in_logic_warning_title": "Inconsistencia de lógica",
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota {quotaName}",
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota \"{quotaName}\"",
"question_used_in_recall": "Esta pregunta se está recordando en la pregunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pregunta se está recordando en la Tarjeta Final",
"quotas": {
@@ -1613,7 +1527,6 @@
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta",
"roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas de la tarjeta.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"rows": "Filas",
"save_and_close": "Guardar y cerrar",
@@ -1655,6 +1568,7 @@
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
"subheading": "Subtítulo",
"subtract": "Restar -",
"suggest_colors": "Sugerir colores",
"survey_completed_heading": "Encuesta completada",
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta",
@@ -1731,7 +1645,7 @@
"validation_rules": "Reglas de validación",
"validation_rules_description": "Solo aceptar respuestas que cumplan los siguientes criterios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala primero de la lógica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable {variableName} se está utilizando en la cuota {quotaName}",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" se está utilizando en la cuota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "El nombre de la variable entra en conflicto con un ID de campo oculto existente.",
"variable_name_is_already_taken_please_choose_another": "El nombre de la variable ya está en uso, por favor elige otro.",
"variable_name_must_start_with_a_letter": "El nombre de la variable debe comenzar con una letra.",
@@ -1957,7 +1871,6 @@
"filtered_responses_excel": "Respuestas filtradas (Excel)",
"generating_qr_code": "Generando código QR",
"impressions": "Impresiones",
"impressions_identified_only": "Solo se muestran impresiones de contactos identificados",
"impressions_tooltip": "Número de veces que se ha visto la encuesta.",
"in_app": {
"connection_description": "La encuesta se mostrará a los usuarios de tu sitio web que cumplan con los criterios enumerados a continuación",
@@ -2000,7 +1913,6 @@
"last_quarter": "Último trimestre",
"last_year": "Último año",
"limit": "Límite",
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"other_values_found": "Otros valores encontrados",
"overall": "General",
@@ -2140,71 +2052,9 @@
"look": {
"add_background_color": "Añadir color de fondo",
"add_background_color_description": "Añade un color de fondo al contenedor del logotipo.",
"advanced_styling_field_border_radius": "Radio del borde",
"advanced_styling_field_button_bg": "Fondo del botón",
"advanced_styling_field_button_bg_description": "Rellena el botón siguiente / enviar.",
"advanced_styling_field_button_border_radius_description": "Redondea las esquinas del botón.",
"advanced_styling_field_button_font_size_description": "Escala el texto de la etiqueta del botón.",
"advanced_styling_field_button_font_weight_description": "Hace el texto del botón más ligero o más grueso.",
"advanced_styling_field_button_height_description": "Controla la altura del botón.",
"advanced_styling_field_button_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
"advanced_styling_field_button_padding_y_description": "Añade espacio arriba y abajo.",
"advanced_styling_field_button_text": "Texto del botón",
"advanced_styling_field_button_text_description": "Colorea la etiqueta dentro de los botones.",
"advanced_styling_field_description_color": "Color de la descripción",
"advanced_styling_field_description_color_description": "Colorea el texto debajo de cada titular.",
"advanced_styling_field_description_size": "Tamaño de fuente de la descripción",
"advanced_styling_field_description_size_description": "Escala el texto de la descripción.",
"advanced_styling_field_description_weight": "Grosor de fuente de la descripción",
"advanced_styling_field_description_weight_description": "Hace el texto de la descripción más ligero o más grueso.",
"advanced_styling_field_font_size": "Tamaño de fuente",
"advanced_styling_field_font_weight": "Grosor de fuente",
"advanced_styling_field_headline_color": "Color del titular",
"advanced_styling_field_headline_color_description": "Colorea el texto principal de la pregunta.",
"advanced_styling_field_headline_size": "Tamaño de fuente del titular",
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fondo del indicador",
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
"advanced_styling_field_input_shadow_description": "Añade una sombra alrededor de los campos de entrada.",
"advanced_styling_field_input_text": "Texto de entrada",
"advanced_styling_field_input_text_description": "Colorea el texto escrito en los campos de entrada.",
"advanced_styling_field_option_bg": "Fondo",
"advanced_styling_field_option_bg_description": "Rellena los elementos de opción.",
"advanced_styling_field_option_border_radius_description": "Redondea las esquinas de las opciones.",
"advanced_styling_field_option_font_size_description": "Escala el texto de la etiqueta de opción.",
"advanced_styling_field_option_label": "Color de la etiqueta",
"advanced_styling_field_option_label_description": "Colorea el texto de la etiqueta de opción.",
"advanced_styling_field_option_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
"advanced_styling_field_option_padding_y_description": "Añade espacio en la parte superior e inferior.",
"advanced_styling_field_padding_x": "Relleno X",
"advanced_styling_field_padding_y": "Relleno Y",
"advanced_styling_field_placeholder_opacity": "Opacidad del marcador de posición",
"advanced_styling_field_shadow": "Sombra",
"advanced_styling_field_track_bg": "Fondo de la pista",
"advanced_styling_field_track_bg_description": "Colorea la parte no rellenada de la barra.",
"advanced_styling_field_track_height": "Altura de la pista",
"advanced_styling_field_track_height_description": "Controla el grosor de la barra de progreso.",
"advanced_styling_field_upper_label_color": "Color de la etiqueta del titular",
"advanced_styling_field_upper_label_color_description": "Colorea la etiqueta pequeña sobre los campos de entrada.",
"advanced_styling_field_upper_label_size": "Tamaño de fuente de la etiqueta del titular",
"advanced_styling_field_upper_label_size_description": "Escala la etiqueta pequeña sobre los campos de entrada.",
"advanced_styling_field_upper_label_weight": "Grosor de fuente de la etiqueta del titular",
"advanced_styling_field_upper_label_weight_description": "Hace que la etiqueta sea más ligera o más gruesa.",
"advanced_styling_section_buttons": "Botones",
"advanced_styling_section_headlines": "Títulos y descripciones",
"advanced_styling_section_inputs": "Campos de entrada",
"advanced_styling_section_options": "Opciones (radio/casilla de verificación)",
"app_survey_placement": "Ubicación de encuesta de aplicación",
"app_survey_placement_settings_description": "Cambia dónde se mostrarán las encuestas en tu aplicación web o sitio web.",
"centered_modal_overlay_color": "Color de superposición del modal centrado",
"email_customization": "Personalización de correo electrónico",
"email_customization_description": "Cambia el aspecto de los correos electrónicos que Formbricks envía en tu nombre.",
"enable_custom_styling": "Habilitar estilo personalizado",
@@ -2215,9 +2065,6 @@
"formbricks_branding_hidden": "La marca de Formbricks está oculta.",
"formbricks_branding_settings_description": "Nos encanta tu apoyo, pero lo entendemos si lo desactivas.",
"formbricks_branding_shown": "La marca de Formbricks se muestra.",
"generate_theme_btn": "Generar",
"generate_theme_confirmation": "¿Te gustaría generar un tema de colores que combine con el color de tu marca? Esto sobrescribirá tu configuración de colores actual.",
"generate_theme_header": "¿Generar tema de colores?",
"logo_removed_successfully": "Logotipo eliminado correctamente",
"logo_settings_description": "Sube el logotipo de tu empresa para personalizar las encuestas y las vistas previas de enlaces.",
"logo_updated_successfully": "Logotipo actualizado correctamente",
@@ -2232,8 +2079,6 @@
"show_formbricks_branding_in": "Mostrar marca de Formbricks en encuestas de {type}",
"show_powered_by_formbricks": "Mostrar firma 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo actualizado correctamente",
"suggest_colors": "Sugerir colores",
"suggested_colors_applied_please_save": "Colores sugeridos generados correctamente. Pulsa \"Guardar\" para conservar los cambios.",
"theme": "Tema",
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
},
@@ -2997,7 +2842,6 @@
"preview_survey_question_2_choice_1_label": "Sí, mantenme informado.",
"preview_survey_question_2_choice_2_label": "¡No, gracias!",
"preview_survey_question_2_headline": "¿Quieres estar al tanto?",
"preview_survey_question_2_subheader": "Esta es una descripción de ejemplo.",
"preview_survey_welcome_card_headline": "¡Bienvenido!",
"prioritize_features_description": "Identifica las funciones que tus usuarios necesitan más y menos.",
"prioritize_features_name": "Priorizar funciones",

View File

@@ -188,7 +188,6 @@
"customer_success": "Succès Client",
"dark_overlay": "Foncée",
"date": "Date",
"days": "jours",
"default": "Par défaut",
"delete": "Supprimer",
"description": "Description",
@@ -218,7 +217,6 @@
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
"error_component_title": "Erreur de chargement des ressources",
"error_loading_data": "Erreur lors du chargement des données",
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
@@ -277,7 +275,6 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!",
"months": "mois",
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
@@ -288,7 +285,6 @@
"no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Sans code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
"no_overlay": "Aucune superposition",
"no_quotas_found": "Aucun quota trouvé",
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
@@ -315,7 +311,6 @@
"organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
"paused": "En pause",
@@ -355,7 +350,6 @@
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"response": "Réponse",
"response_id": "ID de réponse",
"responses": "Réponses",
"restart": "Recommencer",
"role": "Rôle",
@@ -396,7 +390,6 @@
"status": "Statut",
"step_by_step_manual": "Manuel étape par étape",
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
"string": "Texte",
"styling": "Style",
"submit": "Soumettre",
"summary": "Résumé",
@@ -429,7 +422,6 @@
"top_right": "En haut à droite",
"try_again": "Réessayer",
"type": "Type",
"unknown_survey": "Enquête inconnue",
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
"update": "Mise à jour",
"updated": "Mise à jour",
@@ -453,7 +445,6 @@
"website_and_app_connection": "Connexion de sites Web et d'applications",
"website_app_survey": "Sondage sur le site Web et l'application",
"website_survey": "Sondage de site web",
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
@@ -464,7 +455,6 @@
"workspace_not_found": "Projet introuvable",
"workspace_permission_not_found": "Permission du projet introuvable",
"workspaces": "Projets",
"years": "années",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
@@ -636,45 +626,28 @@
"attribute_updated_successfully": "Attribut mis à jour avec succès",
"attribute_value": "Valeur",
"attribute_value_placeholder": "Valeur d'attribut",
"attributes_msg_attribute_limit_exceeded": "Impossible de créer {count, plural, one {# nouvel attribut} other {# nouveaux attributs}} car cela dépasserait la limite maximale de {limit} classes d'attributs. Les attributs existants ont été mis à jour avec succès.",
"attributes_msg_attribute_type_validation_error": "{error} (l'attribut « {key} » a le type de données: {dataType})",
"attributes_msg_email_already_exists": "L'adresse e-mail existe déjà pour cet environnement et n'a pas été mise à jour.",
"attributes_msg_email_or_userid_required": "L'adresse e-mail ou l'identifiant utilisateur est requis. Les valeurs existantes ont été conservées.",
"attributes_msg_new_attribute_created": "Nouvel attribut « {key} » créé avec le type « {dataType} »",
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
"contact_deleted_successfully": "Contact supprimé avec succès",
"contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Actualiser les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"create_attribute": "Créer un attribut",
"create_key": "Créer une clé",
"create_new_attribute": "Créer un nouvel attribut",
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
"custom_attributes": "Attributs personnalisés",
"data_type": "Type de données",
"data_type_cannot_be_changed": "Le type de données ne peut pas être modifié après la création",
"data_type_description": "Choisis comment cet attribut doit être stocké et filtré",
"date_value_required": "Une valeur de date est requise. Utilise le bouton supprimer pour retirer cet attribut si tu ne veux pas définir de date.",
"delete_attribute_confirmation": "{value, plural, one {Cela supprimera l'attribut sélectionné. Toutes les données de contact associées à cet attribut seront perdues.} other {Cela supprimera les attributs sélectionnés. Toutes les données de contact associées à ces attributs seront perdues.}}",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
"displays": "Affichages",
"edit_attribute": "Modifier l'attribut",
"edit_attribute_description": "Mettez à jour l'étiquette et la description de cet attribut.",
"edit_attribute_values": "Modifier les attributs",
"edit_attribute_values_description": "Modifiez les valeurs d'attributs spécifiques pour ce contact.",
"edit_attributes": "Modifier les attributs",
"edit_attributes_success": "Attributs du contact mis à jour avec succès",
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
"no_activity_yet": "Aucune activité pour le moment",
"no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.",
"no_published_surveys": "Aucune enquête publiée",
"no_responses_found": "Aucune réponse trouvée",
"not_provided": "Non fourni",
"number_value_required": "La valeur numérique est requise. Utilisez le bouton supprimer pour retirer cet attribut.",
"personal_link_generated": "Lien personnel généré avec succès",
"personal_link_generated_but_clipboard_failed": "Lien personnel généré mais échec de la copie dans le presse-papiers: {url}",
"personal_survey_link": "Lien vers le sondage personnel",
@@ -683,24 +656,13 @@
"search_contact": "Rechercher un contact",
"select_a_survey": "Sélectionner une enquête",
"select_attribute": "Sélectionner un attribut",
"select_attribute_key": "Sélectionner une clé d'attribut",
"survey_viewed": "Enquête consultée",
"survey_viewed_at": "Consultée le",
"system_attributes": "Attributs système",
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
"upload_contacts_error_attribute_type_mismatch": "L'attribut « {key} » est de type « {dataType} » mais le CSV contient des valeurs invalides: {values}",
"upload_contacts_error_duplicate_mappings": "Mappages en double trouvés pour les attributs suivants: {attributes}",
"upload_contacts_error_file_too_large": "La taille du fichier dépasse la limite maximale de 800 Ko",
"upload_contacts_error_generic": "Une erreur s'est produite lors de l'importation des contacts. Veuillez réessayer plus tard.",
"upload_contacts_error_invalid_file_type": "Veuillez importer un fichier CSV",
"upload_contacts_error_no_valid_contacts": "Le fichier CSV importé ne contient aucun contact valide, veuillez consulter l'exemple de fichier CSV pour le format correct.",
"upload_contacts_modal_attribute_header": "Attribut Formbricks",
"upload_contacts_modal_attributes_description": "Mappez les colonnes de votre CSV aux attributs dans Formbricks.",
"upload_contacts_modal_attributes_new": "Nouvel attribut",
"upload_contacts_modal_attributes_search_or_add": "Rechercher ou ajouter un attribut",
"upload_contacts_modal_attributes_should_be_mapped_to": "devrait être mappé à",
"upload_contacts_modal_attributes_title": "Attributs",
"upload_contacts_modal_csv_column_header": "Colonne CSV",
"upload_contacts_modal_description": "Téléchargez un fichier CSV pour importer rapidement des contacts avec des attributs.",
"upload_contacts_modal_download_example_csv": "Télécharger un exemple de CSV",
"upload_contacts_modal_duplicates_description": "Que faire si un contact existe déjà ?",
@@ -757,12 +719,7 @@
"link_google_sheet": "Lien Google Sheet",
"link_new_sheet": "Lier une nouvelle feuille",
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Votre connexion Google Sheets a expiré. Veuillez vous reconnecter pour continuer à synchroniser les réponses. Vos liens de feuilles de calcul et données existants seront préservés.",
"reconnect_button_tooltip": "Reconnectez l'intégration pour actualiser votre accès. Vos liens de feuilles de calcul et données existants seront préservés.",
"spreadsheet_permission_error": "Vous n'avez pas la permission d'accéder à cette feuille de calcul. Veuillez vous assurer que la feuille de calcul est partagée avec votre compte Google et que vous disposez d'un accès en écriture.",
"spreadsheet_url": "URL de la feuille de calcul",
"token_expired_error": "Le jeton d'actualisation Google Sheets a expiré ou a été révoqué. Veuillez reconnecter l'intégration."
"spreadsheet_url": "URL de la feuille de calcul"
},
"include_created_at": "Inclure la date de création",
"include_hidden_fields": "Inclure les champs cachés",
@@ -886,40 +843,6 @@
"no_attributes_yet": "Aucun attribut pour le moment !",
"no_filters_yet": "Il n'y a pas encore de filtres !",
"no_segments_yet": "Aucun segment n'est actuellement enregistré.",
"operator_contains": "contient",
"operator_does_not_contain": "ne contient pas",
"operator_ends_with": "se termine par",
"operator_is_after": "est après",
"operator_is_before": "est avant",
"operator_is_between": "est entre",
"operator_is_newer_than": "est plus récent que",
"operator_is_not_set": "n'est pas défini",
"operator_is_older_than": "est plus ancien que",
"operator_is_same_day": "est le même jour",
"operator_is_set": "est défini",
"operator_starts_with": "commence par",
"operator_title_contains": "Contient",
"operator_title_does_not_contain": "Ne contient pas",
"operator_title_ends_with": "Se termine par",
"operator_title_equals": "Égal",
"operator_title_greater_equal": "Supérieur ou égal à",
"operator_title_greater_than": "Supérieur à",
"operator_title_is_after": "Est après",
"operator_title_is_before": "Est avant",
"operator_title_is_between": "Est entre",
"operator_title_is_newer_than": "Est plus récent que",
"operator_title_is_not_set": "N'est pas défini",
"operator_title_is_older_than": "Est plus ancien que",
"operator_title_is_same_day": "Est le même jour",
"operator_title_is_set": "Est défini",
"operator_title_less_equal": "Inférieur ou égal à",
"operator_title_less_than": "Inférieur à",
"operator_title_not_equals": "N'est pas égal à",
"operator_title_starts_with": "Commence par",
"operator_title_user_is_in": "L'utilisateur est dans",
"operator_title_user_is_not_in": "L'utilisateur n'est pas dans",
"operator_user_is_in": "L'utilisateur est dans",
"operator_user_is_not_in": "L'utilisateur n'est pas dans",
"person_and_attributes": "Personne et attributs",
"phone": "Téléphone",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Veuillez supprimer le segment de ces enquêtes afin de le supprimer.",
@@ -944,7 +867,6 @@
"user_targeting_is_currently_only_available_when": "La ciblage des utilisateurs est actuellement disponible uniquement lorsque",
"value_cannot_be_empty": "La valeur ne peut pas être vide.",
"value_must_be_a_number": "La valeur doit être un nombre.",
"value_must_be_positive": "La valeur doit être un nombre positif.",
"view_filters": "Filtres de vue",
"where": "Où",
"with_the_formbricks_sdk": "avec le SDK Formbricks"
@@ -1031,32 +953,19 @@
"enterprise_features": "Fonctionnalités d'entreprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
"license_status": "Statut de la licence",
"license_status_active": "Active",
"license_status_description": "Statut de votre licence entreprise.",
"license_status_expired": "Expirée",
"license_status_invalid": "Licence invalide",
"license_status_unreachable": "Inaccessible",
"license_unreachable_grace_period": "Le serveur de licence est injoignable. Vos fonctionnalités entreprise restent actives pendant une période de grâce de 3 jours se terminant le {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Aucun appel nécessaire, aucune obligation : Demandez une licence d'essai gratuite de 30 jours pour tester toutes les fonctionnalités en remplissant ce formulaire :",
"no_credit_card_no_sales_call_just_test_it": "Aucune carte de crédit. Aucun appel de vente. Testez-le simplement :)",
"on_request": "Sur demande",
"organization_roles": "Rôles d'organisation (Administrateur, Éditeur, Développeur, etc.)",
"questions_please_reach_out_to": "Des questions ? Veuillez contacter",
"recheck_license": "Revérifier la licence",
"recheck_license_failed": "La vérification de la licence a échoué. Le serveur de licences est peut-être inaccessible.",
"recheck_license_invalid": "La clé de licence est invalide. Veuillez vérifier votre ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Vérification de la licence réussie",
"recheck_license_unreachable": "Le serveur de licences est inaccessible. Veuillez réessayer plus tard.",
"rechecking": "Revérification en cours...",
"request_30_day_trial_license": "Demander une licence d'essai de 30 jours",
"saml_sso": "SAML SSO",
"service_level_agreement": "Accord de niveau de service",
"soc2_hipaa_iso_27001_compliance_check": "Vérification de conformité SOC2, HIPAA, ISO 27001",
"sso": "SSO (Google, Microsoft, OpenID Connect)",
"teams": "Équipes et Rôles d'Accès (Lire, Lire et Écrire, Gérer)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours."
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours.",
"your_enterprise_license_is_active_all_features_unlocked": "Votre licence d'entreprise est active. Toutes les fonctionnalités sont déverrouillées."
},
"general": {
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
@@ -1080,7 +989,7 @@
"from_your_organization": "de votre organisation",
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
"invite_deleted_successfully": "Invitation supprimée avec succès",
"invite_expires_on": "L'invitation expire le {date}",
"invited_on": "Invité le {date}",
"invites_failed": "Invitations échouées",
"leave_organization": "Quitter l'organisation",
"leave_organization_description": "Vous quitterez cette organisation et perdrez l'accès à toutes les enquêtes et réponses. Vous ne pourrez revenir que si vous êtes de nouveau invité.",
@@ -1193,6 +1102,8 @@
"please_fill_all_workspace_fields": "Veuillez remplir tous les champs pour ajouter un nouvel espace de travail.",
"read": "Lire",
"read_write": "Lire et Écrire",
"select_member": "Sélectionner membre",
"select_workspace": "Sélectionner un espace de travail",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",
@@ -1242,6 +1153,7 @@
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
"add_hidden_field_id": "Ajouter un champ caché ID",
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
"add_logic": "Ajouter de la logique",
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
"add_option": "Ajouter une option",
@@ -1280,7 +1192,6 @@
"block_duplicated": "Bloc dupliqué.",
"bold": "Gras",
"brand_color": "Couleur de marque",
"brand_color_description": "Appliqué aux boutons, liens et éléments mis en évidence.",
"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.",
@@ -1298,9 +1209,7 @@
"capture_new_action": "Capturer une nouvelle action",
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
"card_background_color": "Couleur de fond de la carte",
"card_background_color_description": "Remplit la zone de la carte d'enquête.",
"card_border_color": "Couleur de la bordure de la carte",
"card_border_color_description": "Délimite la carte d'enquête.",
"card_styling": "Style de carte",
"casual": "Décontracté",
"caution_edit_duplicate": "Dupliquer et modifier",
@@ -1311,12 +1220,20 @@
"caution_explanation_responses_are_safe": "Les réponses anciennes et nouvelles se mélangent, ce qui peut entraîner des résumés de données trompeurs.",
"caution_recommendation": "Cela peut entraîner des incohérences de données dans le résumé du sondage. Nous recommandons de dupliquer le sondage à la place.",
"caution_text": "Les changements entraîneront des incohérences.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"change_anyway": "Changer de toute façon",
"change_background": "Changer l'arrière-plan",
"change_question_type": "Changer le type de question",
"change_survey_type": "Le changement de type de sondage affecte l'accès existant",
"change_the_background_color_of_the_card": "Changez la couleur de fond de la carte.",
"change_the_background_color_of_the_input_fields": "Vous pouvez modifier la couleur d'arrière-plan des champs de saisie.",
"change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.",
"change_the_border_color_of_the_card": "Changez la couleur de la bordure de la carte.",
"change_the_border_color_of_the_input_fields": "Vous pouvez modifier la couleur de la bordure des champs de saisie.",
"change_the_border_radius_of_the_card_and_the_inputs": "Vous pouvez arrondir la bordure des encadrés et des champs de saisie.",
"change_the_brand_color_of_the_survey": "Vous pouvez modifier la couleur dominante d'une enquête.",
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Vous pouvez modifier la couleur des questions d'une enquête.",
"changes_saved": "Modifications enregistrées.",
"changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.",
"checkbox_label": "Étiquette de case à cocher",
@@ -1356,7 +1273,6 @@
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
"display_type": "Type d'affichage",
"divide": "Diviser /",
"does_not_contain": "Ne contient pas",
"does_not_end_with": "Ne se termine pas par",
@@ -1364,7 +1280,6 @@
"does_not_include_all_of": "n'inclut pas tout",
"does_not_include_one_of": "n'inclut pas un de",
"does_not_start_with": "Ne commence pas par",
"dropdown": "Menu déroulant",
"duplicate_block": "Dupliquer le bloc",
"duplicate_question": "Dupliquer la question",
"edit_link": "Modifier le lien",
@@ -1456,6 +1371,7 @@
"hide_progress_bar": "Cacher la barre de progression",
"hide_question_settings": "Masquer les paramètres de la question",
"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 avez besoin de plus, veuillez",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
@@ -1466,9 +1382,7 @@
"initial_value": "Valeur initiale",
"inner_text": "Texte interne",
"input_border_color": "Couleur de la bordure des champs de saisie",
"input_border_color_description": "Délimite les champs de texte et les zones de texte.",
"input_color": "Couleur d'arrière-plan des champs de saisie",
"input_color_description": "Remplit l'intérieur des champs de texte.",
"insert_link": "Insérer un lien",
"invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience",
"invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.",
@@ -1498,7 +1412,6 @@
"limit_the_maximum_file_size": "Limiter la taille maximale des fichiers pour les téléversements.",
"limit_upload_file_size_to": "Limiter la taille de téléversement des fichiers à",
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
"list": "Liste",
"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.",
@@ -1552,12 +1465,13 @@
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
"publish": "Publier",
"question": "Question",
"question_color": "Couleur des questions",
"question_deleted": "Question supprimée.",
"question_duplicated": "Question dupliquée.",
"question_id_updated": "ID de la question mis à jour",
"question_used_in_logic_warning_text": "Des éléments de ce bloc sont utilisés dans une règle logique, êtes-vous sûr de vouloir le supprimer?",
"question_used_in_logic_warning_title": "Incohérence de logique",
"question_used_in_quota": "Cette question est utilisée dans le quota {quotaName}",
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
"quotas": {
@@ -1613,7 +1527,6 @@
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse",
"roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins de la carte.",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"rows": "Lignes",
"save_and_close": "Enregistrer et fermer",
@@ -1655,6 +1568,7 @@
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
"subheading": "Sous-titre",
"subtract": "Soustraire -",
"suggest_colors": "Suggérer des couleurs",
"survey_completed_heading": "Enquête terminée",
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête",
@@ -1731,7 +1645,7 @@
"validation_rules": "Règles de validation",
"validation_rules_description": "Accepter uniquement les réponses qui répondent aux critères suivants",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable {variableName} est utilisée dans le quota {quotaName}",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "Le nom de la variable est en conflit avec un ID de champ masqué existant.",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
@@ -1957,7 +1871,6 @@
"filtered_responses_excel": "Réponses filtrées (Excel)",
"generating_qr_code": "Génération du code QR",
"impressions": "Impressions",
"impressions_identified_only": "Affichage uniquement des impressions des contacts identifiés",
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
"in_app": {
"connection_description": "Le sondage sera affiché aux utilisateurs de votre site web, qui correspondent aux critères listés ci-dessous",
@@ -2000,7 +1913,6 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"limit": "Limite",
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
@@ -2140,71 +2052,9 @@
"look": {
"add_background_color": "Ajouter une couleur d'arrière-plan",
"add_background_color_description": "Ajoutez une couleur d'arrière-plan au conteneur du logo.",
"advanced_styling_field_border_radius": "Rayon de bordure",
"advanced_styling_field_button_bg": "Arrière-plan du bouton",
"advanced_styling_field_button_bg_description": "Remplit le bouton Suivant / Envoyer.",
"advanced_styling_field_button_border_radius_description": "Arrondit les coins du bouton.",
"advanced_styling_field_button_font_size_description": "Ajuste la taille du texte du libellé du bouton.",
"advanced_styling_field_button_font_weight_description": "Rend le texte du bouton plus léger ou plus gras.",
"advanced_styling_field_button_height_description": "Contrôle la hauteur du bouton.",
"advanced_styling_field_button_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
"advanced_styling_field_button_padding_y_description": "Ajoute de l'espace en haut et en bas.",
"advanced_styling_field_button_text": "Texte du bouton",
"advanced_styling_field_button_text_description": "Colore le libellé à l'intérieur des boutons.",
"advanced_styling_field_description_color": "Couleur de la description",
"advanced_styling_field_description_color_description": "Colore le texte sous chaque titre.",
"advanced_styling_field_description_size": "Taille de police de la description",
"advanced_styling_field_description_size_description": "Ajuste la taille du texte de description.",
"advanced_styling_field_description_weight": "Graisse de police de la description",
"advanced_styling_field_description_weight_description": "Rend le texte de description plus léger ou plus gras.",
"advanced_styling_field_font_size": "Taille de police",
"advanced_styling_field_font_weight": "Graisse de police",
"advanced_styling_field_headline_color": "Couleur du titre",
"advanced_styling_field_headline_color_description": "Colore le texte principal de la question.",
"advanced_styling_field_headline_size": "Taille de police du titre",
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
"advanced_styling_field_headline_weight": "Graisse de police du titre",
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
"advanced_styling_field_height": "Hauteur minimale",
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
"advanced_styling_field_input_shadow_description": "Ajoute une ombre portée autour des champs de saisie.",
"advanced_styling_field_input_text": "Texte de saisie",
"advanced_styling_field_input_text_description": "Colore le texte saisi dans les champs.",
"advanced_styling_field_option_bg": "Arrière-plan",
"advanced_styling_field_option_bg_description": "Remplit les éléments d'option.",
"advanced_styling_field_option_border_radius_description": "Arrondit les coins des options.",
"advanced_styling_field_option_font_size_description": "Ajuste la taille du texte des libellés d'option.",
"advanced_styling_field_option_label": "Couleur de l'étiquette",
"advanced_styling_field_option_label_description": "Colore le texte des libellés d'option.",
"advanced_styling_field_option_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
"advanced_styling_field_option_padding_y_description": "Ajoute de l'espace en haut et en bas.",
"advanced_styling_field_padding_x": "Marge intérieure X",
"advanced_styling_field_padding_y": "Marge intérieure Y",
"advanced_styling_field_placeholder_opacity": "Opacité du placeholder",
"advanced_styling_field_shadow": "Ombre",
"advanced_styling_field_track_bg": "Arrière-plan de la piste",
"advanced_styling_field_track_bg_description": "Colore la partie non remplie de la barre.",
"advanced_styling_field_track_height": "Hauteur de la piste",
"advanced_styling_field_track_height_description": "Contrôle l'épaisseur de la barre de progression.",
"advanced_styling_field_upper_label_color": "Couleur de l'étiquette du titre",
"advanced_styling_field_upper_label_color_description": "Colore le petit libellé au-dessus des champs de saisie.",
"advanced_styling_field_upper_label_size": "Taille de police de l'étiquette du titre",
"advanced_styling_field_upper_label_size_description": "Ajuste la taille du petit libellé au-dessus des champs de saisie.",
"advanced_styling_field_upper_label_weight": "Graisse de police de l'étiquette du titre",
"advanced_styling_field_upper_label_weight_description": "Rend le libellé plus léger ou plus gras.",
"advanced_styling_section_buttons": "Boutons",
"advanced_styling_section_headlines": "Titres et descriptions",
"advanced_styling_section_inputs": "Champs de saisie",
"advanced_styling_section_options": "Options (boutons radio/cases à cocher)",
"app_survey_placement": "Placement du sondage d'application",
"app_survey_placement_settings_description": "Modifiez l'emplacement où les sondages seront affichés dans votre application web ou site web.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"email_customization": "Personnalisation des e-mails",
"email_customization_description": "Modifiez l'apparence des e-mails que Formbricks envoie en votre nom.",
"enable_custom_styling": "Activer le style personnalisé",
@@ -2215,9 +2065,6 @@
"formbricks_branding_hidden": "Le logo Formbricks est masqué.",
"formbricks_branding_settings_description": "Nous apprécions votre soutien mais comprenons si vous choisissez de le désactiver.",
"formbricks_branding_shown": "Le logo Formbricks est affiché.",
"generate_theme_btn": "Générer",
"generate_theme_confirmation": "Souhaitez-vous générer un thème de couleurs assorti basé sur votre couleur de marque? Cela écrasera vos paramètres de couleur actuels.",
"generate_theme_header": "Générer un thème de couleurs?",
"logo_removed_successfully": "Logo supprimé avec succès",
"logo_settings_description": "Téléchargez le logo de votre entreprise pour personnaliser les enquêtes et les aperçus de liens.",
"logo_updated_successfully": "Logo mis à jour avec succès",
@@ -2232,8 +2079,6 @@
"show_formbricks_branding_in": "Afficher le logo Formbricks dans les enquêtes {type}",
"show_powered_by_formbricks": "Afficher la signature «Propulsé par Formbricks»",
"styling_updated_successfully": "Style mis à jour avec succès",
"suggest_colors": "Suggérer des couleurs",
"suggested_colors_applied_please_save": "Couleurs suggérées générées avec succès. Appuyez sur «Enregistrer» pour conserver les modifications.",
"theme": "Thème",
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
},
@@ -2997,7 +2842,6 @@
"preview_survey_question_2_choice_1_label": "Oui, tenez-moi au courant.",
"preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_question_2_subheader": "Ceci est un exemple de description.",
"preview_survey_welcome_card_headline": "Bienvenue !",
"prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.",
"prioritize_features_name": "Prioriser les fonctionnalités",

File diff suppressed because it is too large Load Diff

View File

@@ -188,7 +188,6 @@
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"date": "日付",
"days": "日",
"default": "デフォルト",
"delete": "削除",
"description": "説明",
@@ -218,7 +217,6 @@
"error": "エラー",
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
"error_component_title": "リソース の 読み込み エラー",
"error_loading_data": "データの読み込みエラー",
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
"error_rate_limit_title": "レート制限を超えました",
"expand_rows": "行を展開",
@@ -277,7 +275,6 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
"months": "ヶ月",
"move_down": "下に移動",
"move_up": "上に移動",
"multiple_languages": "多言語",
@@ -288,7 +285,6 @@
"no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません",
"no_overlay": "オーバーレイなし",
"no_quotas_found": "クォータが見つかりません",
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
@@ -315,7 +311,6 @@
"organization_teams_not_found": "組織のチームが見つかりません",
"other": "その他",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
"paused": "一時停止",
@@ -355,7 +350,6 @@
"request_trial_license": "トライアルライセンスをリクエスト",
"reset_to_default": "デフォルトにリセット",
"response": "回答",
"response_id": "回答ID",
"responses": "回答",
"restart": "再開",
"role": "役割",
@@ -396,7 +390,6 @@
"status": "ステータス",
"step_by_step_manual": "ステップバイステップマニュアル",
"storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります",
"string": "テキスト",
"styling": "スタイル",
"submit": "送信",
"summary": "概要",
@@ -429,7 +422,6 @@
"top_right": "右上",
"try_again": "もう一度お試しください",
"type": "種類",
"unknown_survey": "不明なフォーム",
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
"update": "更新",
"updated": "更新済み",
@@ -453,7 +445,6 @@
"website_and_app_connection": "ウェブサイト&アプリ接続",
"website_app_survey": "ウェブサイト&アプリフォーム",
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
@@ -464,7 +455,6 @@
"workspace_not_found": "ワークスペースが見つかりません",
"workspace_permission_not_found": "ワークスペースの権限が見つかりません",
"workspaces": "ワークスペース",
"years": "年",
"you": "あなた",
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
@@ -636,45 +626,28 @@
"attribute_updated_successfully": "属性を更新しました",
"attribute_value": "値",
"attribute_value_placeholder": "属性値",
"attributes_msg_attribute_limit_exceeded": "最大制限の{limit}個の属性クラスを超えるため、{count}個の新しい属性を作成できませんでした。既存の属性は正常に更新されました。",
"attributes_msg_attribute_type_validation_error": "{error}(属性'{key}'のデータ型: {dataType}",
"attributes_msg_email_already_exists": "このメールアドレスはこの環境に既に存在するため、更新されませんでした。",
"attributes_msg_email_or_userid_required": "メールアドレスまたはユーザーIDのいずれかが必要です。既存の値は保持されました。",
"attributes_msg_new_attribute_created": "新しい属性'{key}'をタイプ'{dataType}'で作成しました",
"attributes_msg_userid_already_exists": "このユーザーIDはこの環境に既に存在するため、更新されませんでした。",
"contact_deleted_successfully": "連絡先を正常に削除しました",
"contact_not_found": "そのような連絡先は見つかりません",
"contacts_table_refresh": "連絡先を更新",
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"create_attribute": "属性を作成",
"create_key": "キーを作成",
"create_new_attribute": "新しい属性を作成",
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
"custom_attributes": "カスタム属性",
"data_type": "データ型",
"data_type_cannot_be_changed": "データ型は作成後に変更できません",
"data_type_description": "この属性の保存方法とフィルタリング方法を選択してください",
"date_value_required": "日付の値が必要です。日付を設定したくない場合は、削除ボタンを使用してこの属性を削除してください。",
"delete_attribute_confirmation": "{value, plural, one {選択した属性を削除します。この属性に関連付けられたすべてのコンタクトデータは失われます。} other {選択した属性を削除します。これらの属性に関連付けられたすべてのコンタクトデータは失われます。}}",
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
"displays": "表示回数",
"edit_attribute": "属性を編集",
"edit_attribute_description": "この属性のラベルと説明を更新します。",
"edit_attribute_values": "属性を編集",
"edit_attribute_values_description": "この連絡先の特定の属性の値を変更します。",
"edit_attributes": "属性を編集",
"edit_attributes_success": "連絡先属性が正常に更新されました",
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
"no_activity_yet": "まだアクティビティがありません",
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
"no_published_surveys": "公開されたフォームはありません",
"no_responses_found": "回答が見つかりません",
"not_provided": "提供されていません",
"number_value_required": "数値が必要です。この属性を削除するには削除ボタンを使用してください。",
"personal_link_generated": "個人リンクが正常に生成されました",
"personal_link_generated_but_clipboard_failed": "個人用リンクは生成されましたが、クリップボードへのコピーに失敗しました: {url}",
"personal_survey_link": "個人調査リンク",
@@ -683,24 +656,13 @@
"search_contact": "連絡先を検索",
"select_a_survey": "フォームを選択",
"select_attribute": "属性を選択",
"select_attribute_key": "属性キーを選択",
"survey_viewed": "フォームを閲覧",
"survey_viewed_at": "閲覧日時",
"system_attributes": "システム属性",
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
"unlock_contacts_title": "上位プランで連絡先をアンロック",
"upload_contacts_error_attribute_type_mismatch": "属性「{key}」は「{dataType}」として型付けされていますが、CSVに無効な値が含まれています{values}",
"upload_contacts_error_duplicate_mappings": "次の属性に重複したマッピングが見つかりました:{attributes}",
"upload_contacts_error_file_too_large": "ファイルサイズが最大制限の800KBを超えています",
"upload_contacts_error_generic": "連絡先のアップロード中にエラーが発生しました。後でもう一度お試しください。",
"upload_contacts_error_invalid_file_type": "CSVファイルをアップロードしてください",
"upload_contacts_error_no_valid_contacts": "アップロードされたCSVファイルには有効な連絡先が含まれていません。正しい形式についてはサンプルCSVファイルをご確認ください。",
"upload_contacts_modal_attribute_header": "Formbricks属性",
"upload_contacts_modal_attributes_description": "CSVの列をFormbricksの属性にマッピングします。",
"upload_contacts_modal_attributes_new": "新しい属性",
"upload_contacts_modal_attributes_search_or_add": "属性を検索または追加",
"upload_contacts_modal_attributes_should_be_mapped_to": "は以下にマッピングする必要があります",
"upload_contacts_modal_attributes_title": "属性",
"upload_contacts_modal_csv_column_header": "CSV列",
"upload_contacts_modal_description": "CSVをアップロードして、属性を持つ連絡先をすばやくインポート",
"upload_contacts_modal_download_example_csv": "CSVの例をダウンロード",
"upload_contacts_modal_duplicates_description": "連絡先がすでに存在する場合、どのように処理しますか?",
@@ -757,12 +719,7 @@
"link_google_sheet": "スプレッドシートをリンク",
"link_new_sheet": "新しいシートをリンク",
"no_integrations_yet": "Google スプレッドシート連携は、追加するとここに表示されます。⏲️",
"reconnect_button": "再接続",
"reconnect_button_description": "Google Sheetsの接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のスプレッドシートリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のスプレッドシートリンクとデータは保持されます。",
"spreadsheet_permission_error": "このスプレッドシートにアクセスする権限がありません。スプレッドシートがGoogleアカウントと共有されており、書き込みアクセス権があることを確認してください。",
"spreadsheet_url": "スプレッドシートURL",
"token_expired_error": "Google Sheetsのリフレッシュトークンが期限切れになったか、取り消されました。統合を再接続してください。"
"spreadsheet_url": "スプレッドシートURL"
},
"include_created_at": "作成日時を含める",
"include_hidden_fields": "非表示フィールドを含める",
@@ -886,40 +843,6 @@
"no_attributes_yet": "属性がまだありません!",
"no_filters_yet": "フィルターはまだありません!",
"no_segments_yet": "保存されたセグメントはまだありません。",
"operator_contains": "を含む",
"operator_does_not_contain": "を含まない",
"operator_ends_with": "で終わる",
"operator_is_after": "より後",
"operator_is_before": "より前",
"operator_is_between": "の間である",
"operator_is_newer_than": "より新しい",
"operator_is_not_set": "設定されていない",
"operator_is_older_than": "より古い",
"operator_is_same_day": "同じ日である",
"operator_is_set": "設定されている",
"operator_starts_with": "で始まる",
"operator_title_contains": "を含む",
"operator_title_does_not_contain": "を含まない",
"operator_title_ends_with": "で終わる",
"operator_title_equals": "と等しい",
"operator_title_greater_equal": "以上",
"operator_title_greater_than": "より大きい",
"operator_title_is_after": "より後",
"operator_title_is_before": "より前",
"operator_title_is_between": "の間である",
"operator_title_is_newer_than": "より新しい",
"operator_title_is_not_set": "設定されていない",
"operator_title_is_older_than": "より古い",
"operator_title_is_same_day": "同じ日である",
"operator_title_is_set": "設定されている",
"operator_title_less_equal": "以下",
"operator_title_less_than": "より小さい",
"operator_title_not_equals": "等しくない",
"operator_title_starts_with": "で始まる",
"operator_title_user_is_in": "ユーザーが含まれる",
"operator_title_user_is_not_in": "ユーザーが含まれない",
"operator_user_is_in": "ユーザーが含まれる",
"operator_user_is_not_in": "ユーザーが含まれない",
"person_and_attributes": "人物と属性",
"phone": "電話",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "このセグメントを削除するには、まず以下のフォームから外してください。",
@@ -944,7 +867,6 @@
"user_targeting_is_currently_only_available_when": "ユーザーターゲティングは現在、利用条件を満たす場合のみ利用可能です",
"value_cannot_be_empty": "値は空にできません。",
"value_must_be_a_number": "値は数値である必要があります。",
"value_must_be_positive": "値は正の数である必要があります。",
"view_filters": "フィルターを表示",
"where": "条件",
"with_the_formbricks_sdk": "Formbricks SDK を利用して"
@@ -1031,32 +953,19 @@
"enterprise_features": "エンタープライズ機能",
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
"license_status": "ライセンスステータス",
"license_status_active": "有効",
"license_status_description": "エンタープライズライセンスのステータス。",
"license_status_expired": "期限切れ",
"license_status_invalid": "無効なライセンス",
"license_status_unreachable": "接続不可",
"license_unreachable_grace_period": "ライセンスサーバーに接続できません。エンタープライズ機能は{gracePeriodEnd}までの3日間の猶予期間中は引き続き利用できます。",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "電話不要、制約なし: このフォームに記入して、すべての機能をテストするための無料の30日間トライアルライセンスをリクエストしてください:",
"no_credit_card_no_sales_call_just_test_it": "クレジットカード不要。営業電話もありません。ただテストしてください :)",
"on_request": "リクエストに応じて",
"organization_roles": "組織ロール(管理者、編集者、開発者など)",
"questions_please_reach_out_to": "質問はありますか?こちらまでお問い合わせください",
"recheck_license": "ライセンスを再確認",
"recheck_license_failed": "ライセンスの確認に失敗しました。ライセンスサーバーに接続できない可能性があります。",
"recheck_license_invalid": "ライセンスキーが無効です。ENTERPRISE_LICENSE_KEYを確認してください。",
"recheck_license_success": "ライセンスの確認に成功しました",
"recheck_license_unreachable": "ライセンスサーバーに接続できません。後ほど再度お試しください。",
"rechecking": "再確認中...",
"request_30_day_trial_license": "30日間トライアルライセンスをリクエスト",
"saml_sso": "SAML SSO",
"service_level_agreement": "サービスレベル契約",
"soc2_hipaa_iso_27001_compliance_check": "SOC2、HIPAA、ISO 27001準拠チェック",
"sso": "SSOGoogle、Microsoft、OpenID Connect",
"teams": "チーム&アクセスロール(読み取り、読み書き、管理)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。"
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。",
"your_enterprise_license_is_active_all_features_unlocked": "あなたのエンタープライズライセンスは有効です。すべての機能がアンロックされました。"
},
"general": {
"bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。",
@@ -1080,7 +989,7 @@
"from_your_organization": "あなたの組織から",
"invitation_sent_once_more": "招待状を再度送信しました。",
"invite_deleted_successfully": "招待を正常に削除しました",
"invite_expires_on": "招待は{date}に期限切れ",
"invited_on": "{date}に招待",
"invites_failed": "招待に失敗しました",
"leave_organization": "組織を離れる",
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
@@ -1193,6 +1102,8 @@
"please_fill_all_workspace_fields": "新しいワークスペースを追加するには、すべてのフィールドを入力してください。",
"read": "読み取り",
"read_write": "読み書き",
"select_member": "メンバーを選択",
"select_workspace": "ワークスペースを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",
@@ -1242,6 +1153,7 @@
"add_fallback_placeholder": "質問がスキップされた場合に表示するプレースホルダーを追加:",
"add_hidden_field_id": "非表示フィールドIDを追加",
"add_highlight_border": "ハイライトボーダーを追加",
"add_highlight_border_description": "フォームカードに外側のボーダーを追加します。",
"add_logic": "ロジックを追加",
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
"add_option": "オプションを追加",
@@ -1280,7 +1192,6 @@
"block_duplicated": "ブロックが複製されました。",
"bold": "太字",
"brand_color": "ブランドカラー",
"brand_color_description": "ボタン、リンク、ハイライトに適用されます。",
"brightness": "明るさ",
"bulk_edit": "一括編集",
"bulk_edit_description": "以下のオプションを1行ずつ編集してください。空の行はスキップされ、重複は削除されます。",
@@ -1298,9 +1209,7 @@
"capture_new_action": "新しいアクションをキャプチャ",
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
"card_background_color": "カードの背景色",
"card_background_color_description": "フォームカードエリアを塗りつぶします。",
"card_border_color": "カードの枠線の色",
"card_border_color_description": "フォームカードの輪郭を描きます。",
"card_styling": "カードのスタイル設定",
"casual": "カジュアル",
"caution_edit_duplicate": "複製して編集",
@@ -1311,12 +1220,20 @@
"caution_explanation_responses_are_safe": "古い回答と新しい回答が混ざり、データの概要が誤解を招く可能性があります。",
"caution_recommendation": "これにより、フォームの概要にデータの不整合が生じる可能性があります。代わりにフォームを複製することをお勧めします。",
"caution_text": "変更は不整合を引き起こします",
"centered_modal_overlay_color": "中央モーダルのオーバーレイ色",
"change_anyway": "とにかく変更",
"change_background": "背景を変更",
"change_question_type": "質問の種類を変更",
"change_survey_type": "フォームの種類を変更すると、既存のアクセスに影響します",
"change_the_background_color_of_the_card": "カードの背景色を変更します。",
"change_the_background_color_of_the_input_fields": "入力フィールドの背景色を変更します。",
"change_the_background_to_a_color_image_or_animation": "背景を色、画像、またはアニメーションに変更します。",
"change_the_border_color_of_the_card": "カードの枠線の色を変更します。",
"change_the_border_color_of_the_input_fields": "入力フィールドの枠線の色を変更します。",
"change_the_border_radius_of_the_card_and_the_inputs": "カードと入力の角丸を変更します。",
"change_the_brand_color_of_the_survey": "フォームのブランドカラーを変更します。",
"change_the_placement_of_this_survey": "このフォームの配置を変更します。",
"change_the_question_color_of_the_survey": "フォームの質問の色を変更します。",
"changes_saved": "変更を保存しました。",
"changing_survey_type_will_remove_existing_distribution_channels": "フォームの種類を変更すると、共有方法に影響します。回答者が現在のタイプのアクセスリンクをすでに持っている場合、切り替え後にアクセスを失う可能性があります。",
"checkbox_label": "チェックボックスのラベル",
@@ -1356,7 +1273,6 @@
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
"display_number_of_responses_for_survey": "フォームの回答数を表示",
"display_type": "表示タイプ",
"divide": "除算 /",
"does_not_contain": "を含まない",
"does_not_end_with": "で終わらない",
@@ -1364,7 +1280,6 @@
"does_not_include_all_of": "のすべてを含まない",
"does_not_include_one_of": "のいずれも含まない",
"does_not_start_with": "で始まらない",
"dropdown": "ドロップダウン",
"duplicate_block": "ブロックを複製",
"duplicate_question": "質問を複製",
"edit_link": "編集 リンク",
@@ -1456,6 +1371,7 @@
"hide_progress_bar": "プログレスバーを非表示",
"hide_question_settings": "質問設定を非表示",
"hostname": "ホスト名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "{surveyTypeDerived} フォームのカードをどれくらいユニークにしますか",
"if_you_need_more_please": "さらに必要な場合は、",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
"ignore_global_waiting_time": "クールダウン期間を無視",
@@ -1466,9 +1382,7 @@
"initial_value": "初期値",
"inner_text": "内部テキスト",
"input_border_color": "入力の枠線の色",
"input_border_color_description": "テキスト入力とテキストエリアの輪郭を描きます。",
"input_color": "入力の色",
"input_color_description": "テキスト入力の内側を塗りつぶします。",
"insert_link": "リンク を 挿入",
"invalid_targeting": "無効なターゲティング: オーディエンスフィルターを確認してください",
"invalid_video_url_warning": "有効なYouTube、Vimeo、またはLoomのURLを入力してください。現在、他の動画ホスティングプロバイダーはサポートしていません。",
@@ -1498,7 +1412,6 @@
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
"list": "リスト",
"load_segment": "セグメントを読み込み",
"logic_error_warning": "変更するとロジックエラーが発生します",
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
@@ -1552,12 +1465,13 @@
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
"publish": "公開",
"question": "質問",
"question_color": "質問の色",
"question_deleted": "質問を削除しました。",
"question_duplicated": "質問を複製しました。",
"question_id_updated": "質問IDを更新しました",
"question_used_in_logic_warning_text": "このブロックの要素はロジックルールで使用されていますが、本当に削除しますか?",
"question_used_in_logic_warning_title": "ロジックの不整合",
"question_used_in_quota": "この質問は“{quotaName}”クォータで使用されています",
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
"quotas": {
@@ -1613,7 +1527,6 @@
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション",
"roundness": "丸み",
"roundness_description": "カードの角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"rows": "行",
"save_and_close": "保存して閉じる",
@@ -1655,6 +1568,7 @@
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
"subheading": "サブ見出し",
"subtract": "減算 -",
"suggest_colors": "色を提案",
"survey_completed_heading": "フォームが完了しました",
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定",
@@ -1731,7 +1645,7 @@
"validation_rules": "検証ルール",
"validation_rules_description": "次の条件を満たす回答のみを受け付ける",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数{variableName}”は“{quotaName}クォータで使用されています",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_conflicts_with_hidden_field": "変数名が既存の非表示フィールドIDと競合しています。",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
@@ -1957,7 +1871,6 @@
"filtered_responses_excel": "フィルター済み回答 (Excel)",
"generating_qr_code": "QRコードを生成中",
"impressions": "表示回数",
"impressions_identified_only": "識別済みコンタクトからのインプレッションのみを表示しています",
"impressions_tooltip": "フォームが表示された回数。",
"in_app": {
"connection_description": "このフォームは、以下の条件に一致するあなたのウェブサイトのユーザーに表示されます",
@@ -2000,7 +1913,6 @@
"last_quarter": "前四半期",
"last_year": "昨年",
"limit": "制限",
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
"no_responses_found": "回答が見つかりません",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
@@ -2140,71 +2052,9 @@
"look": {
"add_background_color": "背景色を追加",
"add_background_color_description": "ロゴコンテナに背景色を追加します。",
"advanced_styling_field_border_radius": "境界線の丸み",
"advanced_styling_field_button_bg": "ボタンの背景",
"advanced_styling_field_button_bg_description": "次へ/送信ボタンを塗りつぶします。",
"advanced_styling_field_button_border_radius_description": "ボタンの角を丸めます。",
"advanced_styling_field_button_font_size_description": "ボタンラベルのテキストサイズを調整します。",
"advanced_styling_field_button_font_weight_description": "ボタンテキストを細くまたは太くします。",
"advanced_styling_field_button_height_description": "ボタンの高さを調整します。",
"advanced_styling_field_button_padding_x_description": "左右にスペースを追加します。",
"advanced_styling_field_button_padding_y_description": "上下にスペースを追加します。",
"advanced_styling_field_button_text": "ボタンのテキスト",
"advanced_styling_field_button_text_description": "ボタン内のラベルに色を付けます。",
"advanced_styling_field_description_color": "説明文の色",
"advanced_styling_field_description_color_description": "各見出しの下のテキストに色を付けます。",
"advanced_styling_field_description_size": "説明文のフォントサイズ",
"advanced_styling_field_description_size_description": "説明テキストのサイズを調整します。",
"advanced_styling_field_description_weight": "説明文のフォントの太さ",
"advanced_styling_field_description_weight_description": "説明テキストを細くまたは太くします。",
"advanced_styling_field_font_size": "フォントサイズ",
"advanced_styling_field_font_weight": "フォントの太さ",
"advanced_styling_field_headline_color": "見出しの色",
"advanced_styling_field_headline_color_description": "メインの質問テキストに色を付けます。",
"advanced_styling_field_headline_size": "見出しのフォントサイズ",
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
"advanced_styling_field_height": "最小の高さ",
"advanced_styling_field_indicator_bg": "インジケーターの背景",
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
"advanced_styling_field_input_shadow_description": "入力フィールドの周囲にドロップシャドウを追加します。",
"advanced_styling_field_input_text": "入力テキスト",
"advanced_styling_field_input_text_description": "入力フィールドに入力されたテキストの色を設定します。",
"advanced_styling_field_option_bg": "背景",
"advanced_styling_field_option_bg_description": "オプション項目を塗りつぶします。",
"advanced_styling_field_option_border_radius_description": "オプションの角を丸くします。",
"advanced_styling_field_option_font_size_description": "オプションラベルのテキストサイズを調整します。",
"advanced_styling_field_option_label": "ラベルの色",
"advanced_styling_field_option_label_description": "オプションラベルのテキストの色を設定します。",
"advanced_styling_field_option_padding_x_description": "左右にスペースを追加します。",
"advanced_styling_field_option_padding_y_description": "上下にスペースを追加します。",
"advanced_styling_field_padding_x": "パディングX",
"advanced_styling_field_padding_y": "パディングY",
"advanced_styling_field_placeholder_opacity": "プレースホルダーの不透明度",
"advanced_styling_field_shadow": "影",
"advanced_styling_field_track_bg": "トラックの背景",
"advanced_styling_field_track_bg_description": "バーの未入力部分の色を設定します。",
"advanced_styling_field_track_height": "トラックの高さ",
"advanced_styling_field_track_height_description": "プログレスバーの太さを調整します。",
"advanced_styling_field_upper_label_color": "見出しラベルの色",
"advanced_styling_field_upper_label_color_description": "入力フィールド上部の小さなラベルの色を設定します。",
"advanced_styling_field_upper_label_size": "見出しラベルのフォントサイズ",
"advanced_styling_field_upper_label_size_description": "入力フィールド上部の小さなラベルのサイズを調整します。",
"advanced_styling_field_upper_label_weight": "見出しラベルのフォントの太さ",
"advanced_styling_field_upper_label_weight_description": "ラベルを細くまたは太くします。",
"advanced_styling_section_buttons": "ボタン",
"advanced_styling_section_headlines": "見出しと説明",
"advanced_styling_section_inputs": "入力フィールド",
"advanced_styling_section_options": "選択肢(ラジオボタン/チェックボックス)",
"app_survey_placement": "アプリ内フォームの配置",
"app_survey_placement_settings_description": "Webアプリまたはウェブサイトでフォームを表示する場所を変更します。",
"centered_modal_overlay_color": "中央モーダルのオーバーレイ色",
"email_customization": "メールのカスタマイズ",
"email_customization_description": "Formbricksがあなたに代わって送信するメールの外観を変更します。",
"enable_custom_styling": "カスタムスタイルを有効化",
@@ -2215,9 +2065,6 @@
"formbricks_branding_hidden": "Formbricksブランディングは非表示です。",
"formbricks_branding_settings_description": "あなたのサポートに感謝していますが、オフにすることもご理解いただけます。",
"formbricks_branding_shown": "Formbricksブランディングは表示されています。",
"generate_theme_btn": "生成",
"generate_theme_confirmation": "ブランドカラーに基づいて、マッチするカラーテーマを生成しますか?現在のカラー設定は上書きされます。",
"generate_theme_header": "カラーテーマを生成しますか?",
"logo_removed_successfully": "ロゴを正常に削除しました",
"logo_settings_description": "会社のロゴをアップロードして、アンケートとリンクプレビューにブランディングを適用します。",
"logo_updated_successfully": "ロゴを正常に更新しました",
@@ -2232,8 +2079,6 @@
"show_formbricks_branding_in": "{type}アンケートにFormbricksブランディングを表示",
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
"styling_updated_successfully": "スタイルを正常に更新しました",
"suggest_colors": "カラーを提案",
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには「保存」を押してください。",
"theme": "テーマ",
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
},
@@ -2997,7 +2842,6 @@
"preview_survey_question_2_choice_1_label": "はい、最新情報を知りたいです。",
"preview_survey_question_2_choice_2_label": "いいえ、結構です!",
"preview_survey_question_2_headline": "最新情報を知りたいですか?",
"preview_survey_question_2_subheader": "これは説明の例です。",
"preview_survey_welcome_card_headline": "ようこそ!",
"prioritize_features_description": "ユーザーが最も必要とする機能と最も必要としない機能を特定する。",
"prioritize_features_name": "機能の優先順位付け",

View File

@@ -188,7 +188,6 @@
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
"delete": "Verwijderen",
"description": "Beschrijving",
@@ -218,7 +217,6 @@
"error": "Fout",
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
"error_component_title": "Fout bij het laden van bronnen",
"error_loading_data": "Fout bij het laden van gegevens",
"error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.",
"error_rate_limit_title": "Tarieflimiet overschreden",
"expand_rows": "Vouw rijen uit",
@@ -277,7 +275,6 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
"months": "maanden",
"move_down": "Ga naar beneden",
"move_up": "Ga omhoog",
"multiple_languages": "Meerdere talen",
@@ -288,7 +285,6 @@
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
"no_code": "Geen code",
"no_files_uploaded": "Er zijn geen bestanden geüpload",
"no_overlay": "Geen overlay",
"no_quotas_found": "Geen quota gevonden",
"no_result_found": "Geen resultaat gevonden",
"no_results": "Geen resultaten",
@@ -315,7 +311,6 @@
"organization_teams_not_found": "Organisatieteams niet gevonden",
"other": "Ander",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
"paused": "Gepauzeerd",
@@ -355,7 +350,6 @@
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"response": "Antwoord",
"response_id": "Antwoord-ID",
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"role": "Rol",
@@ -396,7 +390,6 @@
"status": "Status",
"step_by_step_manual": "Stap voor stap handleiding",
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
"string": "Tekst",
"styling": "Styling",
"submit": "Indienen",
"summary": "Samenvatting",
@@ -429,7 +422,6 @@
"top_right": "Rechtsboven",
"try_again": "Probeer het opnieuw",
"type": "Type",
"unknown_survey": "Onbekende enquête",
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
"update": "Update",
"updated": "Bijgewerkt",
@@ -453,7 +445,6 @@
"website_and_app_connection": "Website- en app-verbinding",
"website_app_survey": "Website- en app-enquête",
"website_survey": "Website-enquête",
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
@@ -464,7 +455,6 @@
"workspace_not_found": "Werkruimte niet gevonden",
"workspace_permission_not_found": "Werkruimte-machtiging niet gevonden",
"workspaces": "Werkruimtes",
"years": "jaren",
"you": "Jij",
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
@@ -636,45 +626,28 @@
"attribute_updated_successfully": "Attribuut succesvol bijgewerkt",
"attribute_value": "Waarde",
"attribute_value_placeholder": "Attribuutwaarde",
"attributes_msg_attribute_limit_exceeded": "Kon {count} nieuwe attribu(u)t(en) niet aanmaken omdat dit de maximale limiet van {limit} attribuutklassen zou overschrijden. Bestaande attributen zijn succesvol bijgewerkt.",
"attributes_msg_attribute_type_validation_error": "{error} (attribuut '{key}' heeft dataType: {dataType})",
"attributes_msg_email_already_exists": "Het e-mailadres bestaat al voor deze omgeving en is niet bijgewerkt.",
"attributes_msg_email_or_userid_required": "E-mailadres of userId is vereist. De bestaande waarden zijn behouden.",
"attributes_msg_new_attribute_created": "Nieuw attribuut '{key}' aangemaakt met type '{dataType}'",
"attributes_msg_userid_already_exists": "De userId bestaat al voor deze omgeving en is niet bijgewerkt.",
"contact_deleted_successfully": "Contact succesvol verwijderd",
"contact_not_found": "Er is geen dergelijk contact gevonden",
"contacts_table_refresh": "Vernieuw contacten",
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
"create_attribute": "Attribuut aanmaken",
"create_key": "Sleutel aanmaken",
"create_new_attribute": "Nieuw attribuut aanmaken",
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
"custom_attributes": "Aangepaste kenmerken",
"data_type": "Gegevenstype",
"data_type_cannot_be_changed": "Gegevenstype kan niet worden gewijzigd na aanmaak",
"data_type_description": "Kies hoe dit attribuut moet worden opgeslagen en gefilterd",
"date_value_required": "Datumwaarde is vereist. Gebruik de verwijderknop om dit attribuut te verwijderen als je geen datum wilt instellen.",
"delete_attribute_confirmation": "{value, plural, one {Dit verwijdert het geselecteerde attribuut. Alle contactgegevens die aan dit attribuut zijn gekoppeld, gaan verloren.} other {Dit verwijdert de geselecteerde attributen. Alle contactgegevens die aan deze attributen zijn gekoppeld, gaan verloren.}}",
"delete_contact_confirmation": "Hierdoor worden alle enquêtereacties en contactkenmerken verwijderd die aan dit contact zijn gekoppeld. Elke targeting en personalisatie op basis van de gegevens van dit contact gaat verloren.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dit verwijdert alle enquêteresultaten en contactattributen die aan dit contact zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van dit contact gaan verloren. Als dit contact reacties heeft die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.} other {Dit verwijdert alle enquêteresultaten en contactattributen die aan deze contacten zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van deze contacten gaan verloren. Als deze contacten reacties hebben die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.}}",
"displays": "Weergaven",
"edit_attribute": "Attribuut bewerken",
"edit_attribute_description": "Werk het label en de beschrijving voor dit attribuut bij.",
"edit_attribute_values": "Attributen bewerken",
"edit_attribute_values_description": "Wijzig de waarden voor specifieke attributen voor dit contact.",
"edit_attributes": "Attributen bewerken",
"edit_attributes_success": "Contactattributen succesvol bijgewerkt",
"generate_personal_link": "Persoonlijke link genereren",
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
"no_activity_yet": "Nog geen activiteit",
"no_published_link_surveys_available": "Geen gepubliceerde link-enquêtes beschikbaar. Publiceer eerst een link-enquête.",
"no_published_surveys": "Geen gepubliceerde enquêtes",
"no_responses_found": "Geen reacties gevonden",
"not_provided": "Niet voorzien",
"number_value_required": "Getalwaarde is verplicht. Gebruik de verwijderknop om dit attribuut te verwijderen.",
"personal_link_generated": "Persoonlijke link succesvol gegenereerd",
"personal_link_generated_but_clipboard_failed": "Persoonlijke link gegenereerd maar kopiëren naar klembord mislukt: {url}",
"personal_survey_link": "Persoonlijke enquêtelink",
@@ -683,24 +656,13 @@
"search_contact": "Zoek contactpersoon",
"select_a_survey": "Selecteer een enquête",
"select_attribute": "Selecteer Kenmerk",
"select_attribute_key": "Selecteer kenmerksleutel",
"survey_viewed": "Enquête bekeken",
"survey_viewed_at": "Bekeken op",
"system_attributes": "Systeemkenmerken",
"unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes",
"unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement",
"upload_contacts_error_attribute_type_mismatch": "Attribuut \"{key}\" is getypeerd als \"{dataType}\" maar CSV bevat ongeldige waarden: {values}",
"upload_contacts_error_duplicate_mappings": "Dubbele koppelingen gevonden voor de volgende attributen: {attributes}",
"upload_contacts_error_file_too_large": "Bestandsgrootte overschrijdt de maximale limiet van 800KB",
"upload_contacts_error_generic": "Er is een fout opgetreden bij het uploaden van de contacten. Probeer het later opnieuw.",
"upload_contacts_error_invalid_file_type": "Upload een CSV-bestand",
"upload_contacts_error_no_valid_contacts": "Het geüploade CSV-bestand bevat geen geldige contacten, zie het voorbeeld CSV-bestand voor het juiste formaat.",
"upload_contacts_modal_attribute_header": "Formbricks attribuut",
"upload_contacts_modal_attributes_description": "Wijs de kolommen in uw CSV toe aan de attributen in Formbricks.",
"upload_contacts_modal_attributes_new": "Nieuw attribuut",
"upload_contacts_modal_attributes_search_or_add": "Kenmerk zoeken of toevoegen",
"upload_contacts_modal_attributes_should_be_mapped_to": "in kaart moeten worden gebracht",
"upload_contacts_modal_attributes_title": "Kenmerken",
"upload_contacts_modal_csv_column_header": "CSV kolom",
"upload_contacts_modal_description": "Upload een CSV om snel contacten met attributen te importeren",
"upload_contacts_modal_download_example_csv": "Voorbeeld-CSV downloaden",
"upload_contacts_modal_duplicates_description": "Hoe moeten we omgaan als er al een contact bestaat in uw contacten?",
@@ -757,12 +719,7 @@
"link_google_sheet": "Link Google Spreadsheet",
"link_new_sheet": "Nieuw blad koppelen",
"no_integrations_yet": "Uw Google Spreadsheet-integraties verschijnen hier zodra u ze toevoegt. ⏲️",
"reconnect_button": "Maak opnieuw verbinding",
"reconnect_button_description": "Je Google Sheets-verbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van antwoorden. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
"reconnect_button_tooltip": "Maak opnieuw verbinding met de integratie om je toegang te vernieuwen. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
"spreadsheet_permission_error": "Je hebt geen toestemming om deze spreadsheet te openen. Zorg ervoor dat de spreadsheet is gedeeld met je Google-account en dat je schrijftoegang hebt tot de spreadsheet.",
"spreadsheet_url": "Spreadsheet-URL",
"token_expired_error": "Het vernieuwingstoken van Google Sheets is verlopen of ingetrokken. Maak opnieuw verbinding met de integratie."
"spreadsheet_url": "Spreadsheet-URL"
},
"include_created_at": "Inclusief gemaakt op",
"include_hidden_fields": "Inclusief verborgen velden",
@@ -886,40 +843,6 @@
"no_attributes_yet": "Nog geen attributen!",
"no_filters_yet": "Er zijn nog geen filters!",
"no_segments_yet": "Je hebt momenteel geen opgeslagen segmenten.",
"operator_contains": "bevat",
"operator_does_not_contain": "bevat niet",
"operator_ends_with": "eindigt met",
"operator_is_after": "is na",
"operator_is_before": "is eerder",
"operator_is_between": "is tussen",
"operator_is_newer_than": "is nieuwer dan",
"operator_is_not_set": "is niet ingesteld",
"operator_is_older_than": "is ouder dan",
"operator_is_same_day": "is dezelfde dag",
"operator_is_set": "is ingesteld",
"operator_starts_with": "begint met",
"operator_title_contains": "Bevat",
"operator_title_does_not_contain": "Bevat niet",
"operator_title_ends_with": "Eindigt met",
"operator_title_equals": "Gelijk aan",
"operator_title_greater_equal": "Groter dan of gelijk aan",
"operator_title_greater_than": "Groter dan",
"operator_title_is_after": "Is na",
"operator_title_is_before": "Is eerder",
"operator_title_is_between": "Is tussen",
"operator_title_is_newer_than": "Is nieuwer dan",
"operator_title_is_not_set": "Is niet ingesteld",
"operator_title_is_older_than": "Is ouder dan",
"operator_title_is_same_day": "Is dezelfde dag",
"operator_title_is_set": "Is ingesteld",
"operator_title_less_equal": "Kleiner dan of gelijk aan",
"operator_title_less_than": "Kleiner dan",
"operator_title_not_equals": "Is niet gelijk aan",
"operator_title_starts_with": "Begint met",
"operator_title_user_is_in": "Gebruiker zit in",
"operator_title_user_is_not_in": "Gebruiker zit niet in",
"operator_user_is_in": "Gebruiker zit in",
"operator_user_is_not_in": "Gebruiker zit niet in",
"person_and_attributes": "Persoon & attributen",
"phone": "Telefoon",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Verwijder het segment uit deze enquêtes om het te kunnen verwijderen.",
@@ -944,7 +867,6 @@
"user_targeting_is_currently_only_available_when": "Gebruikerstargeting is momenteel alleen beschikbaar wanneer",
"value_cannot_be_empty": "Waarde kan niet leeg zijn.",
"value_must_be_a_number": "Waarde moet een getal zijn.",
"value_must_be_positive": "Waarde moet een positief getal zijn.",
"view_filters": "Bekijk filters",
"where": "Waar",
"with_the_formbricks_sdk": "met de Formbricks SDK"
@@ -1031,32 +953,19 @@
"enterprise_features": "Enterprise-functies",
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
"license_status": "Licentiestatus",
"license_status_active": "Actief",
"license_status_description": "Status van je enterprise-licentie.",
"license_status_expired": "Verlopen",
"license_status_invalid": "Ongeldige licentie",
"license_status_unreachable": "Niet bereikbaar",
"license_unreachable_grace_period": "Licentieserver is niet bereikbaar. Je enterprise functies blijven actief tijdens een respijtperiode van 3 dagen die eindigt op {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Geen telefoontje nodig, geen verplichtingen: vraag een gratis proeflicentie van 30 dagen aan om alle functies te testen door dit formulier in te vullen:",
"no_credit_card_no_sales_call_just_test_it": "Geen creditcard. Geen verkoopgesprek. Gewoon testen :)",
"on_request": "Op aanvraag",
"organization_roles": "Organisatierollen (beheerder, redacteur, ontwikkelaar, etc.)",
"questions_please_reach_out_to": "Vragen? Neem contact op met",
"recheck_license": "Licentie opnieuw controleren",
"recheck_license_failed": "Licentiecontrole mislukt. De licentieserver is mogelijk niet bereikbaar.",
"recheck_license_invalid": "De licentiesleutel is ongeldig. Controleer je ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Licentiecontrole geslaagd",
"recheck_license_unreachable": "Licentieserver is niet bereikbaar. Probeer het later opnieuw.",
"rechecking": "Opnieuw controleren...",
"request_30_day_trial_license": "Vraag een proeflicentie van 30 dagen aan",
"saml_sso": "SAML-SSO",
"service_level_agreement": "Service Level Overeenkomst",
"soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 Conformiteitscontrole",
"sso": "SSO (Google, Microsoft, OpenID Connect)",
"teams": "Teams en toegangsrollen (lezen, lezen en schrijven, beheren)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis."
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis.",
"your_enterprise_license_is_active_all_features_unlocked": "Uw Enterprise-licentie is actief. Alle functies ontgrendeld."
},
"general": {
"bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.",
@@ -1080,7 +989,7 @@
"from_your_organization": "vanuit uw organisatie",
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
"invite_expires_on": "Uitnodiging verloopt op {date}",
"invited_on": "Uitgenodigd op {date}",
"invites_failed": "Uitnodigingen zijn mislukt",
"leave_organization": "Verlaat de organisatie",
"leave_organization_description": "U verlaat deze organisatie en verliest de toegang tot alle enquêtes en reacties. Je kunt alleen weer meedoen als je opnieuw wordt uitgenodigd.",
@@ -1193,6 +1102,8 @@
"please_fill_all_workspace_fields": "Vul alle velden in om een nieuwe werkruimte toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"select_member": "Selecteer lid",
"select_workspace": "Selecteer werkruimte",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",
@@ -1242,6 +1153,7 @@
"add_fallback_placeholder": "Voeg een tijdelijke aanduiding toe om aan te geven of er geen waarde is om te onthouden.",
"add_hidden_field_id": "Voeg een verborgen veld-ID toe",
"add_highlight_border": "Markeerrand toevoegen",
"add_highlight_border_description": "Voeg een buitenrand toe aan uw enquêtekaart.",
"add_logic": "Voeg logica toe",
"add_none_of_the_above": "Voeg 'Geen van bovenstaande' toe",
"add_option": "Optie toevoegen",
@@ -1280,7 +1192,6 @@
"block_duplicated": "Blok gedupliceerd.",
"bold": "Vetgedrukt",
"brand_color": "Merk kleur",
"brand_color_description": "Toegepast op knoppen, links en highlights.",
"brightness": "Helderheid",
"bulk_edit": "Bulkbewerking",
"bulk_edit_description": "Bewerk alle opties hieronder, één per regel. Lege regels worden overgeslagen en duplicaten verwijderd.",
@@ -1298,9 +1209,7 @@
"capture_new_action": "Leg nieuwe actie vast",
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
"card_background_color": "Achtergrondkleur van de kaart",
"card_background_color_description": "Vult het enquêtekaartgebied.",
"card_border_color": "Randkleur kaart",
"card_border_color_description": "Omlijnt de enquêtekaart.",
"card_styling": "Kaartstijl",
"casual": "Casual",
"caution_edit_duplicate": "Dupliceren en bewerken",
@@ -1311,12 +1220,20 @@
"caution_explanation_responses_are_safe": "Oudere en nieuwere antwoorden lopen door elkaar heen, wat kan leiden tot misleidende gegevenssamenvattingen.",
"caution_recommendation": "Dit kan inconsistenties in de gegevens in de onderzoekssamenvatting veroorzaken. Wij raden u aan de enquête te dupliceren.",
"caution_text": "Veranderingen zullen tot inconsistenties leiden",
"centered_modal_overlay_color": "Gecentreerde modale overlaykleur",
"change_anyway": "Hoe dan ook veranderen",
"change_background": "Achtergrond wijzigen",
"change_question_type": "Vraagtype wijzigen",
"change_survey_type": "Als u van enquêtetype verandert, heeft dit invloed op de bestaande toegang",
"change_the_background_color_of_the_card": "Verander de achtergrondkleur van de kaart.",
"change_the_background_color_of_the_input_fields": "Verander de achtergrondkleur van de invoervelden.",
"change_the_background_to_a_color_image_or_animation": "Verander de achtergrond in een kleur, afbeelding of animatie.",
"change_the_border_color_of_the_card": "Verander de randkleur van de kaart.",
"change_the_border_color_of_the_input_fields": "Wijzig de randkleur van de invoervelden.",
"change_the_border_radius_of_the_card_and_the_inputs": "Wijzig de randradius van de kaart en de ingangen.",
"change_the_brand_color_of_the_survey": "Wijzig de merkkleur van de enquête.",
"change_the_placement_of_this_survey": "Wijzig de plaatsing van deze enquête.",
"change_the_question_color_of_the_survey": "Verander de vraagkleur van de enquête.",
"changes_saved": "Wijzigingen opgeslagen.",
"changing_survey_type_will_remove_existing_distribution_channels": "Het wijzigen van het enquêtetype heeft invloed op de manier waarop deze kan worden gedeeld. Als respondenten al toegangslinks hebben voor het huidige type, verliezen ze mogelijk de toegang na de overstap.",
"checkbox_label": "Selectievakje-label",
@@ -1356,7 +1273,6 @@
"disable_the_visibility_of_survey_progress": "Schakel de zichtbaarheid van de voortgang van het onderzoek uit.",
"display_an_estimate_of_completion_time_for_survey": "Geef een schatting weer van de voltooiingstijd voor het onderzoek",
"display_number_of_responses_for_survey": "Weergave aantal reacties voor enquête",
"display_type": "Weergavetype",
"divide": "Verdeling /",
"does_not_contain": "Bevat niet",
"does_not_end_with": "Eindigt niet met",
@@ -1364,7 +1280,6 @@
"does_not_include_all_of": "Omvat niet alles",
"does_not_include_one_of": "Bevat niet een van",
"does_not_start_with": "Begint niet met",
"dropdown": "Dropdown",
"duplicate_block": "Blok dupliceren",
"duplicate_question": "Vraag dupliceren",
"edit_link": "Link bewerken",
@@ -1456,6 +1371,7 @@
"hide_progress_bar": "Voortgangsbalk verbergen",
"hide_question_settings": "Vraaginstellingen verbergen",
"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 je meer nodig hebt,",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
"ignore_global_waiting_time": "Afkoelperiode negeren",
@@ -1466,9 +1382,7 @@
"initial_value": "Initiële waarde",
"inner_text": "Innerlijke tekst",
"input_border_color": "Randkleur invoeren",
"input_border_color_description": "Omlijnt tekstvelden en tekstgebieden.",
"input_color": "Kleur invoeren",
"input_color_description": "Vult de binnenkant van tekstvelden.",
"insert_link": "Link invoegen",
"invalid_targeting": "Ongeldige targeting: controleer uw doelgroepfilters",
"invalid_video_url_warning": "Voer een geldige YouTube-, Vimeo- of Loom-URL in. We ondersteunen momenteel geen andere videohostingproviders.",
@@ -1498,7 +1412,6 @@
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
"limit_upload_file_size_to": "Beperk uploadbestandsgrootte tot",
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
"list": "Lijst",
"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",
@@ -1552,12 +1465,13 @@
"protect_survey_with_pin_description": "Alleen gebruikers die de pincode hebben, hebben toegang tot de enquête.",
"publish": "Publiceren",
"question": "Vraag",
"question_color": "Vraag kleur",
"question_deleted": "Vraag verwijderd.",
"question_duplicated": "Vraag dubbel gesteld.",
"question_id_updated": "Vraag-ID bijgewerkt",
"question_used_in_logic_warning_text": "Elementen uit dit blok worden gebruikt in een logische regel, weet je zeker dat je het wilt verwijderen?",
"question_used_in_logic_warning_title": "Logica-inconsistentie",
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum {quotaName}",
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum '{quotaName}'",
"question_used_in_recall": "Deze vraag wordt teruggehaald in vraag {questionIndex}.",
"question_used_in_recall_ending_card": "Deze vraag wordt teruggeroepen in de Eindkaart",
"quotas": {
@@ -1613,7 +1527,6 @@
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
"response_options": "Reactieopties",
"roundness": "Rondheid",
"roundness_description": "Bepaalt hoe afgerond de kaarthoeken zijn.",
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"rows": "Rijen",
"save_and_close": "Opslaan en sluiten",
@@ -1655,6 +1568,7 @@
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
"subheading": "Ondertitel",
"subtract": "Aftrekken -",
"suggest_colors": "Stel kleuren voor",
"survey_completed_heading": "Enquête voltooid",
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen",
@@ -1731,7 +1645,7 @@
"validation_rules": "Validatieregels",
"validation_rules_description": "Accepteer alleen antwoorden die voldoen aan de volgende criteria",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele {variableName} wordt gebruikt in het quotum “{quotaName}",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele \"{variableName}\" wordt gebruikt in het \"{quotaName}\" quotum",
"variable_name_conflicts_with_hidden_field": "Variabelenaam conflicteert met een bestaande verborgen veld-ID.",
"variable_name_is_already_taken_please_choose_another": "Variabelenaam is al in gebruik, kies een andere.",
"variable_name_must_start_with_a_letter": "Variabelenaam moet beginnen met een letter.",
@@ -1957,7 +1871,6 @@
"filtered_responses_excel": "Gefilterde reacties (Excel)",
"generating_qr_code": "QR-code genereren",
"impressions": "Indrukken",
"impressions_identified_only": "Alleen weergaven van geïdentificeerde contacten worden getoond",
"impressions_tooltip": "Aantal keren dat de enquête is bekeken.",
"in_app": {
"connection_description": "De enquête wordt getoond aan gebruikers van uw website die voldoen aan de onderstaande criteria",
@@ -2000,7 +1913,6 @@
"last_quarter": "Laatste kwartaal",
"last_year": "Vorig jaar",
"limit": "Beperken",
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
"no_responses_found": "Geen reacties gevonden",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",
@@ -2140,71 +2052,9 @@
"look": {
"add_background_color": "Achtergrondkleur toevoegen",
"add_background_color_description": "Voeg een achtergrondkleur toe aan de logocontainer.",
"advanced_styling_field_border_radius": "Hoekradius",
"advanced_styling_field_button_bg": "Knopachtergrond",
"advanced_styling_field_button_bg_description": "Vult de volgende/verzend-knop.",
"advanced_styling_field_button_border_radius_description": "Rondt de knophoeken af.",
"advanced_styling_field_button_font_size_description": "Schaalt de tekst van het knoplabel.",
"advanced_styling_field_button_font_weight_description": "Maakt knoptekst lichter of vetter.",
"advanced_styling_field_button_height_description": "Bepaalt de knophoogte.",
"advanced_styling_field_button_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
"advanced_styling_field_button_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
"advanced_styling_field_button_text": "Knoptekst",
"advanced_styling_field_button_text_description": "Kleurt het label binnen knoppen.",
"advanced_styling_field_description_color": "Beschrijvingskleur",
"advanced_styling_field_description_color_description": "Kleurt de tekst onder elke kop.",
"advanced_styling_field_description_size": "Lettergrootte beschrijving",
"advanced_styling_field_description_size_description": "Schaalt de beschrijvingstekst.",
"advanced_styling_field_description_weight": "Letterdikte beschrijving",
"advanced_styling_field_description_weight_description": "Maakt beschrijvingstekst lichter of vetter.",
"advanced_styling_field_font_size": "Lettergrootte",
"advanced_styling_field_font_weight": "Letterdikte",
"advanced_styling_field_headline_color": "Kopkleur",
"advanced_styling_field_headline_color_description": "Kleurt de hoofdvraagtekst.",
"advanced_styling_field_headline_size": "Lettergrootte kop",
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
"advanced_styling_field_headline_weight": "Letterdikte kop",
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
"advanced_styling_field_height": "Minimale hoogte",
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
"advanced_styling_field_input_shadow_description": "Voegt een slagschaduw toe rond invoervelden.",
"advanced_styling_field_input_text": "Invoertekst",
"advanced_styling_field_input_text_description": "Kleurt de getypte tekst in invoervelden.",
"advanced_styling_field_option_bg": "Achtergrond",
"advanced_styling_field_option_bg_description": "Vult de optie-items.",
"advanced_styling_field_option_border_radius_description": "Rondt de hoeken van opties af.",
"advanced_styling_field_option_font_size_description": "Schaalt de tekst van optielabels.",
"advanced_styling_field_option_label": "Labelkleur",
"advanced_styling_field_option_label_description": "Kleurt de tekst van optielabels.",
"advanced_styling_field_option_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
"advanced_styling_field_option_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
"advanced_styling_field_padding_x": "Opvulling X",
"advanced_styling_field_padding_y": "Opvulling Y",
"advanced_styling_field_placeholder_opacity": "Plaatshouderopaciteit",
"advanced_styling_field_shadow": "Schaduw",
"advanced_styling_field_track_bg": "Sporachtergrond",
"advanced_styling_field_track_bg_description": "Kleurt het ongevulde gedeelte van de balk.",
"advanced_styling_field_track_height": "Spoorhoogte",
"advanced_styling_field_track_height_description": "Regelt de dikte van de voortgangsbalk.",
"advanced_styling_field_upper_label_color": "Koplabelkleur",
"advanced_styling_field_upper_label_color_description": "Kleurt het kleine label boven invoervelden.",
"advanced_styling_field_upper_label_size": "Lettergrootte koplabel",
"advanced_styling_field_upper_label_size_description": "Schaalt het kleine label boven invoervelden.",
"advanced_styling_field_upper_label_weight": "Letterdikte koplabel",
"advanced_styling_field_upper_label_weight_description": "Maakt het label lichter of vetter.",
"advanced_styling_section_buttons": "Knoppen",
"advanced_styling_section_headlines": "Koppen & beschrijvingen",
"advanced_styling_section_inputs": "Invoervelden",
"advanced_styling_section_options": "Opties (radio/checkbox)",
"app_survey_placement": "App-enquête plaatsing",
"app_survey_placement_settings_description": "Wijzig waar enquêtes worden weergegeven in uw web-app of website.",
"centered_modal_overlay_color": "Gecentreerde modale overlaykleur",
"email_customization": "E-mail aanpassing",
"email_customization_description": "Wijzig het uiterlijk van e-mails die Formbricks namens u verstuurt.",
"enable_custom_styling": "Aangepaste styling inschakelen",
@@ -2215,9 +2065,6 @@
"formbricks_branding_hidden": "Formbricks-branding is verborgen.",
"formbricks_branding_settings_description": "We waarderen uw steun, maar begrijpen het als u dit uitschakelt.",
"formbricks_branding_shown": "Formbricks-branding wordt weergegeven.",
"generate_theme_btn": "Genereren",
"generate_theme_confirmation": "Wil je een passend kleurthema genereren op basis van je merkkleur? Dit overschrijft je huidige kleurinstellingen.",
"generate_theme_header": "Kleurthema genereren?",
"logo_removed_successfully": "Logo succesvol verwijderd",
"logo_settings_description": "Upload uw bedrijfslogo om enquêtes en linkvoorbeelden te voorzien van uw huisstijl.",
"logo_updated_successfully": "Logo succesvol bijgewerkt",
@@ -2232,8 +2079,6 @@
"show_formbricks_branding_in": "Toon Formbricks-branding in {type} enquêtes",
"show_powered_by_formbricks": "Toon 'Powered by Formbricks' handtekening",
"styling_updated_successfully": "Styling succesvol bijgewerkt",
"suggest_colors": "Kleuren voorstellen",
"suggested_colors_applied_please_save": "Voorgestelde kleuren succesvol gegenereerd. Druk op \"Opslaan\" om de wijzigingen te behouden.",
"theme": "Thema",
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
},
@@ -2997,7 +2842,6 @@
"preview_survey_question_2_choice_1_label": "Ja, houd mij op de hoogte.",
"preview_survey_question_2_choice_2_label": "Nee, dank je!",
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
"preview_survey_welcome_card_headline": "Welkom!",
"prioritize_features_description": "Identificeer functies die uw gebruikers het meest en het minst nodig hebben.",
"prioritize_features_name": "Geef prioriteit aan functies",

View File

@@ -188,7 +188,6 @@
"customer_success": "Sucesso do Cliente",
"dark_overlay": "sobreposição escura",
"date": "Encontro",
"days": "dias",
"default": "Padrão",
"delete": "Apagar",
"description": "Descrição",
@@ -218,7 +217,6 @@
"error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
"error_component_title": "Erro ao carregar recursos",
"error_loading_data": "Erro ao carregar dados",
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
@@ -277,7 +275,6 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!",
"months": "meses",
"move_down": "Descer",
"move_up": "Subir",
"multiple_languages": "Vários idiomas",
@@ -288,7 +285,6 @@
"no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado",
"no_overlay": "Sem sobreposição",
"no_quotas_found": "Nenhuma cota encontrada",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
@@ -315,7 +311,6 @@
"organization_teams_not_found": "Equipes da organização não encontradas",
"other": "outro",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
"password": "Senha",
"paused": "Pausado",
@@ -355,7 +350,6 @@
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"response": "Resposta",
"response_id": "ID da resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Rolê",
@@ -396,7 +390,6 @@
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
"string": "Texto",
"styling": "Estilização",
"submit": "Enviar",
"summary": "Resumo",
@@ -429,7 +422,6 @@
"top_right": "Canto Superior Direito",
"try_again": "Tenta de novo",
"type": "Tipo",
"unknown_survey": "Pesquisa desconhecida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "atualizar",
"updated": "atualizado",
@@ -453,7 +445,6 @@
"website_and_app_connection": "Conexão de Site e App",
"website_app_survey": "Pesquisa de Site e App",
"website_survey": "Pesquisa de Site",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
@@ -464,7 +455,6 @@
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
@@ -636,45 +626,28 @@
"attribute_updated_successfully": "Atributo atualizado com sucesso",
"attribute_value": "Valor",
"attribute_value_placeholder": "Valor do atributo",
"attributes_msg_attribute_limit_exceeded": "Não foi possível criar {count} novo(s) atributo(s), pois excederia o limite máximo de {limit} classes de atributos. Os atributos existentes foram atualizados com sucesso.",
"attributes_msg_attribute_type_validation_error": "{error} (atributo '{key}' tem dataType: {dataType})",
"attributes_msg_email_already_exists": "O e-mail já existe para este ambiente e não foi atualizado.",
"attributes_msg_email_or_userid_required": "E-mail ou userId é obrigatório. Os valores existentes foram preservados.",
"attributes_msg_new_attribute_created": "Novo atributo '{key}' criado com tipo '{dataType}'",
"attributes_msg_userid_already_exists": "O userId já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contato excluído com sucesso",
"contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_key": "Criar chave",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
"custom_attributes": "Atributos personalizados",
"data_type": "Tipo de dados",
"data_type_cannot_be_changed": "O tipo de dados não pode ser alterado após a criação",
"data_type_description": "Escolha como este atributo deve ser armazenado e filtrado",
"date_value_required": "O valor da data é obrigatório. Use o botão excluir para remover este atributo se você não quiser definir uma data.",
"delete_attribute_confirmation": "{value, plural, one {Isso excluirá o atributo selecionado. Todos os dados de contato associados a este atributo serão perdidos.} other {Isso excluirá os atributos selecionados. Todos os dados de contato associados a estes atributos serão perdidos.}}",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
"displays": "Exibições",
"edit_attribute": "Editar atributo",
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
"edit_attribute_values": "Editar atributos",
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contato.",
"edit_attributes": "Editar atributos",
"edit_attributes_success": "Atributos do contato atualizados com sucesso",
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
"no_activity_yet": "Nenhuma atividade ainda",
"no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.",
"no_published_surveys": "Sem pesquisas publicadas",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"number_value_required": "O valor numérico é obrigatório. Use o botão excluir para remover este atributo.",
"personal_link_generated": "Link pessoal gerado com sucesso",
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado, mas falha ao copiar para a área de transferência: {url}",
"personal_survey_link": "Link da pesquisa pessoal",
@@ -683,24 +656,13 @@
"search_contact": "Buscar contato",
"select_a_survey": "Selecione uma pesquisa",
"select_attribute": "Selecionar Atributo",
"select_attribute_key": "Selecionar chave de atributo",
"survey_viewed": "Pesquisa visualizada",
"survey_viewed_at": "Visualizada em",
"system_attributes": "Atributos do sistema",
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
"upload_contacts_error_attribute_type_mismatch": "O atributo \"{key}\" está tipado como \"{dataType}\", mas o CSV contém valores inválidos: {values}",
"upload_contacts_error_duplicate_mappings": "Mapeamentos duplicados encontrados para os seguintes atributos: {attributes}",
"upload_contacts_error_file_too_large": "O tamanho do arquivo excede o limite máximo de 800KB",
"upload_contacts_error_generic": "Ocorreu um erro ao fazer upload dos contatos. Por favor, tente novamente mais tarde.",
"upload_contacts_error_invalid_file_type": "Por favor, faça upload de um arquivo CSV",
"upload_contacts_error_no_valid_contacts": "O arquivo CSV enviado não contém nenhum contato válido, por favor veja o arquivo CSV de exemplo para o formato correto.",
"upload_contacts_modal_attribute_header": "Atributo do Formbricks",
"upload_contacts_modal_attributes_description": "Mapeie as colunas do seu CSV para os atributos no Formbricks.",
"upload_contacts_modal_attributes_new": "Novo atributo",
"upload_contacts_modal_attributes_search_or_add": "Buscar ou adicionar atributo",
"upload_contacts_modal_attributes_should_be_mapped_to": "deve ser mapeado para",
"upload_contacts_modal_attributes_title": "Atributos",
"upload_contacts_modal_csv_column_header": "Coluna CSV",
"upload_contacts_modal_description": "Faça upload de um CSV para importar contatos com atributos rapidamente",
"upload_contacts_modal_download_example_csv": "Baixar exemplo de CSV",
"upload_contacts_modal_duplicates_description": "O que devemos fazer se um contato já existir nos seus contatos?",
@@ -757,12 +719,7 @@
"link_google_sheet": "Link da Planilha do Google",
"link_new_sheet": "Vincular nova planilha",
"no_integrations_yet": "Suas integrações do Google Sheets vão aparecer aqui assim que você adicioná-las. ⏲️",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Sua conexão com o Google Sheets expirou. Reconecte para continuar sincronizando respostas. Seus links de planilhas e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links de planilhas e dados existentes serão preservados.",
"spreadsheet_permission_error": "Você não tem permissão para acessar esta planilha. Certifique-se de que a planilha está compartilhada com sua conta do Google e que você tem acesso de escrita à planilha.",
"spreadsheet_url": "URL da planilha",
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Reconecte a integração."
"spreadsheet_url": "URL da planilha"
},
"include_created_at": "Incluir Data de Criação",
"include_hidden_fields": "Incluir Campos Ocultos",
@@ -886,40 +843,6 @@
"no_attributes_yet": "Ainda não tem atributos!",
"no_filters_yet": "Ainda não tem filtros!",
"no_segments_yet": "Você não tem segmentos salvos no momento.",
"operator_contains": "contém",
"operator_does_not_contain": "não contém",
"operator_ends_with": "termina com",
"operator_is_after": "é depois",
"operator_is_before": "é antes",
"operator_is_between": "está entre",
"operator_is_newer_than": "é mais recente que",
"operator_is_not_set": "não está definido",
"operator_is_older_than": "é mais antigo que",
"operator_is_same_day": "é no mesmo dia",
"operator_is_set": "está definido",
"operator_starts_with": "começa com",
"operator_title_contains": "Contém",
"operator_title_does_not_contain": "Não contém",
"operator_title_ends_with": "Termina com",
"operator_title_equals": "Igual",
"operator_title_greater_equal": "Maior ou igual a",
"operator_title_greater_than": "Maior que",
"operator_title_is_after": "É depois",
"operator_title_is_before": "É antes",
"operator_title_is_between": "Está entre",
"operator_title_is_newer_than": "É mais recente que",
"operator_title_is_not_set": "Não está definido",
"operator_title_is_older_than": "É mais antigo que",
"operator_title_is_same_day": "É no mesmo dia",
"operator_title_is_set": "Está definido",
"operator_title_less_equal": "Menor ou igual a",
"operator_title_less_than": "Menor que",
"operator_title_not_equals": "Diferente de",
"operator_title_starts_with": "Começa com",
"operator_title_user_is_in": "Usuário está em",
"operator_title_user_is_not_in": "Usuário não está em",
"operator_user_is_in": "Usuário está em",
"operator_user_is_not_in": "Usuário não está em",
"person_and_attributes": "Pessoa & Atributos",
"phone": "Celular",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento dessas pesquisas para deletá-lo.",
@@ -944,7 +867,6 @@
"user_targeting_is_currently_only_available_when": "A segmentação de usuários está disponível apenas quando",
"value_cannot_be_empty": "O valor não pode estar vazio.",
"value_must_be_a_number": "O valor deve ser um número.",
"value_must_be_positive": "O valor deve ser um número positivo.",
"view_filters": "Ver filtros",
"where": "Onde",
"with_the_formbricks_sdk": "com o SDK do Formbricks."
@@ -1031,32 +953,19 @@
"enterprise_features": "Recursos Empresariais",
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
"license_status": "Status da licença",
"license_status_active": "Ativa",
"license_status_description": "Status da sua licença enterprise.",
"license_status_expired": "Expirada",
"license_status_invalid": "Licença inválida",
"license_status_unreachable": "Inacessível",
"license_unreachable_grace_period": "O servidor de licenças não pode ser alcançado. Seus recursos empresariais permanecem ativos durante um período de carência de 3 dias que termina em {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Sem necessidade de ligação, sem compromisso: Solicite uma licença de teste gratuita de 30 dias para testar todas as funcionalidades preenchendo este formulário:",
"no_credit_card_no_sales_call_just_test_it": "Sem cartão de crédito. Sem ligação de vendas. Só teste :)",
"on_request": "Quando solicitado",
"organization_roles": "Funções na Organização (Admin, Editor, Desenvolvedor, etc.)",
"questions_please_reach_out_to": "Perguntas? Entre em contato com",
"recheck_license": "Verificar licença novamente",
"recheck_license_failed": "Falha na verificação da licença. O servidor de licenças pode estar inacessível.",
"recheck_license_invalid": "A chave de licença é inválida. Verifique sua ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Verificação da licença bem-sucedida",
"recheck_license_unreachable": "Servidor de licenças inacessível. Por favor, tente novamente mais tarde.",
"rechecking": "Verificando novamente...",
"request_30_day_trial_license": "Pedir Licença de Teste de 30 Dias",
"saml_sso": "SSO SAML",
"service_level_agreement": "Acordo de Nível de Serviço",
"soc2_hipaa_iso_27001_compliance_check": "Verificação de conformidade SOC2, HIPAA, ISO 27001",
"sso": "SSO (Google, Microsoft, OpenID Connect)",
"teams": "Equipes e Funções de Acesso (Ler, Ler e Escrever, Gerenciar)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias.",
"your_enterprise_license_is_active_all_features_unlocked": "Sua licença empresarial está ativa. Todos os recursos estão desbloqueados."
},
"general": {
"bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.",
@@ -1080,7 +989,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
"invite_expires_on": "O convite expira em {date}",
"invited_on": "Convidado em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Você vai sair dessa organização e perder acesso a todas as pesquisas e respostas. Você só pode voltar se for convidado de novo.",
@@ -1193,6 +1102,8 @@
"please_fill_all_workspace_fields": "Preencha todos os campos para adicionar um novo espaço de trabalho.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"select_member": "Selecionar membro",
"select_workspace": "Selecionar espaço de trabalho",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",
@@ -1242,6 +1153,7 @@
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar campo oculto ID",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das opções acima\"",
"add_option": "Adicionar opção",
@@ -1280,7 +1192,6 @@
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brand_color_description": "Aplicado a botões, links e destaques.",
"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.",
@@ -1298,9 +1209,7 @@
"capture_new_action": "Capturar nova ação",
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_background_color_description": "Preenche a área do cartão da pesquisa.",
"card_border_color": "Cor da borda do cartão",
"card_border_color_description": "Contorna o cartão da pesquisa.",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
@@ -1311,12 +1220,20 @@
"caution_explanation_responses_are_safe": "Respostas antigas e novas são misturadas, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_text": "Mudanças vão levar a inconsistências",
"centered_modal_overlay_color": "cor de sobreposição modal centralizada",
"change_anyway": "Mudar mesmo assim",
"change_background": "Mudar fundo",
"change_question_type": "Mudar tipo de pergunta",
"change_survey_type": "Alterar o tipo de pesquisa afeta o acesso existente",
"change_the_background_color_of_the_card": "Muda a cor de fundo do cartão.",
"change_the_background_color_of_the_input_fields": "Mude a cor de fundo dos campos de entrada.",
"change_the_background_to_a_color_image_or_animation": "Mude o fundo para uma cor, imagem ou animação.",
"change_the_border_color_of_the_card": "Muda a cor da borda do cartão.",
"change_the_border_color_of_the_input_fields": "Mude a cor da borda dos campos de entrada.",
"change_the_border_radius_of_the_card_and_the_inputs": "Muda o raio da borda do card e dos inputs.",
"change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.",
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"changes_saved": "Mudanças salvas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"checkbox_label": "Rótulo da Caixa de Seleção",
@@ -1356,7 +1273,6 @@
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
"display_type": "Tipo de exibição",
"divide": "Divida /",
"does_not_contain": "não contém",
"does_not_end_with": "Não termina com",
@@ -1364,7 +1280,6 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"dropdown": "Menu suspenso",
"duplicate_block": "Duplicar bloco",
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
@@ -1456,6 +1371,7 @@
"hide_progress_bar": "Esconder barra de progresso",
"hide_question_settings": "Ocultar configurações da pergunta",
"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",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
"ignore_global_waiting_time": "Ignorar período de espera",
@@ -1466,9 +1382,7 @@
"initial_value": "Valor inicial",
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda de entrada",
"input_border_color_description": "Contorna campos de texto e áreas de texto.",
"input_color": "Cor de entrada",
"input_color_description": "Preenche o interior dos campos de texto.",
"insert_link": "Inserir link",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os filtros do seu público",
"invalid_video_url_warning": "Por favor, insira uma URL válida do YouTube, Vimeo ou Loom. No momento, não suportamos outros provedores de vídeo.",
@@ -1498,7 +1412,6 @@
"limit_the_maximum_file_size": "Limitar o tamanho máximo de arquivo para uploads.",
"limit_upload_file_size_to": "Limitar tamanho de arquivo de upload para",
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
"list": "Lista",
"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",
@@ -1552,12 +1465,13 @@
"protect_survey_with_pin_description": "Somente usuários que têm o PIN podem acessar a pesquisa.",
"publish": "Publicar",
"question": "Pergunta",
"question_color": "Cor da pergunta",
"question_deleted": "Pergunta deletada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic_warning_text": "Elementos deste bloco são usados em uma regra de lógica, tem certeza de que deseja excluí-lo?",
"question_used_in_logic_warning_title": "Inconsistência de lógica",
"question_used_in_quota": "Esta pergunta está sendo usada na cota \"{quotaName}\"",
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
"quotas": {
@@ -1613,7 +1527,6 @@
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"roundness": "Circularidade",
"roundness_description": "Controla o arredondamento dos cantos do cartão.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"rows": "linhas",
"save_and_close": "Salvar e Fechar",
@@ -1655,6 +1568,7 @@
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"suggest_colors": "Sugerir cores",
"survey_completed_heading": "Pesquisa Concluída",
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa",
@@ -1731,7 +1645,7 @@
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que atendam aos seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
@@ -1957,7 +1871,6 @@
"filtered_responses_excel": "Respostas filtradas (Excel)",
"generating_qr_code": "Gerando código QR",
"impressions": "Impressões",
"impressions_identified_only": "Mostrando apenas impressões de contatos identificados",
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
"in_app": {
"connection_description": "A pesquisa será exibida para usuários do seu site, que atendam aos critérios listados abaixo",
@@ -2000,7 +1913,6 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"limit": "Limite",
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
@@ -2140,71 +2052,9 @@
"look": {
"add_background_color": "Adicionar cor de fundo",
"add_background_color_description": "Adicione uma cor de fundo ao container do logo.",
"advanced_styling_field_border_radius": "Raio da borda",
"advanced_styling_field_button_bg": "Fundo do botão",
"advanced_styling_field_button_bg_description": "Preenche o botão Próximo / Enviar.",
"advanced_styling_field_button_border_radius_description": "Arredonda os cantos do botão.",
"advanced_styling_field_button_font_size_description": "Ajusta o tamanho do texto do rótulo do botão.",
"advanced_styling_field_button_font_weight_description": "Torna o texto do botão mais leve ou mais negrito.",
"advanced_styling_field_button_height_description": "Controla a altura do botão.",
"advanced_styling_field_button_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_button_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_button_text": "Texto do botão",
"advanced_styling_field_button_text_description": "Colore o rótulo dentro dos botões.",
"advanced_styling_field_description_color": "Cor da descrição",
"advanced_styling_field_description_color_description": "Colore o texto abaixo de cada título.",
"advanced_styling_field_description_size": "Tamanho da fonte da descrição",
"advanced_styling_field_description_size_description": "Ajusta o tamanho do texto da descrição.",
"advanced_styling_field_description_weight": "Peso da fonte da descrição",
"advanced_styling_field_description_weight_description": "Torna o texto da descrição mais leve ou mais negrito.",
"advanced_styling_field_font_size": "Tamanho da fonte",
"advanced_styling_field_font_weight": "Peso da fonte",
"advanced_styling_field_headline_color": "Cor do título",
"advanced_styling_field_headline_color_description": "Colore o texto principal da pergunta.",
"advanced_styling_field_headline_size": "Tamanho da fonte do título",
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
"advanced_styling_field_input_shadow_description": "Adiciona uma sombra ao redor dos campos de entrada.",
"advanced_styling_field_input_text": "Texto de entrada",
"advanced_styling_field_input_text_description": "Colore o texto digitado nos campos de entrada.",
"advanced_styling_field_option_bg": "Fundo",
"advanced_styling_field_option_bg_description": "Preenche os itens de opção.",
"advanced_styling_field_option_border_radius_description": "Arredonda os cantos das opções.",
"advanced_styling_field_option_font_size_description": "Ajusta o tamanho do texto do rótulo da opção.",
"advanced_styling_field_option_label": "Cor do rótulo",
"advanced_styling_field_option_label_description": "Colore o texto do rótulo da opção.",
"advanced_styling_field_option_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_option_padding_y_description": "Adiciona espaço na parte superior e inferior.",
"advanced_styling_field_padding_x": "Espaçamento X",
"advanced_styling_field_padding_y": "Espaçamento Y",
"advanced_styling_field_placeholder_opacity": "Opacidade do placeholder",
"advanced_styling_field_shadow": "Sombra",
"advanced_styling_field_track_bg": "Fundo da trilha",
"advanced_styling_field_track_bg_description": "Colore a porção não preenchida da barra.",
"advanced_styling_field_track_height": "Altura da trilha",
"advanced_styling_field_track_height_description": "Controla a espessura da barra de progresso.",
"advanced_styling_field_upper_label_color": "Cor do rótulo do título",
"advanced_styling_field_upper_label_color_description": "Colore o pequeno rótulo acima dos campos de entrada.",
"advanced_styling_field_upper_label_size": "Tamanho da fonte do rótulo do título",
"advanced_styling_field_upper_label_size_description": "Ajusta o tamanho do pequeno rótulo acima dos campos de entrada.",
"advanced_styling_field_upper_label_weight": "Peso da fonte do rótulo do título",
"advanced_styling_field_upper_label_weight_description": "Torna o rótulo mais leve ou mais negrito.",
"advanced_styling_section_buttons": "Botões",
"advanced_styling_section_headlines": "Títulos e descrições",
"advanced_styling_section_inputs": "Campos de entrada",
"advanced_styling_section_options": "Opções (rádio/caixa de seleção)",
"app_survey_placement": "Posicionamento da pesquisa de app",
"app_survey_placement_settings_description": "Altere onde as pesquisas serão exibidas em seu aplicativo web ou site.",
"centered_modal_overlay_color": "Cor de sobreposição modal centralizada",
"email_customization": "Personalização de e-mail",
"email_customization_description": "Altere a aparência dos e-mails que o Formbricks envia em seu nome.",
"enable_custom_styling": "Habilitar estilização personalizada",
@@ -2215,9 +2065,6 @@
"formbricks_branding_hidden": "A marca Formbricks está oculta.",
"formbricks_branding_settings_description": "Adoramos seu apoio, mas entendemos se você desativar.",
"formbricks_branding_shown": "A marca Formbricks está visível.",
"generate_theme_btn": "Gerar",
"generate_theme_confirmation": "Gostaria de gerar um tema de cores correspondente baseado na cor da sua marca? Isso substituirá suas configurações de cores atuais.",
"generate_theme_header": "Gerar tema de cores?",
"logo_removed_successfully": "Logo removido com sucesso",
"logo_settings_description": "Faça upload do logo da sua empresa para personalizar pesquisas e pré-visualizações de links.",
"logo_updated_successfully": "Logo atualizado com sucesso",
@@ -2232,8 +2079,6 @@
"show_formbricks_branding_in": "Mostrar marca Formbricks em pesquisas {type}",
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione \"Salvar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
},
@@ -2997,7 +2842,6 @@
"preview_survey_question_2_choice_1_label": "Sim, me mantenha informado.",
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"prioritize_features_description": "Identifique os recursos que seus usuários mais e menos precisam.",
"prioritize_features_name": "Priorizar Funcionalidades",

View File

@@ -188,7 +188,6 @@
"customer_success": "Sucesso do Cliente",
"dark_overlay": "Sobreposição escura",
"date": "Data",
"days": "dias",
"default": "Padrão",
"delete": "Eliminar",
"description": "Descrição",
@@ -218,7 +217,6 @@
"error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
"error_component_title": "Erro ao carregar recursos",
"error_loading_data": "Erro ao carregar dados",
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
@@ -277,7 +275,6 @@
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
"months": "meses",
"move_down": "Mover para baixo",
"move_up": "Mover para cima",
"multiple_languages": "Várias línguas",
@@ -288,7 +285,6 @@
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado",
"no_overlay": "Sem sobreposição",
"no_quotas_found": "Nenhum quota encontrado",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
@@ -315,7 +311,6 @@
"organization_teams_not_found": "Equipas da organização não encontradas",
"other": "Outro",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
"password": "Palavra-passe",
"paused": "Em pausa",
@@ -355,7 +350,6 @@
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"response": "Resposta",
"response_id": "ID de resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Função",
@@ -396,7 +390,6 @@
"status": "Estado",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
"string": "Texto",
"styling": "Estilo",
"submit": "Submeter",
"summary": "Resumo",
@@ -429,7 +422,6 @@
"top_right": "Superior Direito",
"try_again": "Tente novamente",
"type": "Tipo",
"unknown_survey": "Inquérito desconhecido",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "Atualizar",
"updated": "Atualizado",
@@ -453,7 +445,6 @@
"website_and_app_connection": "Ligação de Website e Aplicação",
"website_app_survey": "Inquérito do Website e da Aplicação",
"website_survey": "Inquérito do Website",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
@@ -464,7 +455,6 @@
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
@@ -636,45 +626,28 @@
"attribute_updated_successfully": "Atributo atualizado com sucesso",
"attribute_value": "Valor",
"attribute_value_placeholder": "Valor do atributo",
"attributes_msg_attribute_limit_exceeded": "Não foi possível criar {count} novo(s) atributo(s), pois excederia o limite máximo de {limit} classes de atributos. Os atributos existentes foram atualizados com sucesso.",
"attributes_msg_attribute_type_validation_error": "{error} (o atributo '{key}' tem dataType: {dataType})",
"attributes_msg_email_already_exists": "O email já existe para este ambiente e não foi atualizado.",
"attributes_msg_email_or_userid_required": "É necessário email ou userId. Os valores existentes foram preservados.",
"attributes_msg_new_attribute_created": "Criado novo atributo '{key}' com tipo '{dataType}'",
"attributes_msg_userid_already_exists": "O userId já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contacto eliminado com sucesso",
"contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_key": "Criar chave",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
"custom_attributes": "Atributos personalizados",
"data_type": "Tipo de dados",
"data_type_cannot_be_changed": "O tipo de dados não pode ser alterado após a criação",
"data_type_description": "Escolhe como este atributo deve ser armazenado e filtrado",
"date_value_required": "O valor da data é obrigatório. Usa o botão eliminar para remover este atributo se não quiseres definir uma data.",
"delete_attribute_confirmation": "{value, plural, one {Isto irá eliminar o atributo selecionado. Todos os dados de contacto associados a este atributo serão perdidos.} other {Isto irá eliminar os atributos selecionados. Todos os dados de contacto associados a estes atributos serão perdidos.}}",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
"displays": "Visualizações",
"edit_attribute": "Editar atributo",
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
"edit_attribute_values": "Editar atributos",
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contacto.",
"edit_attributes": "Editar atributos",
"edit_attributes_success": "Atributos do contacto atualizados com sucesso",
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
"no_activity_yet": "Ainda sem atividade",
"no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.",
"no_published_surveys": "Sem inquéritos publicados",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"number_value_required": "O valor numérico é obrigatório. Usa o botão eliminar para remover este atributo.",
"personal_link_generated": "Link pessoal gerado com sucesso",
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado mas falha ao copiar para a área de transferência: {url}",
"personal_survey_link": "Link do inquérito pessoal",
@@ -683,24 +656,13 @@
"search_contact": "Procurar contacto",
"select_a_survey": "Selecione um inquérito",
"select_attribute": "Selecionar Atributo",
"select_attribute_key": "Selecionar chave de atributo",
"survey_viewed": "Inquérito visualizado",
"survey_viewed_at": "Visualizado em",
"system_attributes": "Atributos do sistema",
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
"upload_contacts_error_attribute_type_mismatch": "O atributo \"{key}\" está definido como \"{dataType}\", mas o CSV contém valores inválidos: {values}",
"upload_contacts_error_duplicate_mappings": "Foram encontrados mapeamentos duplicados para os seguintes atributos: {attributes}",
"upload_contacts_error_file_too_large": "O tamanho do ficheiro excede o limite máximo de 800KB",
"upload_contacts_error_generic": "Ocorreu um erro ao carregar os contactos. Por favor, tenta novamente mais tarde.",
"upload_contacts_error_invalid_file_type": "Por favor, carrega um ficheiro CSV",
"upload_contacts_error_no_valid_contacts": "O ficheiro CSV carregado não contém contactos válidos, por favor consulta o ficheiro CSV de exemplo para o formato correto.",
"upload_contacts_modal_attribute_header": "Atributo Formbricks",
"upload_contacts_modal_attributes_description": "Mapeie as colunas no seu CSV para os atributos no Formbricks.",
"upload_contacts_modal_attributes_new": "Novo atributo",
"upload_contacts_modal_attributes_search_or_add": "Pesquisar ou adicionar atributo",
"upload_contacts_modal_attributes_should_be_mapped_to": "deve ser mapeado para",
"upload_contacts_modal_attributes_title": "Atributos",
"upload_contacts_modal_csv_column_header": "Coluna CSV",
"upload_contacts_modal_description": "Carregue um ficheiro CSV para importar rapidamente contactos com atributos",
"upload_contacts_modal_download_example_csv": "Descarregar exemplo de CSV",
"upload_contacts_modal_duplicates_description": "Como devemos proceder se um contacto já existir nos seus contactos?",
@@ -757,12 +719,7 @@
"link_google_sheet": "Ligar Folha do Google",
"link_new_sheet": "Ligar nova Folha",
"no_integrations_yet": "As suas integrações com o Google Sheets aparecerão aqui assim que as adicionar. ⏲️",
"reconnect_button": "Reconectar",
"reconnect_button_description": "A tua ligação ao Google Sheets expirou. Por favor, reconecta para continuar a sincronizar respostas. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecta a integração para atualizar o teu acesso. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
"spreadsheet_permission_error": "Não tens permissão para aceder a esta folha de cálculo. Por favor, certifica-te de que a folha de cálculo está partilhada com a tua conta Google e que tens acesso de escrita à folha de cálculo.",
"spreadsheet_url": "URL da folha de cálculo",
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Por favor, reconecta a integração."
"spreadsheet_url": "URL da folha de cálculo"
},
"include_created_at": "Incluir Criado Em",
"include_hidden_fields": "Incluir Campos Ocultos",
@@ -886,40 +843,6 @@
"no_attributes_yet": "Ainda não há atributos!",
"no_filters_yet": "Ainda não há filtros!",
"no_segments_yet": "Atualmente, não tem segmentos guardados.",
"operator_contains": "contém",
"operator_does_not_contain": "não contém",
"operator_ends_with": "termina com",
"operator_is_after": "é depois",
"operator_is_before": "é antes",
"operator_is_between": "está entre",
"operator_is_newer_than": "é mais recente que",
"operator_is_not_set": "não está definido",
"operator_is_older_than": "é mais antigo que",
"operator_is_same_day": "é no mesmo dia",
"operator_is_set": "está definido",
"operator_starts_with": "começa com",
"operator_title_contains": "Contém",
"operator_title_does_not_contain": "Não contém",
"operator_title_ends_with": "Termina com",
"operator_title_equals": "Igual",
"operator_title_greater_equal": "Maior ou igual a",
"operator_title_greater_than": "Maior que",
"operator_title_is_after": "É depois",
"operator_title_is_before": "É antes",
"operator_title_is_between": "Está entre",
"operator_title_is_newer_than": "É mais recente que",
"operator_title_is_not_set": "Não está definido",
"operator_title_is_older_than": "É mais antigo que",
"operator_title_is_same_day": "É no mesmo dia",
"operator_title_is_set": "Está definido",
"operator_title_less_equal": "Menor ou igual a",
"operator_title_less_than": "Menor que",
"operator_title_not_equals": "Diferente de",
"operator_title_starts_with": "Começa com",
"operator_title_user_is_in": "O utilizador está em",
"operator_title_user_is_not_in": "O utilizador não está em",
"operator_user_is_in": "O utilizador está em",
"operator_user_is_not_in": "O utilizador não está em",
"person_and_attributes": "Pessoa e Atributos",
"phone": "Telefone",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento destes questionários para o eliminar.",
@@ -944,7 +867,6 @@
"user_targeting_is_currently_only_available_when": "A segmentação de utilizadores está atualmente disponível apenas quando",
"value_cannot_be_empty": "O valor não pode estar vazio.",
"value_must_be_a_number": "O valor deve ser um número.",
"value_must_be_positive": "O valor deve ser um número positivo.",
"view_filters": "Ver filtros",
"where": "Onde",
"with_the_formbricks_sdk": "com o SDK Formbricks"
@@ -1031,32 +953,19 @@
"enterprise_features": "Funcionalidades da Empresa",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
"license_status": "Estado da licença",
"license_status_active": "Ativa",
"license_status_description": "Estado da sua licença empresarial.",
"license_status_expired": "Expirada",
"license_status_invalid": "Licença inválida",
"license_status_unreachable": "Inacessível",
"license_unreachable_grace_period": "Não é possível contactar o servidor de licenças. As suas funcionalidades empresariais permanecem ativas durante um período de tolerância de 3 dias que termina a {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Sem necessidade de chamada, sem compromissos: Solicite uma licença de teste gratuita de 30 dias para testar todas as funcionalidades preenchendo este formulário:",
"no_credit_card_no_sales_call_just_test_it": "Sem cartão de crédito. Sem chamada de vendas. Apenas teste :)",
"on_request": "A pedido",
"organization_roles": "Funções da Organização (Administrador, Editor, Programador, etc.)",
"questions_please_reach_out_to": "Questões? Por favor entre em contacto com",
"recheck_license": "Verificar licença novamente",
"recheck_license_failed": "A verificação da licença falhou. O servidor de licenças pode estar inacessível.",
"recheck_license_invalid": "A chave de licença é inválida. Por favor, verifique a sua ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Verificação da licença bem-sucedida",
"recheck_license_unreachable": "O servidor de licenças está inacessível. Por favor, tenta novamente mais tarde.",
"rechecking": "A verificar novamente...",
"request_30_day_trial_license": "Solicitar Licença de Teste de 30 Dias",
"saml_sso": "SSO SAML",
"service_level_agreement": "Acordo de Nível de Serviço",
"soc2_hipaa_iso_27001_compliance_check": "Verificação de conformidade SOC2, HIPAA, ISO 27001",
"sso": "SSO (Google, Microsoft, OpenID Connect)",
"teams": "Equipas e Funções de Acesso (Ler, Ler e Escrever, Gerir)",
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias.",
"your_enterprise_license_is_active_all_features_unlocked": "A sua licença Enterprise está ativa. Todas as funcionalidades desbloqueadas."
},
"general": {
"bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".",
@@ -1080,7 +989,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado mais uma vez.",
"invite_deleted_successfully": "Convite eliminado com sucesso",
"invite_expires_on": "O convite expira em {date}",
"invited_on": "Convidado em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Vai sair desta organização e perder o acesso a todos os inquéritos e respostas. Só pode voltar a juntar-se se for convidado novamente.",
@@ -1193,6 +1102,8 @@
"please_fill_all_workspace_fields": "Preencha todos os campos para adicionar um novo espaço de trabalho.",
"read": "Ler",
"read_write": "Ler e Escrever",
"select_member": "Selecionar membro",
"select_workspace": "Selecionar espaço de trabalho",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",
@@ -1242,6 +1153,7 @@
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das Opções Acima\"",
"add_option": "Adicionar opção",
@@ -1280,7 +1192,6 @@
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brand_color_description": "Aplicado a botões, links e destaques.",
"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.",
@@ -1298,9 +1209,7 @@
"capture_new_action": "Capturar nova ação",
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_background_color_description": "Preenche a área do cartão do inquérito.",
"card_border_color": "Cor da borda do cartão",
"card_border_color_description": "Contorna o cartão do inquérito.",
"card_styling": "Estilo de cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
@@ -1311,12 +1220,20 @@
"caution_explanation_responses_are_safe": "As respostas mais antigas e mais recentes se misturam, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_text": "As alterações levarão a inconsistências",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"change_anyway": "Alterar mesmo assim",
"change_background": "Alterar fundo",
"change_question_type": "Alterar tipo de pergunta",
"change_survey_type": "Alterar o tipo de inquérito afeta o acesso existente",
"change_the_background_color_of_the_card": "Alterar a cor de fundo do cartão",
"change_the_background_color_of_the_input_fields": "Alterar a cor de fundo dos campos de entrada",
"change_the_background_to_a_color_image_or_animation": "Altere o fundo para uma cor, imagem ou animação",
"change_the_border_color_of_the_card": "Alterar a cor da borda do cartão.",
"change_the_border_color_of_the_input_fields": "Alterar a cor da borda dos campos de entrada",
"change_the_border_radius_of_the_card_and_the_inputs": "Alterar o raio da borda do cartão e dos campos de entrada",
"change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito",
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"changes_saved": "Alterações guardadas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"checkbox_label": "Rótulo da Caixa de Seleção",
@@ -1356,7 +1273,6 @@
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
"display_type": "Tipo de exibição",
"divide": "Dividir /",
"does_not_contain": "Não contém",
"does_not_end_with": "Não termina com",
@@ -1364,7 +1280,6 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"dropdown": "Menu suspenso",
"duplicate_block": "Duplicar bloco",
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
@@ -1456,6 +1371,7 @@
"hide_progress_bar": "Ocultar barra de progresso",
"hide_question_settings": "Ocultar definições da pergunta",
"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",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
"ignore_global_waiting_time": "Ignorar período de espera",
@@ -1466,9 +1382,7 @@
"initial_value": "Valor inicial",
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda do campo de entrada",
"input_border_color_description": "Contorna campos de texto e áreas de texto.",
"input_color": "Cor do campo de entrada",
"input_color_description": "Preenche o interior dos campos de texto.",
"insert_link": "Inserir ligação",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os seus filtros de audiência",
"invalid_video_url_warning": "Por favor, insira um URL válido do YouTube, Vimeo ou Loom. Atualmente, não suportamos outros fornecedores de hospedagem de vídeo.",
@@ -1498,7 +1412,6 @@
"limit_the_maximum_file_size": "Limitar o tamanho máximo de ficheiro para carregamentos.",
"limit_upload_file_size_to": "Limitar o tamanho de ficheiro de carregamento para",
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
"list": "Lista",
"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",
@@ -1552,6 +1465,7 @@
"protect_survey_with_pin_description": "Apenas utilizadores com o PIN podem aceder ao inquérito.",
"publish": "Publicar",
"question": "Pergunta",
"question_color": "Cor da pergunta",
"question_deleted": "Pergunta eliminada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
@@ -1613,7 +1527,6 @@
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"roundness": "Arredondamento",
"roundness_description": "Controla o arredondamento dos cantos do cartão.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"rows": "Linhas",
"save_and_close": "Guardar e Fechar",
@@ -1655,6 +1568,7 @@
"styling_set_to_theme_styles": "Estilo definido para estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"suggest_colors": "Sugerir cores",
"survey_completed_heading": "Inquérito Concluído",
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito",
@@ -1731,7 +1645,7 @@
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que cumpram os seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A variável \"{variableName}\" está a ser usada na quota \"{quotaName}\"",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
@@ -1957,7 +1871,6 @@
"filtered_responses_excel": "Respostas filtradas (Excel)",
"generating_qr_code": "A gerar código QR",
"impressions": "Impressões",
"impressions_identified_only": "A mostrar apenas impressões de contactos identificados",
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
"in_app": {
"connection_description": "O questionário será exibido aos utilizadores do seu website que correspondam aos critérios listados abaixo",
@@ -2000,7 +1913,6 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"limit": "Limite",
"no_identified_impressions": "Sem impressões de contactos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
@@ -2140,71 +2052,9 @@
"look": {
"add_background_color": "Adicionar cor de fundo",
"add_background_color_description": "Adicione uma cor de fundo ao contentor do logótipo.",
"advanced_styling_field_border_radius": "Raio da borda",
"advanced_styling_field_button_bg": "Fundo do botão",
"advanced_styling_field_button_bg_description": "Preenche o botão Seguinte / Submeter.",
"advanced_styling_field_button_border_radius_description": "Arredonda os cantos do botão.",
"advanced_styling_field_button_font_size_description": "Ajusta o tamanho do texto da etiqueta do botão.",
"advanced_styling_field_button_font_weight_description": "Torna o texto do botão mais leve ou mais negrito.",
"advanced_styling_field_button_height_description": "Controla a altura do botão.",
"advanced_styling_field_button_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_button_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_button_text": "Texto do botão",
"advanced_styling_field_button_text_description": "Colore a etiqueta dentro dos botões.",
"advanced_styling_field_description_color": "Cor da descrição",
"advanced_styling_field_description_color_description": "Colore o texto abaixo de cada título.",
"advanced_styling_field_description_size": "Tamanho da fonte da descrição",
"advanced_styling_field_description_size_description": "Ajusta o tamanho do texto da descrição.",
"advanced_styling_field_description_weight": "Peso da fonte da descrição",
"advanced_styling_field_description_weight_description": "Torna o texto da descrição mais leve ou mais negrito.",
"advanced_styling_field_font_size": "Tamanho da fonte",
"advanced_styling_field_font_weight": "Peso da fonte",
"advanced_styling_field_headline_color": "Cor do título",
"advanced_styling_field_headline_color_description": "Colore o texto principal da pergunta.",
"advanced_styling_field_headline_size": "Tamanho da fonte do título",
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
"advanced_styling_field_input_shadow_description": "Adiciona uma sombra ao redor dos campos de entrada.",
"advanced_styling_field_input_text": "Texto de entrada",
"advanced_styling_field_input_text_description": "Colore o texto digitado nos campos de entrada.",
"advanced_styling_field_option_bg": "Fundo",
"advanced_styling_field_option_bg_description": "Preenche os itens de opção.",
"advanced_styling_field_option_border_radius_description": "Arredonda os cantos das opções.",
"advanced_styling_field_option_font_size_description": "Ajusta o tamanho do texto da etiqueta da opção.",
"advanced_styling_field_option_label": "Cor da etiqueta",
"advanced_styling_field_option_label_description": "Colore o texto da etiqueta da opção.",
"advanced_styling_field_option_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_option_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_padding_x": "Espaçamento X",
"advanced_styling_field_padding_y": "Espaçamento Y",
"advanced_styling_field_placeholder_opacity": "Opacidade do marcador de posição",
"advanced_styling_field_shadow": "Sombra",
"advanced_styling_field_track_bg": "Fundo da faixa",
"advanced_styling_field_track_bg_description": "Colore a porção não preenchida da barra.",
"advanced_styling_field_track_height": "Altura da faixa",
"advanced_styling_field_track_height_description": "Controla a espessura da barra de progresso.",
"advanced_styling_field_upper_label_color": "Cor da etiqueta do título",
"advanced_styling_field_upper_label_color_description": "Colore a pequena etiqueta acima dos campos de entrada.",
"advanced_styling_field_upper_label_size": "Tamanho da fonte da etiqueta do título",
"advanced_styling_field_upper_label_size_description": "Ajusta o tamanho da pequena etiqueta acima dos campos de entrada.",
"advanced_styling_field_upper_label_weight": "Peso da fonte da etiqueta do título",
"advanced_styling_field_upper_label_weight_description": "Torna a etiqueta mais leve ou mais negrito.",
"advanced_styling_section_buttons": "Botões",
"advanced_styling_section_headlines": "Títulos e descrições",
"advanced_styling_section_inputs": "Campos de entrada",
"advanced_styling_section_options": "Opções (rádio/caixa de seleção)",
"app_survey_placement": "Colocação do inquérito (app)",
"app_survey_placement_settings_description": "Altere onde os inquéritos serão apresentados na sua aplicação web ou website.",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"email_customization": "Personalização de e-mail",
"email_customization_description": "Altere a aparência dos e-mails que a Formbricks envia em seu nome.",
"enable_custom_styling": "Ativar estilização personalizada",
@@ -2215,9 +2065,6 @@
"formbricks_branding_hidden": "A marca Formbricks está oculta.",
"formbricks_branding_settings_description": "Adoramos o seu apoio, mas compreendemos se preferir desativar.",
"formbricks_branding_shown": "A marca Formbricks está visível.",
"generate_theme_btn": "Gerar",
"generate_theme_confirmation": "Gostarias de gerar um tema de cores correspondente com base na cor da tua marca? Isto irá substituir as tuas definições de cor atuais.",
"generate_theme_header": "Gerar tema de cores?",
"logo_removed_successfully": "Logótipo removido com sucesso",
"logo_settings_description": "Carregue o logótipo da sua empresa para personalizar inquéritos e pré-visualizações de links.",
"logo_updated_successfully": "Logótipo atualizado com sucesso",
@@ -2232,8 +2079,6 @@
"show_formbricks_branding_in": "Mostrar marca Formbricks em inquéritos {type}",
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressiona \"Guardar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
},
@@ -2997,7 +2842,6 @@
"preview_survey_question_2_choice_1_label": "Sim, mantenha-me informado.",
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"prioritize_features_description": "Identifique as funcionalidades que os seus utilizadores precisam mais e menos.",
"prioritize_features_name": "Priorizar Funcionalidades",

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