Compare commits

..

59 Commits

Author SHA1 Message Date
Dhruwang 2da72cefba Merge branch 'main' of https://github.com/formbricks/formbricks into feat/estonian-language-surveys 2026-03-27 11:27:47 +05:30
Dhruwang Jariwala 83bc272ed2 fix: prevent duplicate hobby subscriptions from race condition (#7597)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:50:52 +00:00
Dhruwang Jariwala 59cc9c564e fix: duplicate org creation (#7593) 2026-03-26 05:52:09 +00:00
Dhruwang Jariwala 20dc147682 fix: scrolling behaviour to invalid questions (#7573) 2026-03-25 13:35:51 +00:00
cursor[bot] 2bb7a6f277 fix: prevent TypeError when checking for duplicate matrix labels (#7579)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-25 13:14:18 +00:00
Dhruwang Jariwala deb062dd03 fix: handle 404 race condition in Stripe webhook reconciliation (#7584) 2026-03-25 09:58:00 +00:00
Dhruwang Jariwala 474be86d33 fix: translations for option types (#7576) 2026-03-24 13:18:26 +00:00
Dhruwang Jariwala e7ca66ed77 fix: use TTC data for reliable survey impression counting (#7572) 2026-03-24 08:52:35 +00:00
Illimar Reinbusch 0b0378cb4d feat: add Estonian language support for surveys 2026-03-24 10:32:39 +02:00
Matti Nannt 2b49dbecd3 chore: add dev:setup script to generate .env and missing secrets (#7555)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 08:26:32 +00:00
Anshuman Pandey 6da4c6f352 fix: proper errors server side when resources are not found (#7571) 2026-03-24 07:52:37 +00:00
Aryan Ghugare 659b240fca feat: enhance welcome card to support video uploads and display #7491 (#7497)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 07:34:43 +00:00
Dhruwang Jariwala 19c0b1d14d fix: response table settings formatting (#7540)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 06:36:45 +00:00
Dhruwang Jariwala b4472f48e9 fix: (Duplicate) prevent multi-language survey buttons from falling back to English (#7559) 2026-03-24 05:45:47 +00:00
bharath kumar d197271771 fix(web): add <noscript> message for when JS is disabled (#7455) (#7459)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 12:35:29 +00:00
Dhruwang Jariwala 37f652c70e fix: prevent session expiry during active use (#7558) 2026-03-23 10:44:55 +00:00
Matti Nannt 645f0ab0d1 fix: resolve remaining dependabot alerts (#7561) 2026-03-23 09:59:01 +00:00
Johannes 389a7d9e7b feat: enhance segment activity summary and settings in segment modal (#7553)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-23 08:39:10 +00:00
Tiago c4cf468c7e fix: localize survey and app date rendering (#7473)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 07:23:07 +00:00
Johannes cbc3e923e4 fix: segment targeting "isNotIn" didnt work (#7550) 2026-03-23 05:22:19 +00:00
Tiago a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt 998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr 13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala 0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey 0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago 78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala 95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala 136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey 0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr 540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala 2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Dhruwang Jariwala 633bf18204 fix: auto-expand multi-language card when toggle is enabled (#7504)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:18:35 +00:00
Balázs Úr 9a6cbd05b6 fix: mark various strings as translatable (#7338)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 11:30:38 +00:00
Johannes 94b0248075 fix: only allow URL in exact match URL (#7505)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 07:20:14 +00:00
Johannes 082de1042d feat: add validation for custom survey closed message heading (#7502) 2026-03-18 06:40:57 +00:00
Johannes 8c19587baa fix: ensure at least one filter is required for segments (#7503)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:39:58 +00:00
Anshuman Pandey 433750d3fe fix: removes pino pretty from edge runtime (#7510) 2026-03-18 06:32:55 +00:00
Johannes 61befd5ffd feat: add enterprise license features table (#7492)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:14:40 +00:00
Dhruwang Jariwala 1e7817fb69 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-17 15:33:44 +00:00
Anshuman Pandey f250bc7e88 fix: fixes race between setUserId and trigger (#7498)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 08:57:07 +00:00
Santosh c7faa29437 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7409)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 05:36:58 +00:00
Anshuman Pandey a51a006c26 fix: fixes data element i18n fixes (#7488) 2026-03-16 10:12:48 +00:00
Matti Nannt ce96cb0b89 feat: replace hosted stripe pricing table (#7486)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-16 10:11:40 +00:00
Matti Nannt fb265d9dba feat: add SAML telemetry reporting (#7461) 2026-03-16 09:41:33 +00:00
Matti Nannt e4c155b501 fix: defer hobby subscription creation (#7484) 2026-03-15 14:13:53 +00:00
Johannes 2dc5c50f4d feat: implement trial days remaining alert in billing components (#7474) 2026-03-13 16:38:43 +01:00
Anshuman Pandey bddcec0466 fix: adds monkey patching for replaceState (#7475) 2026-03-13 13:40:20 +00:00
Dhruwang Jariwala 92677e1ec0 fix: respect overwriteThemeStyling in link survey metadata (#7466)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-13 13:07:54 +00:00
Anshuman Pandey b12228e305 fix: fixes button url fixes in survey editor (#7472) 2026-03-13 13:07:41 +00:00
Dhruwang Jariwala 91be2af30b fix: add missing Stripe billing setup for setup route org creation (#7470)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:18:01 +01:00
Anshuman Pandey 84c668be86 fix: fixes contact links api gating issue (#7468) 2026-03-13 11:09:53 +00:00
Dhruwang Jariwala 4015c76f2b fix: use logical CSS direction classes for RTL matrix question (#7463)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:06:41 +00:00
Dhruwang Jariwala a7b2ade4a9 fix: remove follow-ups from trial features and gate trial page for subscribers (#7465)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:00:23 +00:00
Dhruwang Jariwala 75f44952c7 fix: clear validation settings when disabling open text validation (#7464)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:39:42 +00:00
339 changed files with 13788 additions and 3967 deletions
+1 -2
View File
@@ -150,7 +150,6 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PRICING_TABLE_ID=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -232,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGO_API_KEY=your_api_key_here
+8
View File
@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.
@@ -1,5 +1,6 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const channel = project.config.channel || null;
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projects = await getUserProjects(session.user.id, organizationId);
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
return (
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
@@ -28,7 +29,7 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
@@ -1,8 +1,12 @@
import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
interface PlanPageProps {
params: Promise<{
organizationId: string;
@@ -22,6 +26,16 @@ const Page = async (props: PlanPageProps) => {
return redirect(`/auth/login`);
}
// Users with an existing paid/trial subscription should not be shown the trial page.
// Redirect them directly to the next onboarding step.
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
const currentPlan = billing?.stripe?.plan;
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
if (hasExistingSubscription) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
};
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
@@ -45,7 +46,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
throw new ResourceNotFoundError(t("common.team"), null);
}
const publicDomain = getPublicDomain();
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -17,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
return (
@@ -2,7 +2,11 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -46,7 +50,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", organizationId);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -42,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
return (
@@ -28,6 +28,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
@@ -167,6 +168,20 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return (
@@ -241,6 +256,13 @@ export const MainNavigation = ({
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
{/* User Switch */}
<div className="flex items-center">
<DropdownMenu>
@@ -1,4 +1,5 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -20,15 +21,15 @@ const AccountSettingsLayout = async (props: {
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -146,18 +147,18 @@ const Page = async (props: {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!memberships) {
throw new Error(t("common.membership_not_found"));
throw new ResourceNotFoundError(t("common.membership"), null);
}
if (user?.notificationSettings) {
@@ -1,3 +1,4 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -60,7 +61,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[
{
text: IS_FORMBRICKS_CLOUD
? t("common.start_free_trial")
? t("common.upgrade_plan")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing`
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -25,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
@@ -0,0 +1,146 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -15,6 +16,7 @@ import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps {
status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date;
environmentId: string;
}
@@ -44,10 +46,12 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({
status,
lastChecked,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -92,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
<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 className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div>
<Button
type="button"
@@ -118,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
<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, {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
year: "numeric",
month: "short",
day: "numeric",
@@ -10,6 +10,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
@@ -93,15 +94,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
<>
<EnterpriseLicenseStatus
status={licenseState.status}
lastChecked={licenseState.lastChecked}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : (
<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">
@@ -1,4 +1,5 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -17,15 +18,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
);
// Save settings to localStorage when they change
@@ -300,7 +300,6 @@ export const ResponseTable = ({
<DataTableSettingsModal
open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table}
columnOrder={columnOrder}
handleDragEnd={handleDragEnd}
@@ -8,10 +8,11 @@ import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -34,6 +35,7 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -167,6 +169,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -218,6 +221,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt",
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
},
};
@@ -1,3 +1,4 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -23,25 +23,24 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
throw new Error(t("common.survey_not_found"));
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -50,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), organization.id);
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
@@ -86,7 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
locale={user.locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
@@ -64,15 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
@@ -81,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
return (
formattedDate ??
t("common.invalid_date_with_value", {
value,
})
);
};
return (
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
<div className="px-4 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
@@ -9,11 +10,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate();
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
throw new ResourceNotFoundError(t("common.survey"), surveyId);
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Workspace not found");
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const styling = getStyling(project, survey);
@@ -11,8 +11,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import {
getElementSummary,
getResponsesForSummary,
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
}));
vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredElementIds: [],
calculations: {},
});
});
test("calculates dropOff correctly with welcome card disabled", () => {
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 },
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
finished: false,
}, // Dropped at q2
{
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
);
expect(dropOff.length).toBe(2);
// Q1
// Q1: welcome card disabled so impressions = displayCount
expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
expect(dropOff[0].impressions).toBe(displayCount);
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10);
// Q2
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10);
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
});
test("handles logic jumps", () => {
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
const surveyWithWelcome: TSurvey = {
...surveyWithBlocks,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
};
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 }, // Only saw q1, never reached q2
finished: false,
},
] as any;
const displayCount = 1;
const dropOff = getSurveySummaryDropOff(
surveyWithWelcome,
getElementsFromBlocks(surveyWithWelcome.blocks),
responses,
displayCount
);
expect(dropOff[0].impressions).toBe(1); // Saw q1
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
expect(dropOff[1].impressions).toBe(0); // Never saw q2
expect(dropOff[1].dropOffCount).toBe(0);
});
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
const surveyWithLogic: TSurvey = {
...mockBaseSurvey,
blocks: [
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
charLimit: { enabled: false },
},
] as TSurveyElement[],
logic: [
{
id: "logic1",
conditions: {
id: "condition1",
connector: "and" as const,
conditions: [
{
id: "c1",
leftOperand: {
type: "element" as const,
value: "q2",
},
operator: "equals" as const,
rightOperand: {
type: "static" as const,
value: "b",
},
},
],
},
actions: [
{
id: "action1",
objective: "jumpToBlock" as const,
target: "q4",
},
],
},
],
},
{
id: "block3",
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
],
questions: [],
};
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b" },
data: { q1: "a", q2: "b", q4: "d" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 10 },
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
finished: false,
}, // Jumps from q2 to q4, drops at q4
},
];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff(
surveyWithLogic,
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
1
);
expect(dropOff[0].impressions).toBe(1); // q1
expect(dropOff[1].impressions).toBe(1); // q2
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
expect(dropOff[0].impressions).toBe(1); // q1: seen
expect(dropOff[1].impressions).toBe(1); // q2: seen
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
});
});
@@ -11,7 +11,6 @@ import {
TResponseData,
TResponseFilterCriteria,
TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
@@ -37,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
@@ -93,63 +91,13 @@ export const getSurveySummaryMeta = (
};
};
const evaluateLogicAndGetNextElementId = (
localSurvey: TSurvey,
elements: TSurveyElement[],
data: TResponseData,
localVariables: TResponseVariables,
currentElementIndex: number,
currElementTemp: TSurveyElement,
selectedLanguage: string | null
): {
nextElementId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredElementIds.length > 0) {
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next element
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables };
// Determine whether a response interacted with a given element.
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
// This is more reliable than replaying survey logic, which can misattribute impressions
// when branching logic skips elements or when partial response data is insufficient
// to evaluate conditions correctly.
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
};
export const getSurveySummaryDropOff = (
@@ -170,16 +118,8 @@ export const getSurveySummaryDropOff = (
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => {
// Calculate total time-to-completion
// Calculate total time-to-completion per element
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
@@ -187,51 +127,21 @@ export const getSurveySummaryDropOff = (
}
});
let localSurvey = structuredClone(survey);
let localResponseData: TResponseData = { ...response.data };
let localVariables: TResponseVariables = {
...surveyVariablesData,
};
// Count impressions based on actual interaction data (ttc + response data)
// instead of replaying survey logic which is unreliable with branching
let lastSeenIdx = -1;
let currQuesIdx = 0;
while (currQuesIdx < elements.length) {
const currQues = elements[currQuesIdx];
if (!currQues) break;
// element is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (wasElementSeen(response, element.id)) {
impressionsArr[i]++;
lastSeenIdx = i;
}
}
impressionsArr[currQuesIdx]++;
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
localSurvey,
elements,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextElementId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
if (!response.data[nextElementId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
// Attribute drop-off to the last element the respondent interacted with
if (!response.finished && lastSeenIdx >= 0) {
dropOffArr[lastSeenIdx]++;
}
});
@@ -240,6 +150,8 @@ export const getSurveySummaryDropOff = (
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
});
// When the welcome card is disabled, the first element's impressions should equal displayCount
// because every survey display is an impression of the first element
if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - impressionsArr[0];
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
@@ -251,7 +163,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount;
} else {
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
}
for (let i = 1; i < elements.length; i++) {
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -32,13 +33,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new Error(t("common.survey_not_found"));
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
@@ -46,11 +47,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
@@ -2,21 +2,16 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
@@ -97,68 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);
@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -54,6 +55,25 @@ export enum OptionsType {
QUOTAS = "Quotas",
}
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = {
label: string;
elementType?: TSurveyElementTypeEnum;
@@ -218,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
<CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
Select,
SelectContent,
@@ -14,7 +15,6 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
@@ -1,4 +1,6 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { SurveyContextWrapper } from "./context/survey-context";
interface SurveyLayoutProps {
@@ -10,9 +12,10 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
const resolvedParams = await params;
const survey = await getSurvey(resolvedParams.surveyId);
const t = await getTranslate();
if (!survey) {
throw new Error("Survey not found");
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
}
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,11 +18,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -33,9 +34,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -52,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>
@@ -3,13 +3,14 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -21,19 +22,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -49,7 +48,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>
@@ -3,6 +3,7 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET,
@@ -11,7 +12,7 @@ import {
} from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -28,18 +29,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration] = await Promise.all([
const [surveys, notionIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getUserLocale(session.user.id),
]);
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
@@ -56,7 +57,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</PageContentWrapper>
);
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,15 +17,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration] = await Promise.all([
const [surveys, slackIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -41,7 +40,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>
+4 -2
View File
@@ -6,8 +6,10 @@ import {
CHATWOOT_WEBSITE_TOKEN,
IS_CHATWOOT_CONFIGURED,
POSTHOG_KEY,
SESSION_MAX_AGE,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
@@ -23,7 +25,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
}
return (
<>
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
<NoMobileOverlay />
{POSTHOG_KEY && user && (
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
@@ -39,7 +41,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
)}
<ToasterClient />
{children}
</>
</NextAuthProvider>
);
};
@@ -50,6 +50,7 @@ vi.mock("@/lib/env", () => ({
RECAPTCHA_SITE_KEY: "site-key",
RECAPTCHA_SECRET_KEY: "secret-key",
GITHUB_ID: "github-id",
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
},
}));
@@ -138,6 +139,7 @@ describe("sendTelemetryEvents", () => {
expect(payload.userCount).toBe(5);
expect(payload.integrations.notion).toBe(true);
expect(payload.sso.github).toBe(true);
expect(payload.sso.saml).toBe(true);
// Check cache update (no TTL parameter)
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
@@ -212,6 +212,7 @@ const sendTelemetry = async (lastSent: number) => {
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
saml: !!env.SAML_DATABASE_URL || ssoProviders.some((p) => p.provider === "saml"),
};
// Construct telemetry payload with usage statistics and configuration.
@@ -190,7 +190,7 @@ export const PUT = withV1ApiWrapper({
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -1,12 +1,14 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import {
TSurvey,
TSurveyCreateInputWithEnvironmentId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { checkFeaturePermissions } from "./utils";
// Mock dependencies
@@ -24,6 +26,14 @@ vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
getSurveyFollowUpsPermission: vi.fn(),
}));
vi.mock("@/modules/survey/lib/permission", () => ({
getExternalUrlsPermission: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
}));
const mockOrganization: TOrganization = {
id: "test-org",
name: "Test Organization",
@@ -98,6 +108,13 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
};
describe("checkFeaturePermissions", () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
afterEach(() => {
vi.clearAllMocks();
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
});
test("should return null if no restricted features are used", async () => {
const surveyData = { ...baseSurveyData };
const result = await checkFeaturePermissions(surveyData, mockOrganization);
@@ -197,4 +214,315 @@ describe("checkFeaturePermissions", () => {
);
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
});
// External URLs - ending card button link tests
test("should return forbiddenResponse when adding new ending with buttonLink without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://example.com",
buttonLabel: { default: "Click" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"External URLs are not enabled for this organization. Upgrade to use external button links."
);
});
test("should return forbiddenResponse when changing ending buttonLink without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://new-url.com",
buttonLabel: { default: "Click" },
},
],
};
const oldSurvey = {
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://old-url.com",
buttonLabel: { default: "Click" },
},
],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});
test("should allow keeping existing ending buttonLink without permission (grandfathering)", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://existing-url.com",
buttonLabel: { default: "Click" },
},
],
};
const oldSurvey = {
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://existing-url.com",
buttonLabel: { default: "Click" },
},
],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeNull();
});
test("should allow ending buttonLink when permission is granted", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
endings: [
{
id: "ending1",
type: "endScreen" as const,
headline: { default: "Thanks" },
subheader: { default: "" },
buttonLink: "https://example.com",
buttonLabel: { default: "Click" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
// External URLs - CTA external button tests
test("should return forbiddenResponse when adding CTA with external button without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
);
});
test("should return forbiddenResponse when changing CTA external button URL without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://new-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const oldSurvey = {
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://old-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});
test("should allow keeping existing CTA external button without permission (grandfathering)", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://existing-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const oldSurvey = {
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://existing-url.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeNull();
});
test("should allow CTA external button when permission is granted", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
expect(result).toBeNull();
});
test("should return forbiddenResponse when switching CTA from internal to external without permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const surveyData = {
...baseSurveyData,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
};
const oldSurvey = {
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: false,
buttonUrl: "",
ctaButtonLabel: { default: "Click" },
},
],
buttonLabel: { default: "Next" },
},
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});
});
@@ -1,12 +1,15 @@
import { TOrganization } from "@formbricks/types/organizations";
import { TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
export const checkFeaturePermissions = async (
surveyData: TSurveyCreateInputWithEnvironmentId,
organization: TOrganization
organization: TOrganization,
oldSurvey?: TSurvey
): Promise<Response | null> => {
if (surveyData.recaptcha?.enabled) {
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id);
@@ -22,5 +25,46 @@ export const checkFeaturePermissions = async (
}
}
const isExternalUrlsAllowed = await getExternalUrlsPermission(organization.id);
if (!isExternalUrlsAllowed) {
// Check ending cards for new/changed button links
if (surveyData.endings) {
for (const newEnding of surveyData.endings) {
const oldEnding = oldSurvey?.endings.find((e) => e.id === newEnding.id);
if (newEnding.type === "endScreen" && newEnding.buttonLink) {
if (!oldEnding || oldEnding.type !== "endScreen" || oldEnding.buttonLink !== newEnding.buttonLink) {
return responses.forbiddenResponse(
"External URLs are not enabled for this organization. Upgrade to use external button links."
);
}
}
}
}
// Check CTA elements for new/changed external button URLs
if (surveyData.blocks) {
const newElements = getElementsFromBlocks(surveyData.blocks);
const oldElements = oldSurvey?.blocks ? getElementsFromBlocks(oldSurvey.blocks) : [];
for (const newElement of newElements) {
const oldElement = oldElements.find((e) => e.id === newElement.id);
if (newElement.type === "cta" && newElement.buttonExternal) {
if (
!oldElement ||
oldElement.type !== "cta" ||
!oldElement.buttonExternal ||
oldElement.buttonUrl !== newElement.buttonUrl
) {
return responses.forbiddenResponse(
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
);
}
}
}
}
}
return null;
};
+324
View File
@@ -0,0 +1,324 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockGetServerSession: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: mockAuthenticateRequest,
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
describe("withV3ApiWrapper", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
expect(authentication).toMatchObject({ user: { id: "user_1" } });
expect(requestId).toBe("req-1");
expect(instance).toBe("/api/v3/surveys");
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
headers: { "x-request-id": "req-1" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("req-1");
expect(handler).toHaveBeenCalledOnce();
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"user_1"
);
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
});
test("falls back to api key auth in both mode", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
environmentPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-api-key": "fbk_test" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"key_1"
);
expect(mockGetServerSession).not.toHaveBeenCalled();
});
test("returns 401 problem response when authentication is required but missing", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
});
test("returns 400 problem response for invalid query input", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
schemas: {
query: z.object({
limit: z.coerce.number().int().positive(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
headers: { "x-request-id": "req-invalid" },
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
expect(body.requestId).toBe("req-invalid");
});
test("parses body, repeated query params, and async route params", async () => {
const handler = vi.fn(async ({ parsedInput }) => {
expect(parsedInput).toEqual({
body: { name: "Survey API" },
query: { tag: ["a", "b"] },
params: { workspaceId: "ws_123" },
});
return Response.json(
{ ok: true },
{
headers: {
"X-Request-Id": "handler-request-id",
},
}
);
});
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
query: z.object({
tag: z.array(z.string()),
}),
params: z.object({
workspaceId: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
method: "POST",
body: JSON.stringify({ name: "Survey API" }),
headers: {
"Content-Type": "application/json",
},
}),
{
params: Promise.resolve({
workspaceId: "ws_123",
}),
} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
expect(handler).toHaveBeenCalledOnce();
});
test("returns 400 problem response for malformed JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{",
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "body",
reason: "Malformed JSON input, please check your request body",
},
]);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
params: z.object({
workspaceId: z.string().min(3),
}),
},
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
params: Promise.resolve({
workspaceId: "x",
}),
} as never);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => Response.json({ ok: true }),
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("60");
const body = await response.json();
expect(body.code).toBe("too_many_requests");
});
test("returns 500 problem response when the handler throws unexpectedly", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => {
throw new Error("boom");
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-request-id": "req-boom" },
}),
{} as never
);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.code).toBe("internal_server_error");
expect(body.requestId).toBe("req-boom");
});
});
+349
View File
@@ -0,0 +1,349 @@
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
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 type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
export type TV3Schemas = {
body?: TV3Schema;
query?: TV3Schema;
params?: TV3Schema;
};
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
}
: Record<string, never>;
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
parsedInput: TParsedInput;
requestId: string;
instance: string;
};
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
auth?: TV3AuthMode;
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
if (authMode === "session") {
return "Session required";
}
if (authMode === "apiKey") {
return "API key required";
}
return "Not authenticated";
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {};
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key);
query[key] = values.length > 1 ? values : (values[0] ?? "");
}
return query;
}
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
if (!authentication) {
return null;
}
if ("user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
if ("apiKeyId" in authentication) {
return authentication.apiKeyId;
}
return null;
}
function isPromiseLike<T>(value: unknown): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
if (!props || typeof props !== "object" || !("params" in props)) {
return {};
}
const params = (props as { params?: unknown }).params;
if (!params) {
return {};
}
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
return typeof resolvedParams === "object" && resolvedParams !== null
? (resolvedParams as Record<string, unknown>)
: {};
}
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
if (authMode === "none") {
return null;
}
if (authMode === "both" && req.headers.has("x-api-key")) {
const apiKeyAuth = await authenticateRequest(req);
if (apiKeyAuth) {
return apiKeyAuth;
}
}
if (authMode === "session" || authMode === "both") {
const session = await getServerSession(authOptions);
if (session?.user?.id) {
return session;
}
if (authMode === "session") {
return null;
}
}
if (authMode === "apiKey" || authMode === "both") {
return await authenticateRequest(req);
}
return null;
}
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
req: NextRequest,
props: TProps,
schemas: S | undefined,
requestId: string,
instance: string
): Promise<
| { ok: true; parsedInput: TV3ParsedInput<S> }
| {
ok: false;
response: Response;
}
> {
const parsedInput = {} as TV3ParsedInput<S>;
if (schemas?.body) {
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
}),
};
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: formatZodIssues(bodyResult.error, "body"),
}),
};
}
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
}
if (schemas?.query) {
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
if (!queryResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid query parameters", {
instance,
invalid_params: formatZodIssues(queryResult.error, "query"),
}),
};
}
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
}
if (schemas?.params) {
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
if (!paramsResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid route parameters", {
instance,
invalid_params: formatZodIssues(paramsResult.error, "params"),
}),
};
}
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
}
return { ok: true, parsedInput };
}
function ensureRequestIdHeader(response: Response, requestId: string): Response {
if (response.headers.get("X-Request-Id")) {
return response;
}
const headers = new Headers(response.headers);
headers.set("X-Request-Id", requestId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function authenticateV3RequestOrRespond(
req: NextRequest,
authMode: TV3AuthMode,
requestId: string,
instance: string
): Promise<
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
> {
const authentication = await authenticateV3Request(req, authMode);
if (!authentication && authMode !== "none") {
return {
authentication: null,
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
};
}
return {
authentication,
response: null,
};
}
async function applyV3RateLimitOrRespond(params: {
authentication: TV3Authentication;
enabled: boolean;
config: TRateLimitConfig;
requestId: string;
log: ReturnType<typeof logger.withContext>;
}): Promise<Response | null> {
const { authentication, enabled, config, requestId, log } = params;
if (!enabled) {
return null;
}
const identifier = getRateLimitIdentifier(authentication);
if (!identifier) {
return null;
}
try {
await applyRateLimit(config, identifier);
} catch (error) {
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
return problemTooManyRequests(
requestId,
error instanceof Error ? error.message : "Rate limit exceeded",
error instanceof TooManyRequestsError ? error.retryAfter : undefined
);
}
return null;
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const instance = req.nextUrl.pathname;
const log = logger.withContext({
requestId,
method: req.method,
path: instance,
});
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
if (authResult.response) {
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
return authResult.response;
}
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
if (!parsedInputResult.ok) {
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
return parsedInputResult.response;
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}
const response = await handler({
req,
props,
authentication: authResult.authentication,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
return ensureRequestIdHeader(response, requestId);
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
};
};
+274
View File
@@ -0,0 +1,274 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
test("returns 401 when authentication is null", async () => {
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 401 when authentication is API key (no user)", async () => {
const result = await requireSessionWorkspaceAccess(
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
"proj_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_nonexistent",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
],
});
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"readWrite",
requestId
);
expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_abc",
organizationId: "org_1",
});
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
],
});
});
});
const keyBase = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_k",
organizationAccess: { accessControl: { read: true, write: false } },
};
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
return {
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_k",
projectName: "K",
permission,
};
}
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({
id: "env_k",
projectId: "proj_k",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
});
test("401 when authentication is null", async () => {
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
expect((r as Response).status).toBe(401);
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s",
projectId: "proj_s",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_s",
"read",
requestId
);
expect(r).toEqual({
environmentId: "env_s",
projectId: "proj_s",
organizationId: "org_s",
});
});
test("returns context for API key with read on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({
environmentId: "ws_a",
projectId: "proj_k",
organizationId: "org_k",
});
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
});
test("returns context for API key with write on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({
environmentId: "ws_b",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when API key permission is lower than the required permission", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key has no matching environment", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("other_env")],
};
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key permission is not list-eligible (runtime value)", async () => {
const auth = {
...keyBase,
environmentPermissions: [
{
...envPerm("ws_c"),
permission: "invalid" as unknown as ApiKeyPermission,
},
],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("returns context for API key with manage on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
expect(r).toEqual({
environmentId: "ws_m",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("401 when auth is neither session nor valid API key payload", async () => {
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
expect((r as Response).status).toBe(401);
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* V3 API auth session (browser) or API key with environment-scoped access.
*/
import { ApiKeyPermission } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
const grantedRank = {
[ApiKeyPermission.read]: 1,
[ApiKeyPermission.write]: 2,
[ApiKeyPermission.manage]: 3,
}[permission];
const requiredRank = {
read: 1,
readWrite: 2,
manage: 3,
}[minPermission];
return grantedRank >= requiredRank;
}
/**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/
export async function requireSessionWorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const userId = authentication.user.id;
const log = logger.withContext({ requestId, workspaceId });
try {
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
log.warn({ statusCode: 403, errorCode: err.name }, message);
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
export async function requireV3WorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
try {
const context = await resolveV3WorkspaceContext(workspaceId);
const permission = keyAuth.environmentPermissions.find(
(environmentPermission) => environmentPermission.environmentId === context.environmentId
);
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return context;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw error;
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, test } from "vitest";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
successListResponse,
} from "./response";
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
expect(res.headers.get("X-Request-Id")).toBe("rid");
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
test("problemUnauthorized default detail", async () => {
const res = problemUnauthorized("r1");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.detail).toBe("Not authenticated");
expect(body.code).toBe("not_authenticated");
});
test("problemForbidden", async () => {
const res = problemForbidden("r2", undefined, "/api/x");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(body.instance).toBe("/api/x");
});
test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
expect(body.detail).toBe("oops");
});
test("problemNotFound includes details", async () => {
const res = problemNotFound("r4", "Survey", "s1", "/s");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("not_found");
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
});
test("problemTooManyRequests with Retry-After", async () => {
const res = problemTooManyRequests("r5", "slow down", 60);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const body = await res.json();
expect(body.code).toBe("too_many_requests");
});
test("problemTooManyRequests without Retry-After", async () => {
const res = problemTooManyRequests("r6", "nope");
expect(res.headers.get("Retry-After")).toBeNull();
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
[{ a: 1 }],
{ limit: 10, nextCursor: "cursor-1" },
{
requestId: "req-x",
}
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-x");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: [{ a: 1 }],
meta: { limit: 10, nextCursor: "cursor-1" },
});
});
test("custom Cache-Control", async () => {
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* V3 API response helpers RFC 9457 Problem Details (application/problem+json)
* and list envelope for success responses.
*/
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
requestId: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
};
export type ProblemBody = {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
} & ProblemExtension;
function problemResponse(
status: number,
title: string,
detail: string,
requestId: string,
options?: {
type?: string;
instance?: string;
code?: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
headers?: Record<string, string>;
}
): Response {
const body: ProblemBody = {
title,
status,
detail,
requestId,
...(options?.type && { type: options.type }),
...(options?.instance && { instance: options.instance }),
...(options?.code && { code: options.code }),
...(options?.details && { details: options.details }),
...(options?.invalid_params && { invalid_params: options.invalid_params }),
};
const headers: Record<string, string> = {
"Content-Type": PROBLEM_JSON,
"Cache-Control": CACHE_NO_STORE,
"X-Request-Id": requestId,
...options?.headers,
};
return Response.json(body, { status, headers });
}
export function problemBadRequest(
requestId: string,
detail: string,
options?: { invalid_params?: InvalidParam[]; instance?: string }
): Response {
return problemResponse(400, "Bad Request", detail, requestId, {
code: "bad_request",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
instance?: string
): Response {
return problemResponse(401, "Unauthorized", detail, requestId, {
code: "not_authenticated",
instance,
});
}
export function problemForbidden(
requestId: string,
detail: string = "You are not authorized to access this resource",
instance?: string
): Response {
return problemResponse(403, "Forbidden", detail, requestId, {
code: "forbidden",
instance,
});
}
/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
* Prefer problemForbidden with a generic message for those cases.
*/
export function problemNotFound(
requestId: string,
resourceType: string,
resourceId: string | null,
instance?: string
): Response {
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
code: "not_found",
details: { resource_type: resourceType, resource_id: resourceId },
instance,
});
}
export function problemInternalError(
requestId: string,
detail: string = "An unexpected error occurred.",
instance?: string
): Response {
return problemResponse(500, "Internal Server Error", detail, requestId, {
code: "internal_server_error",
instance,
});
}
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
const headers: Record<string, string> = {};
if (retryAfter !== undefined) {
headers["Retry-After"] = String(retryAfter);
}
return problemResponse(429, "Too Many Requests", detail, requestId, {
code: "too_many_requests",
headers,
});
}
export function successListResponse<T, TMeta extends Record<string, unknown>>(
data: T[],
meta: TMeta,
options?: { requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json({ data, meta }, { status: 200, headers });
}
+4
View File
@@ -0,0 +1,4 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
@@ -0,0 +1,38 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_xyz",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_xyz",
organizationId: "org_123",
});
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
});
test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
/**
* V3 API workspace internal IDs translation layer (retro-compatibility / future-proofing).
*
* Workspace is the default container for surveys. We are deprecating Environment and making
* Workspace that container. In the API, workspaceId refers to that container.
*
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
* (and derive environmentId or equivalent from it). Change only this file.
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
/**
* Internal IDs derived from a V3 workspace identifier.
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
*/
export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string;
/** Project ID used for projectTeam auth. */
projectId: string;
/** Organization ID used for org-level auth. */
organizationId: string;
};
/**
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
*
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
// Today: workspaceId is the environment id (survey container). Look it up.
const environment = await getEnvironment(workspaceId);
if (!environment) {
throw new ResourceNotFoundError("environment", workspaceId);
}
// Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return {
environmentId: workspaceId,
projectId: environment.projectId,
organizationId,
};
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vitest";
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
const wid = "clxx1234567890123456789012";
function params(qs: string): URLSearchParams {
return new URLSearchParams(qs);
}
describe("collectMultiValueQueryParam", () => {
test("merges repeated keys and comma-separated values", () => {
const sp = params("status=draft&status=inProgress&type=link,app");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
});
test("dedupes", () => {
const sp = params("status=draft&status=draft");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
});
});
describe("parseV3SurveysListQuery", () => {
test("rejects unsupported query parameters like filterCriteria", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
});
test("rejects unknown query parameters", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&foo=bar`));
expect(r.ok).toBe(false);
if (!r.ok)
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
test("rejects the legacy after query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&after=legacy-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects the legacy flat name query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&name=Foo`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("parses minimal query", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
`workspaceId=${wid}&filter[name][contains]=Foo&filter[status][in]=inProgress&filter[status][in]=draft&filter[type][in]=link&sortBy=updatedAt`
)
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.filterCriteria).toEqual({
name: "Foo",
status: ["inProgress", "draft"],
type: ["link"],
});
expect(r.sortBy).toBe("updatedAt");
}
});
test("invalid status", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[status][in]=notastatus`));
expect(r.ok).toBe(false);
});
test("rejects the createdBy filter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[createdBy][in]=you`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects an invalid cursor", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&cursor=not-a-real-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params).toEqual([
{
name: "cursor",
reason: "The cursor is invalid.",
},
]);
}
});
});
@@ -0,0 +1,159 @@
/**
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
* Keeps HTTP parsing separate from the route handler and shared survey list service.
*/
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
type TSurveyFilterCriteria,
ZSurveyFilters,
ZSurveyStatus,
ZSurveyType,
} from "@formbricks/types/surveys/types";
import {
type TSurveyListPageCursor,
type TSurveyListSort,
decodeSurveyListPageCursor,
normalizeSurveyListSort,
} from "@/modules/survey/list/lib/survey-page";
const V3_SURVEYS_DEFAULT_LIMIT = 20;
const V3_SURVEYS_MAX_LIMIT = 100;
const FILTER_NAME_CONTAINS_QUERY_PARAM = "filter[name][contains]" as const;
const FILTER_STATUS_IN_QUERY_PARAM = "filter[status][in]" as const;
const FILTER_TYPE_IN_QUERY_PARAM = "filter[type][in]" as const;
const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
"sortBy",
] as const;
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
type InvalidParam = { name: string; reason: string };
/** Collect repeated query keys and comma-separated values for operator-style filters. */
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
const acc: string[] = [];
for (const raw of searchParams.getAll(key)) {
for (const part of raw.split(",")) {
const t = part.trim();
if (t) acc.push(t);
}
}
return [...new Set(acc)];
}
const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
.optional()
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
[FILTER_STATUS_IN_QUERY_PARAM]: z.array(ZSurveyStatus).optional(),
[FILTER_TYPE_IN_QUERY_PARAM]: z.array(ZSurveyType).optional(),
sortBy: ZSurveyFilters.shape.sortBy.optional(),
});
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
export type TV3SurveysListQueryParseResult =
| {
ok: true;
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
| { ok: false; invalid_params: InvalidParam[] };
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
const unsupportedParams = [
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
];
return unsupportedParams.map((name) => ({
name,
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
}));
}
function buildFilterCriteria(q: TV3SurveysListQuery): TSurveyFilterCriteria | undefined {
const f: TSurveyFilterCriteria = {};
if (q[FILTER_NAME_CONTAINS_QUERY_PARAM]) f.name = q[FILTER_NAME_CONTAINS_QUERY_PARAM];
if (q[FILTER_STATUS_IN_QUERY_PARAM]?.length) f.status = q[FILTER_STATUS_IN_QUERY_PARAM];
if (q[FILTER_TYPE_IN_QUERY_PARAM]?.length) f.type = q[FILTER_TYPE_IN_QUERY_PARAM];
return Object.keys(f).length > 0 ? f : undefined;
}
export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3SurveysListQueryParseResult {
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
if (unsupportedQueryParams.length > 0) {
return {
ok: false,
invalid_params: unsupportedQueryParams,
};
}
const statusVals = collectMultiValueQueryParam(searchParams, FILTER_STATUS_IN_QUERY_PARAM);
const typeVals = collectMultiValueQueryParam(searchParams, FILTER_TYPE_IN_QUERY_PARAM);
const raw = {
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
sortBy: searchParams.get("sortBy")?.trim() || undefined,
};
const result = ZV3SurveysListQuery.safeParse(raw);
if (!result.success) {
return {
ok: false,
invalid_params: result.error.issues.map((issue) => ({
name: issue.path.join(".") || "query",
reason: issue.message,
})),
};
}
const q = result.data;
const sortBy = normalizeSurveyListSort(q.sortBy);
let cursor: TSurveyListPageCursor | null = null;
if (q.cursor) {
try {
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
} catch (error) {
return {
ok: false,
invalid_params: [
{
name: "cursor",
reason: error instanceof Error ? error.message : "The cursor is invalid.",
},
],
};
}
}
return {
ok: true,
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
}
+357
View File
@@ -0,0 +1,357 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
return {
...actual,
getSurveyListPage: vi.fn(),
};
});
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
getSurveyCount: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when the createdBy filter is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, nextCursor, and totalCount in meta", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: "cursor-123",
});
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 10,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
],
nextCursor: null,
});
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveyListPage", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* GET /api/v3/surveys list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successListResponse,
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
handler: async ({ req, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId });
try {
const searchParams = new URL(req.url).searchParams;
const parsed = parseV3SurveysListQuery(searchParams);
if (!parsed.ok) {
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
return problemBadRequest(requestId, "Invalid query parameters", {
invalid_params: parsed.invalid_params,
instance,
});
}
const authResult = await requireV3WorkspaceAccess(
authentication,
parsed.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,18 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
return {
...rest,
workspaceId: environmentId,
};
}
+49 -19
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
@@ -12,6 +13,18 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({
userEmail,
userName,
@@ -20,15 +33,14 @@ export const ChatwootWidget = ({
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => {
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
const $chatwoot = getChatwoot();
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
@@ -36,7 +48,19 @@ export const ChatwootWidget = ({
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
}, [userId, userEmail, userName, getChatwoot]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
@@ -65,23 +89,19 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
if (getChatwoot()) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
const $chatwoot = getChatwoot();
if ($chatwoot) {
$chatwoot.reset();
}
@@ -90,8 +110,18 @@ export const ChatwootWidget = ({
scriptElement?.remove();
userSetRef.current = false;
customerStatusSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
}, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
return null;
};
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});
@@ -0,0 +1,21 @@
import type { TUserLocale } from "@formbricks/types/user";
import { getTranslate } from "@/lingodotdev/server";
interface NoScriptWarningProps {
locale: TUserLocale;
}
export const NoScriptWarning = async ({ locale }: NoScriptWarningProps) => {
const t = await getTranslate(locale);
return (
<noscript>
<div className="fixed inset-0 z-[9999] flex h-dvh w-full items-center justify-center bg-slate-50">
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
<h1 className="mb-4 text-2xl font-bold text-slate-800">{t("common.javascript_required")}</h1>
<p className="text-slate-600">{t("common.javascript_required_description")}</p>
</div>
</div>
</noscript>
);
};
+2
View File
@@ -1,5 +1,6 @@
import { Metadata } from "next";
import React from "react";
import { NoScriptWarning } from "@/app/components/NoScriptWarning";
import { SentryProvider } from "@/app/sentry/SentryProvider";
import {
DEFAULT_LOCALE,
@@ -26,6 +27,7 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
<NoScriptWarning locale={locale} />
<SentryProvider
sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE}
@@ -421,6 +421,38 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
status: 401,
headers: { "Content-Type": "application/problem+json" },
});
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({
handler,
unauthenticatedResponse: () => custom401,
});
const res = await wrapped(req, undefined);
expect(res).toBe(custom401);
expect(handler).not.toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
+11 -1
View File
@@ -38,6 +38,11 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
action?: TAuditAction;
targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig;
/**
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
}
enum ApiV1RouteTypeEnum {
@@ -265,7 +270,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig } = params;
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -287,6 +292,11 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
const res = unauthenticatedResponse(req);
await processResponse(res, req, auditLog);
return res;
}
return responses.notAuthenticatedResponse();
}
@@ -90,6 +90,17 @@ describe("endpoint-validator", () => {
});
describe("isManagementApiRoute", () => {
test("should return Both for v3 surveys routes", () => {
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true,
@@ -22,6 +22,9 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
export const isManagementApiRoute = (
url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks"))
@@ -1,12 +1,15 @@
"use server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
const ZCreateOrganizationAction = z.object({
@@ -33,6 +36,16 @@ export const createOrganizationAction = authenticatedActionClient
accepted: true,
});
// Stripe setup must run AFTER membership is created so the owner email is available
if (IS_FORMBRICKS_CLOUD) {
ensureCloudStripeSetupForOrganization(newOrganization.id).catch((error) => {
logger.error(
{ error, organizationId: newOrganization.id },
"Stripe setup failed after organization creation"
);
});
}
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
+104 -30
View File
@@ -140,6 +140,7 @@ checksums:
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
common/connected: aa0ceca574641de34c74b9e590664230
common/contact: 9afa39bc47019ee6dec6c74b6273967c
common/contacts: d5b6c3f890b3904eaf5754081945c03d
common/continue: 3cfba90b4600131e82fc4260c568d044
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
@@ -147,6 +148,7 @@ checksums:
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/copy_to_environment: c482d26b8fd4962af6542bbf49e49a32
common/count_attributes: 48805e836a9b50f9635ad00fed953058
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
@@ -186,12 +188,12 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 228a8668be1812e031f438d166861729
common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
@@ -228,11 +230,13 @@ checksums:
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf
common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313
common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e
common/invite: 181884cea804cbde665f160811ee7ad0
common/invite_them: d4b7aadbd3c924b04ad4fce419709f10
common/javascript_required: d7988e5934af4d0df54fda369c0e4fb6
common/javascript_required_description: 4b65f456db79af4898888a3dd034fe2f
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
@@ -253,7 +257,9 @@ checksums:
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/meta: 842eac888f134f3525f8ea613d933687
common/metadata: 695d4f7da261ba76e3be4de495491028
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
@@ -267,6 +273,7 @@ checksums:
common/new: 126d036fae5fb6b629728ecb97e6195b
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
common/next: 89ddbcf710eba274963494f312bdc8a9
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
@@ -292,10 +299,9 @@ checksums:
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
common/organization: 3dc8489af7e74121f65ce6d9677bc94d
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
common/organization_not_found: 4cb8c07ec2c599b6f48750e06ffa182b
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
@@ -312,6 +318,7 @@ checksums:
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -353,6 +360,7 @@ checksums:
common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_language: d75cf5fbce8a4c7a9055e2210af74480
common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -372,7 +380,7 @@ checksums:
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/sort_by: 8adf3dbc5668379558957662f0c43563
common/start_free_trial: 4fab76a3fc5d5c94e3248cd279cfdd14
common/start_free_trial: e346e4ed7d138dcc873db187922369da
common/status: 4e1fcce15854d824919b4a582c697c90
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
@@ -385,7 +393,6 @@ checksums:
common/survey_id: 08303e98b3d4134947256e494b0c829e
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
common/survey_live: d1f370505c67509e7b2759952daba20d
common/survey_not_found: 0485ea98d13a414eeefc8f1118b9c293
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
common/surveys: 33f68ad4111b32a6361beb9d5c184533
@@ -400,13 +407,15 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
common/title: 344e64395eaff6822a57d18623853e1a
common/top_left: aa61bb29b56df3e046b6d68d89ee8986
common/top_right: 241f95c923846911aaf13af6109333e5
common/trial_days_remaining: 914ff3132895e410bf0f862433ccb49e
common/trial_expired: ca9f0532ac40ca427ca1ba4c86454e07
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
@@ -414,13 +423,13 @@ checksums:
common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
common/updated_at: 8fdb85248e591254973403755dcc3724
common/upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
common/url: ca97457614226960d41dd18c3c29c86b
common/user: 61073457a5c3901084b557d065f876be
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a
@@ -436,14 +445,13 @@ checksums:
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
common/workspace_id: bafef925e1b57b52a69844fdf47aac3c
common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
@@ -619,7 +627,6 @@ checksums:
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
@@ -799,9 +806,16 @@ checksums:
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
@@ -913,44 +927,80 @@ checksums:
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
environments/settings/billing/add_payment_method_to_upgrade_tooltip: 977005ad38bfe0800a78c21edcd16e4d
environments/settings/billing/billing_interval_toggle: 62c76eb73507108fc6aefdf1ab86cc38
environments/settings/billing/current_plan_badge: 27f172f76ac28e72cb062f80002b0ad5
environments/settings/billing/current_plan_cta: 53ac259fd40a361274861ee7c7498424
environments/settings/billing/custom_plan_description: 53faa38123cc74e5adc7e59630641d66
environments/settings/billing/custom_plan_title: f3b71be0d1cd4f81a177ada040119f30
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
environments/settings/billing/keep_current_plan: 57ac15ffa2c29ac364dd405669eeb7f6
environments/settings/billing/manage_billing_details: 40448f0b5ed4b3bb1d864ba6e1bb6a3b
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
environments/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
environments/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
environments/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
environments/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
environments/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
environments/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
environments/settings/billing/plan_change_applied: d1e04599487247dd0e21a7d99785dc7a
environments/settings/billing/plan_change_scheduled: 16455d4aa9a152b156ee434d8c7e34d4
environments/settings/billing/plan_custom: b7b89901f46267f532600a23cfc54ae2
environments/settings/billing/plan_feature_everything_in_hobby: 5417a498136fa29988c8215291e3fd8b
environments/settings/billing/plan_feature_everything_in_pro: 3f5129ff1f01eed4f051a8790ed62997
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
environments/settings/billing/plan_hobby_description: 1fa1cf69b42ec82727aebc5ef1ec24a2
environments/settings/billing/plan_hobby_feature_responses: d1e6c1d83f5e57cbae2a09e6a818a25d
environments/settings/billing/plan_hobby_feature_workspaces: 02a34669419ed7f30f728980f54d42ef
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
environments/settings/billing/plan_pro_description: 748c848ea0d8cf81a66704762edcd6f4
environments/settings/billing/plan_pro_feature_responses: e16ffe385051a16dba76538c13d97a5f
environments/settings/billing/plan_pro_feature_workspaces: 819874022b491209ca7f0f1ab1e3daea
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
environments/settings/billing/plan_scale_description: ef5c66e0b52686f56319e31388bd8409
environments/settings/billing/plan_scale_feature_responses: 0b74bf8d089c738ebb7f0867bdd7d7f1
environments/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef
environments/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599
environments/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d
environments/settings/billing/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
environments/settings/billing/retry_setup: bef560e42fa8798271fea150476791e0
environments/settings/billing/scale_banner_description: 79a9734c77ab0336d5d2fadb5f2151be
environments/settings/billing/scale_banner_title: a2a78f57ebcbf444ad881ece234b8f45
environments/settings/billing/scale_feature_api: 67231215e5452944b86edc2bc47d2a16
environments/settings/billing/scale_feature_quota: 31fb6b5e846dd44de140a69fd3e4c067
environments/settings/billing/scale_feature_spam: 8a8229b6ac3f3e0427fd347cb667ce11
environments/settings/billing/scale_feature_teams: f6e8428f6cdb227176a5fa8c5c95c976
environments/settings/billing/select_plan_header_subtitle: 8de6b4e3ce5726829829bd46582f343a
environments/settings/billing/select_plan_header_title: d851e9fa093ddb248924cf99e1d79b4e
environments/settings/billing/select_plan_header_title: b15a9d86b819a7fae8e956a50572184c
environments/settings/billing/status_trialing: 4fd32760caf3bd7169935b0a6d2b5b67
environments/settings/billing/stay_on_hobby_plan: 966ab0c752a79f00ef10d6a5ed1d8cad
environments/settings/billing/stripe_setup_incomplete: fa6d6e295dd14b73c17ac8678205109b
environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284
environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343
environments/settings/billing/subscription_description: b03618508e576666198d4adf3c2cb9a9
environments/settings/billing/switch_at_period_end: 9c91b2287886e077a0571efab8908623
environments/settings/billing/switch_plan_now: dad56622a1916fe5d1a2bda5b0393194
environments/settings/billing/this_includes: 127e0fe104f47886b54106a057a6b26f
environments/settings/billing/trial_alert_description: aba3076cc6814cc6128d425d3d1957e8
environments/settings/billing/trial_already_used: 5433347ff7647fe0aba0fe91a44560ba
environments/settings/billing/trial_feature_api_access: d7aabb2de18beb5bd30c274cd768a2a9
environments/settings/billing/trial_feature_collaboration: a43509fffe319e14d69a981ef2791517
environments/settings/billing/trial_feature_email_followups: add368efdd84c5aef8886f369d54cbed
environments/settings/billing/trial_feature_quotas: 3a67818b3901bdaa72abc62db72ab170
environments/settings/billing/trial_feature_webhooks: 8d7f034e006b2fe0eb8fa9b8f1abef51
environments/settings/billing/trial_feature_whitelabel: 624a7aeca6a0fa65935c63fd7a8e9638
environments/settings/billing/trial_feature_api_access: 8c6d03728c3d9470616eb5cee5f9f65d
environments/settings/billing/trial_feature_attribute_segmentation: 90087da973ae48e32ec6d863516fc8c9
environments/settings/billing/trial_feature_contact_segment_management: 27f17a039ebed6413811ab3a461db2f4
environments/settings/billing/trial_feature_email_followups: 0cc02dc14aa28ce94ca6153c306924e5
environments/settings/billing/trial_feature_hide_branding: b8dbcb24e50e0eb4aeb0c97891cac61d
environments/settings/billing/trial_feature_mobile_sdks: 0963480a27df49657c1b7507adec9a06
environments/settings/billing/trial_feature_respondent_identification: a82e24ab4c27c5e485326678d9b7bd79
environments/settings/billing/trial_feature_unlimited_seats: a3257d5b6a23bfbc4b7fd1108087a823
environments/settings/billing/trial_feature_webhooks: 5ead39fba97fbd37835a476ee67fdd94
environments/settings/billing/trial_no_credit_card: 01c70aa6e1001815a9a11951394923ca
environments/settings/billing/trial_title: 23d0d2cbe306ae0f784b8289bf66a2c7
environments/settings/billing/trial_payment_method_added_description: 872a5c557f56bafc9b7ec4895f9c33e8
environments/settings/billing/trial_title: f2c3791c1fb2970617ec0f2d243a931b
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
@@ -972,6 +1022,25 @@ checksums:
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
@@ -1281,7 +1350,6 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
@@ -1319,6 +1387,7 @@ checksums:
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
@@ -1542,6 +1611,8 @@ checksums:
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
@@ -1570,6 +1641,7 @@ checksums:
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
@@ -1585,10 +1657,12 @@ checksums:
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
@@ -2857,7 +2931,7 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
@@ -3110,7 +3184,7 @@ checksums:
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
+13 -1
View File
@@ -1,7 +1,19 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
export const onRequestError = Sentry.captureRequestError;
export const onRequestError: Instrumentation.onRequestError = (...args) => {
const [error] = args;
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
// These are handled gracefully in the UI and don't need server-side Sentry reporting
if (error instanceof Error && isExpectedError(error)) {
return;
}
Sentry.captureRequestError(...args);
};
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
-2
View File
@@ -85,7 +85,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_PRICING_TABLE_ID: z.string().optional(),
PUBLIC_URL: z
.url()
.refine(
@@ -203,7 +202,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID,
PUBLIC_URL: process.env.PUBLIC_URL,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
+2 -2
View File
@@ -1,7 +1,7 @@
"use server";
import "server-only";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "../../organization/service";
import { getMembershipByUserIdOrganizationId } from "../service";
@@ -9,7 +9,7 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", null);
}
const currentUserMembership = await getMembershipRole(userId, organization.id);
+1 -6
View File
@@ -308,12 +308,7 @@ export const deleteOrganization = async (organizationId: string) => {
const stripeCustomerId = deletedOrganization.billing?.stripeCustomerId;
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
cleanupStripeCustomer(stripeCustomerId).catch((error) => {
logger.error(
{ error, organizationId, stripeCustomerId },
"Failed to clean up Stripe customer after organization deletion"
);
});
await cleanupStripeCustomer(stripeCustomerId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
+1 -1
View File
@@ -378,7 +378,7 @@ export const getResponseDownloadFile = async (
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
if (!organizationId) {
throw new Error("Organization ID not found");
throw new ResourceNotFoundError("Organization", null);
}
const organizationBilling = await getOrganizationBilling(organizationId);
+28 -55
View File
@@ -1,62 +1,13 @@
import { describe, expect, test } from "vitest";
import {
convertDateString,
convertDateTimeString,
convertDateTimeStringShort,
convertDatesInObject,
convertTimeString,
formatDate,
getTodaysDateFormatted,
getTodaysDateTimeFormatted,
timeSince,
timeSinceDate,
} from "./time";
describe("Time Utilities", () => {
describe("convertDateString", () => {
test("should format date string correctly", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
test("should return null for null input", () => {
expect(convertDateString(null as any)).toBe(null);
});
test("should handle invalid date strings", () => {
expect(convertDateString("not-a-date")).toBe("Invalid Date");
});
});
describe("convertDateTimeString", () => {
test("should format date and time string correctly", () => {
expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeString("")).toBe("");
});
});
describe("convertDateTimeStringShort", () => {
test("should format date and time string in short format", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe("");
});
});
describe("convertTimeString", () => {
test("should format time string correctly", () => {
expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM");
});
});
describe("timeSince", () => {
test("should format time since in English", () => {
const now = new Date();
@@ -75,6 +26,18 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
});
test("should format time since in Brazilian Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
});
test("should format time since in European Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
});
});
describe("timeSinceDate", () => {
@@ -83,6 +46,12 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
});
test("should format time since from Date object in the provided locale", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
});
});
describe("formatDate", () => {
@@ -90,13 +59,17 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024");
});
});
describe("getTodaysDateFormatted", () => {
test("should format today's date with specified separator", () => {
const today = new Date();
const expected = today.toISOString().split("T")[0].split("-").join(".");
expect(getTodaysDateFormatted(".")).toBe(expected);
test("should format date with the provided locale", () => {
const date = new Date(2024, 2, 20);
expect(formatDate(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
});
+27 -120
View File
@@ -1,120 +1,33 @@
import { formatDistance, intlFormat } from "date-fns";
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
export const convertDateString = (dateString: string | null) => {
if (dateString === null) return null;
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return intlFormat(
date,
{
year: "numeric",
month: "short",
day: "numeric",
},
{
locale: "en",
}
);
const DEFAULT_LOCALE: TUserLocale = "en-US";
const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"de-DE": de,
"en-US": enUS,
"es-ES": es,
"fr-FR": fr,
"hu-HU": hu,
"ja-JP": ja,
"nl-NL": nl,
"pt-BR": ptBR,
"pt-PT": pt,
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
export const convertDateTimeString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale);
export const convertDateTimeStringShort = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
/** Maps locale strings to date-fns locales and falls back to English for unsupported inputs. */
const getLocaleForTimeSince = (locale: string): Locale =>
isUserLocale(locale) ? TIME_SINCE_LOCALES[locale] : enUS;
export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
{
hour: "numeric",
minute: "2-digit",
second: "2-digit",
},
{
locale: "en",
}
);
};
const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) {
case "de-DE":
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 "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
}
};
export const timeSince = (dateString: string, locale: TUserLocale) => {
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
@@ -122,27 +35,21 @@ export const timeSince = (dateString: string, locale: TUserLocale) => {
});
};
export const timeSinceDate = (date: Date) => {
export const timeSinceDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
return formatDistance(date, new Date(), {
addSuffix: true,
locale: getLocaleForTimeSince(locale),
});
};
export const formatDate = (date: Date) => {
return intlFormat(date, {
export const formatDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
return formatDateForDisplay(date, locale, {
year: "numeric",
month: "long",
day: "numeric",
});
};
export const getTodaysDateFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
return formattedDate;
};
export const getTodaysDateTimeFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
describe("date display utils", () => {
test("parses ISO stored dates", () => {
const parsedDate = parseStoredDateValue("2025-05-06");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses legacy stored dates using the element format", () => {
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses day-first stored dates when no format is provided", () => {
const parsedDate = parseStoredDateValue("06-05-2025");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("formats stored dates using the selected locale", () => {
const date = new Date(2025, 4, 6);
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("returns null for invalid stored dates", () => {
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
});
test("builds a date format map for survey date elements", () => {
const elements = [
{
id: "dateQuestion",
type: "date",
format: "d-M-y",
},
{
id: "textQuestion",
type: "openText",
},
] as TSurveyElement[];
expect(getSurveyDateFormatMap(elements)).toEqual({
dateQuestion: "d-M-y",
});
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
const ISO_STORED_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const buildDate = (year: number, month: number, day: number): Date | null => {
if ([year, month, day].some((value) => Number.isNaN(value))) {
return null;
}
const parsedDate = new Date(year, month - 1, day);
if (
parsedDate.getFullYear() !== year ||
parsedDate.getMonth() !== month - 1 ||
parsedDate.getDate() !== day
) {
return null;
}
return parsedDate;
};
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
const parts = value.split("-");
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
return null;
}
const [first, second, third] = parts.map(Number);
switch (format) {
case "M-d-y":
return buildDate(third, first, second);
case "d-M-y":
return buildDate(third, second, first);
case "y-M-d":
return buildDate(first, second, third);
}
};
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
const isoMatch = ISO_STORED_DATE_PATTERN.exec(value);
if (isoMatch) {
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
}
if (format) {
return parseLegacyStoredDateValue(value, format);
}
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
return parseLegacyStoredDateValue(value, "d-M-y");
}
return null;
};
export const formatStoredDateForDisplay = (
value: string,
format: TSurveyDateElement["format"] | undefined,
locale: string = "en-US"
): string | null => {
const parsedDate = parseStoredDateValue(value, format);
if (!parsedDate) {
return null;
}
return formatDateWithOrdinal(parsedDate, locale);
};
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
if (element.type === "date") {
dateFormats[element.id] = element.format;
}
return dateFormats;
}, {});
};
+43 -4
View File
@@ -1,5 +1,12 @@
import { describe, expect, test } from "vitest";
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
import {
diffInDays,
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
getFormattedDateTimeString,
isValidDateString,
} from "./datetime";
describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => {
@@ -8,13 +15,45 @@ describe("datetime utils", () => {
expect(diffInDays(date1, date2)).toBe(5);
});
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
test("formatDateWithOrdinal formats a date using the provided locale", () => {
// Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
// Test the function
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
expect(formatDateWithOrdinal(date)).toBe(
new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("formatDateForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateForDisplay(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("formatDateTimeForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
});
test("isValidDateString validates correct date strings", () => {
+44 -13
View File
@@ -1,7 +1,17 @@
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
const DEFAULT_LOCALE = "en-US";
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
};
// Helper function to calculate difference in days between two dates
@@ -10,23 +20,44 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
const day = date.getDate();
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
const year = date.getFullYear();
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
export const formatDateForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateTimeForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
return formatDateForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
};
export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
if (!regex.test(value)) {
return false;
}
const date = new Date(value);
return date;
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value)
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1")
: value;
const date = new Date(normalizedValue);
return !Number.isNaN(date.getTime());
};
export const getFormattedDateTimeString = (date: Date): string => {
+24 -10
View File
@@ -32,16 +32,17 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/utils/datetime", () => ({
isValidDateString: vi.fn((value) => {
try {
return !isNaN(new Date(value as string).getTime());
} catch {
return false;
vi.mock("@/lib/utils/date-display", () => ({
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => {
if (value === "2023-01-01") {
return `formatted-${locale}-${format ?? "iso"}`;
}
}),
formatDateWithOrdinal: vi.fn(() => {
return "January 1st, 2023";
if (value === "01-02-2023" && format === "M-d-y") {
return `legacy-${locale}-${format}`;
}
return null;
}),
}));
@@ -477,7 +478,20 @@ describe("recall utility functions", () => {
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on January 1st, 2023");
expect(result).toBe("You joined on formatted-en-US-iso");
});
test("formats legacy date values using the provided locale and stored format", () => {
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
const responseData: TResponseData = {
joinDate: "01-02-2023",
};
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
joinDate: "M-d-y",
});
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
});
test("formats array values as comma-separated list", () => {
+11 -7
View File
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
export interface fallbacks {
[id: string]: string;
@@ -224,7 +224,9 @@ export const parseRecallInfo = (
text: string,
responseData?: TResponseData,
variables?: TResponseVariables,
withSlash: boolean = false
withSlash: boolean = false,
locale: string = "en-US",
dateFormats?: TSurveyDateFormatMap
) => {
let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : [];
@@ -254,12 +256,14 @@ export const parseRecallInfo = (
value = responseData[recallItemId];
// Apply formatting for special value types
if (value) {
if (isValidDateString(value as string)) {
value = formatDateWithOrdinal(new Date(value as string));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
if (typeof value === "string") {
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
if (formattedDate) {
value = formattedDate;
}
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
}
+104 -30
View File
@@ -167,6 +167,7 @@
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
"connected": "Verbunden",
"contact": "Kontakt",
"contacts": "Kontakte",
"continue": "Weitermachen",
"copied": "Kopiert",
@@ -174,6 +175,7 @@
"copy": "Kopieren",
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"copy_to_environment": "In {{environment}} kopieren",
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"elements": "Elemente",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz",
"environment": "Umgebung",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Inaktive Umfragen",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date": "Ungültiges Datum",
"invalid_date_with_value": "Ungültiges Datum: {value}",
"invalid_file_name": "Ungültiger Dateiname, bitte benennen Sie Ihre Datei um und versuchen Sie es erneut",
"invalid_file_type": "Ungültiger Dateityp",
"invite": "Einladen",
"invite_them": "Lade sie ein",
"javascript_required": "JavaScript erforderlich",
"javascript_required_description": "Formbricks benötigt JavaScript, um ordnungsgemäß zu funktionieren. Bitte aktiviere JavaScript in deinen Browsereinstellungen, um fortzufahren.",
"key": "Schlüssel",
"label": "Bezeichnung",
"language": "Sprache",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership": "Mitgliedschaft",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"meta": "Meta",
"metadata": "Metadaten",
"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!",
@@ -294,6 +300,7 @@
"new": "Neu",
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
"next": "Weiter",
"no_actions_found": "Keine Aktionen gefunden",
"no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen",
@@ -319,10 +326,9 @@
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation nicht gefunden",
"organization_settings": "Organisationseinstellungen",
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"other_filters": "Weitere Filter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
@@ -380,6 +387,7 @@
"select": "Auswählen",
"select_all": "Alles auswählen",
"select_filter": "Filter auswählen",
"select_language": "Sprache auswählen",
"select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
@@ -399,7 +407,7 @@
"something_went_wrong": "Etwas ist schiefgelaufen",
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.",
"sort_by": "Sortieren nach",
"start_free_trial": "Kostenlos starten",
"start_free_trial": "Kostenlose Testversion starten",
"status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
@@ -412,7 +420,6 @@
"survey_id": "Umfrage-ID",
"survey_languages": "Umfragesprachen",
"survey_live": "Umfrage live",
"survey_not_found": "Umfrage nicht gefunden",
"survey_paused": "Umfrage pausiert.",
"survey_type": "Umfragetyp",
"surveys": "Umfragen",
@@ -427,13 +434,15 @@
"team_name": "Teamname",
"team_role": "Team-Rolle",
"teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
"time_to_finish": "Zeit zum Fertigstellen",
"title": "Titel",
"top_left": "Oben links",
"top_right": "Oben rechts",
"trial_days_remaining": "Noch {count} Tage in deiner Testphase",
"trial_expired": "Deine Testphase ist abgelaufen",
"trial_one_day_remaining": "Noch 1 Tag in deiner Testphase",
"try_again": "Versuch's nochmal",
"type": "Typ",
"unknown_survey": "Unbekannte Umfrage",
@@ -441,13 +450,13 @@
"update": "Aktualisierung",
"updated": "Aktualisiert",
"updated_at": "Aktualisiert am",
"upgrade_plan": "Plan upgraden",
"upload": "Hochladen",
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
"url": "URL",
"user": "Benutzer",
"user_id": "Benutzer-ID",
"user_not_found": "Benutzer nicht gefunden",
"variable": "Variable",
"variable_ids": "Variablen-IDs",
"variables": "Variablen",
@@ -463,14 +472,13 @@
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace": "Arbeitsbereich",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"workspace_id": "Projekt-ID",
"workspace_name": "Projektname",
"workspace_name_placeholder": "z. B. Formbricks",
"workspace_not_found": "Projekt nicht gefunden",
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID 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",
@@ -846,9 +853,16 @@
"created_by_third_party": "Erstellt von einer dritten Partei",
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"endpoint_bad_gateway_error": "Ungültiges Gateway (502): Proxy-/Gateway-Fehler, Dienst nicht erreichbar",
"endpoint_gateway_timeout_error": "Gateway-Zeitüberschreitung (504): Gateway-Zeitüberschreitung, Dienst nicht erreichbar",
"endpoint_internal_server_error": "Interner Serverfehler (500): Der Dienst ist auf einen unerwarteten Fehler gestoßen",
"endpoint_method_not_allowed_error": "Methode nicht erlaubt (405): Der Endpoint existiert, akzeptiert aber keine POST-Anfragen",
"endpoint_not_found_error": "Nicht gefunden (404): Der Endpoint existiert nicht",
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"endpoint_service_unavailable_error": "Dienst nicht verfügbar (503): Dienst ist vorübergehend nicht verfügbar",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"no_triggers": "Keine Trigger",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt",
@@ -968,44 +982,80 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"cancelling": "Wird storniert",
"add_payment_method": "Zahlungsmethode hinzufügen",
"add_payment_method_to_upgrade_tooltip": "Bitte füge oben eine Zahlungsmethode hinzu, um auf einen kostenpflichtigen Plan zu upgraden",
"billing_interval_toggle": "Abrechnungsintervall",
"current_plan_badge": "Aktuell",
"current_plan_cta": "Aktueller Tarif",
"custom_plan_description": "Deine Organisation nutzt ein individuelles Abrechnungsmodell. Du kannst trotzdem zu einem der Standardtarife unten wechseln.",
"custom_plan_title": "Individueller Tarif",
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
"manage_subscription": "Abonnement verwalten",
"keep_current_plan": "Aktuellen Tarif beibehalten",
"manage_billing_details": "Kartendaten & Rechnungen verwalten",
"monthly": "Monatlich",
"most_popular": "Am beliebtesten",
"pending_change_removed": "Geplante Tarifänderung entfernt.",
"pending_plan_badge": "Geplant",
"pending_plan_change_description": "Dein Tarif wechselt am {{date}} zu {{plan}}.",
"pending_plan_change_title": "Geplante Tarifänderung",
"pending_plan_cta": "Geplant",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_change_applied": "Tarif erfolgreich aktualisiert.",
"plan_change_scheduled": "Tarifänderung erfolgreich geplant.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Alles aus Hobby",
"plan_feature_everything_in_pro": "Alles aus Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Für Einzelpersonen und kleine Teams, die mit Formbricks Cloud starten.",
"plan_hobby_feature_responses": "250 Antworten / Monat",
"plan_hobby_feature_workspaces": "1 Arbeitsbereich",
"plan_pro": "Pro",
"plan_pro_description": "Für wachsende Teams, die höhere Limits, Automatisierungen und dynamische Überschreitungen benötigen.",
"plan_pro_feature_responses": "2.000 Antworten / Monat (dynamische Überschreitung)",
"plan_pro_feature_workspaces": "3 Arbeitsbereiche",
"plan_scale": "Scale",
"plan_scale_description": "Für größere Teams, die mehr Kapazität, stärkere Governance und höheres Antwortvolumen benötigen.",
"plan_scale_feature_responses": "5.000 Antworten / Monat (dynamische Mehrnutzung)",
"plan_scale_feature_workspaces": "5 Arbeitsbereiche",
"plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle dann direkt in Formbricks den Plan.",
"plan_selection_title": "Wähle deinen Plan",
"plan_unknown": "Unbekannt",
"remove_branding": "Branding entfernen",
"retry_setup": "Erneut einrichten",
"scale_banner_description": "Schalte höhere Limits, Teamzusammenarbeit und erweiterte Sicherheitsfunktionen mit dem Scale-Tarif frei.",
"scale_banner_title": "Bereit für den nächsten Schritt?",
"scale_feature_api": "Vollständiger API-Zugang",
"scale_feature_quota": "Quotenverwaltung",
"scale_feature_spam": "Spamschutz",
"scale_feature_teams": "Teams & Zugriffsrollen",
"select_plan_header_subtitle": "Keine Kreditkarte erforderlich, keine versteckten Bedingungen.",
"select_plan_header_title": "Versende noch heute professionelle Umfragen ohne Branding!",
"select_plan_header_title": "Nahtlos integrierte Umfragen, 100% deine Marke.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Ich möchte beim Hobby-Plan bleiben",
"stripe_setup_incomplete": "Abrechnungseinrichtung unvollständig",
"stripe_setup_incomplete_description": "Die Abrechnungseinrichtung war nicht erfolgreich. Bitte versuche es erneut, um Dein Abo zu aktivieren.",
"subscription": "Abonnement",
"subscription_description": "Verwalte Dein Abonnement und behalte Deine Nutzung im Blick",
"switch_at_period_end": "Am Ende der Periode wechseln",
"switch_plan_now": "Plan jetzt wechseln",
"this_includes": "Das beinhaltet",
"trial_alert_description": "Füge eine Zahlungsmethode hinzu, um weiterhin Zugriff auf alle Funktionen zu behalten.",
"trial_already_used": "Für diese E-Mail-Adresse wurde bereits eine kostenlose Testversion genutzt. Bitte upgraden Sie stattdessen auf einen kostenpflichtigen Plan.",
"trial_feature_api_access": "Vollen API-Zugriff erhalten",
"trial_feature_collaboration": "Alle Team- und Kollaborationsfunktionen",
"trial_feature_email_followups": "E-Mail-Nachfassaktionen einrichten",
"trial_feature_quotas": "Kontingente verwalten",
"trial_feature_webhooks": "Benutzerdefinierte Webhooks einrichten",
"trial_feature_whitelabel": "Vollständig white-labeled Umfragen",
"trial_feature_api_access": "API-Zugriff",
"trial_feature_attribute_segmentation": "Attributbasierte Segmentierung",
"trial_feature_contact_segment_management": "Kontakt- & Segmentverwaltung",
"trial_feature_email_followups": "E-Mail-Nachfassaktionen",
"trial_feature_hide_branding": "Formbricks-Branding ausblenden",
"trial_feature_mobile_sdks": "iOS & Android SDKs",
"trial_feature_respondent_identification": "Befragten-Identifikation",
"trial_feature_unlimited_seats": "Unbegrenzte Benutzerplätze",
"trial_feature_webhooks": "Individuelle Webhooks",
"trial_no_credit_card": "14 Tage Testversion, keine Kreditkarte erforderlich",
"trial_title": "Pro-Funktionen kostenlos testen!",
"trial_payment_method_added_description": "Alles bereit! Dein Pro-Tarif läuft nach Ende der Testphase automatisch weiter.",
"trial_title": "Hol dir Formbricks Pro kostenlos!",
"unlimited_responses": "Unbegrenzte Antworten",
"unlimited_workspaces": "Unbegrenzte Projekte",
"upgrade": "Upgrade",
"upgrade_now": "Jetzt upgraden",
"usage_cycle": "Usage cycle",
"used": "verwendet",
"yearly": "Jährlich",
"yearly_checkout_unavailable": "Die jährliche Abrechnung ist noch nicht verfügbar. Füge zuerst eine Zahlungsmethode bei einem monatlichen Plan hinzu oder kontaktiere den Support.",
"your_plan": "Dein Tarif"
},
"domain": {
@@ -1031,6 +1081,25 @@
"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_feature_access_control": "Zugriffskontrolle (RBAC)",
"license_feature_audit_logs": "Audit-Protokolle",
"license_feature_contacts": "Kontakte & Segmente",
"license_feature_projects": "Arbeitsbereiche",
"license_feature_quotas": "Kontingente",
"license_feature_remove_branding": "Branding entfernen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam-Schutz",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
"license_feature_whitelabel": "White-Label-E-Mails",
"license_features_table_access": "Zugriff",
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
"license_features_table_disabled": "Deaktiviert",
"license_features_table_enabled": "Aktiviert",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Lizenzierte Funktionen",
"license_features_table_unlimited": "Unbegrenzt",
"license_features_table_value": "Wert",
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
"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",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"everyone": "Jeder",
"expand_preview": "Vorschau erweitern",
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"reverse_order_occasionally": "Reihenfolge gelegentlich umkehren",
"reverse_order_occasionally_except_last": "Reihenfolge gelegentlich umkehren, außer letzter",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"shrink_preview": "Vorschau verkleinern",
"simple": "Einfach",
"six_points": "6 Punkte",
"smiley": "Smiley",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -",
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage",
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
@@ -3012,7 +3086,7 @@
"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_question_open_text_headline": "Möchtest Du noch etwas teilen?",
"preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?",
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
"preview_survey_welcome_card_headline": "Willkommen!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
"follow_up_label": "Möchten Sie noch etwas hinzufügen?",
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
"generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?",
+104 -30
View File
@@ -167,6 +167,7 @@
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
"connected": "Connected",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continue",
"copied": "Copied",
@@ -174,6 +175,7 @@
"copy": "Copy",
"copy_code": "Copy code",
"copy_link": "Copy Link",
"copy_to_environment": "Copy to {{environment}}",
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
"elements": "Elements",
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment": "Environment",
"environment_not_found": "Environment not found",
"environment_notice": "You are currently in the {environment} environment.",
"error": "Error",
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Inactive surveys",
"integration": "integration",
"integrations": "Integrations",
"invalid_date": "Invalid date",
"invalid_date_with_value": "Invalid date: {value}",
"invalid_file_name": "Invalid file name, please rename your file and try again",
"invalid_file_type": "Invalid file type",
"invite": "Invite",
"invite_them": "Invite them",
"javascript_required": "JavaScript Required",
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
"key": "Key",
"label": "Label",
"language": "Language",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership": "Membership",
"membership_not_found": "Membership not found",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Do not worry your surveys look great on every device and screen size!",
@@ -294,6 +300,7 @@
"new": "New",
"new_version_available": "Formbricks {version} is here. Upgrade now!",
"next": "Next",
"no_actions_found": "No actions found",
"no_background_image_found": "No background image found.",
"no_code": "No code",
"no_files_uploaded": "No files were uploaded",
@@ -319,10 +326,9 @@
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
"organization_not_found": "Organization not found",
"organization_settings": "Organization settings",
"organization_teams_not_found": "Organization teams not found",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Please select at least one survey",
"please_select_at_least_one_trigger": "Please select at least one trigger",
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
@@ -380,6 +387,7 @@
"select": "Select",
"select_all": "Select all",
"select_filter": "Select filter",
"select_language": "Select Language",
"select_survey": "Select Survey",
"select_teams": "Select teams",
"selected": "Selected",
@@ -399,7 +407,7 @@
"something_went_wrong": "Something went wrong",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"sort_by": "Sort by",
"start_free_trial": "Start Free Trial",
"start_free_trial": "Start free trial",
"status": "Status",
"step_by_step_manual": "Step by step manual",
"storage_not_configured": "File storage not set up, uploads will likely fail",
@@ -412,7 +420,6 @@
"survey_id": "Survey ID",
"survey_languages": "Survey Languages",
"survey_live": "Survey live",
"survey_not_found": "Survey not found",
"survey_paused": "Survey paused.",
"survey_type": "Survey Type",
"surveys": "Surveys",
@@ -427,13 +434,15 @@
"team_name": "Team name",
"team_role": "Team role",
"teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
"time_to_finish": "Time to finish",
"title": "Title",
"top_left": "Top Left",
"top_right": "Top Right",
"trial_days_remaining": "{count} days left in your trial",
"trial_expired": "Your trial has expired",
"trial_one_day_remaining": "1 day left in your trial",
"try_again": "Try again",
"type": "Type",
"unknown_survey": "Unknown survey",
@@ -441,13 +450,13 @@
"update": "Update",
"updated": "Updated",
"updated_at": "Updated at",
"upgrade_plan": "Upgrade plan",
"upload": "Upload",
"upload_failed": "Upload failed. Please try again.",
"upload_input_description": "Click or drag to upload files.",
"url": "URL",
"user": "User",
"user_id": "User ID",
"user_not_found": "User not found",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
@@ -463,14 +472,13 @@
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace": "Workspace",
"workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
"workspace_id": "Workspace ID",
"workspace_name": "Workspace Name",
"workspace_name_placeholder": "e.g. Formbricks",
"workspace_not_found": "Workspace not found",
"workspace_permission_not_found": "Workspace permission not found",
"workspaces": "Workspaces",
"years": "years",
"you": "You",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully",
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
@@ -846,9 +853,16 @@
"created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_bad_gateway_error": "Bad Gateway (502): Proxy/gateway error, service not reachable",
"endpoint_gateway_timeout_error": "Gateway Timeout (504): Gateway timeout, service not reachable",
"endpoint_internal_server_error": "Internal Server Error (500): The service encountered an unexpected error",
"endpoint_method_not_allowed_error": "Method Not Allowed (405): The endpoint exists, but doesn't accept POST requests",
"endpoint_not_found_error": "Not Found (404): The endpoint doesn't exist",
"endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!",
"endpoint_service_unavailable_error": "Service Unavailable (503): Service is temporarily down",
"learn_to_verify": "Learn how to verify webhook signatures",
"no_triggers": "No Triggers",
"please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL",
"response_created": "Response Created",
@@ -968,44 +982,80 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"cancelling": "Cancelling",
"add_payment_method": "Add payment method",
"add_payment_method_to_upgrade_tooltip": "Please add a payment method above to upgrade to a paid plan",
"billing_interval_toggle": "Billing interval",
"current_plan_badge": "Current",
"current_plan_cta": "Current plan",
"custom_plan_description": "Your organization is on a custom billing setup. You can still switch to one of the standard plans below.",
"custom_plan_title": "Custom plan",
"failed_to_start_trial": "Failed to start trial. Please try again.",
"manage_subscription": "Manage subscription",
"keep_current_plan": "Keep current plan",
"manage_billing_details": "Manage card details & invoices",
"monthly": "Monthly",
"most_popular": "Most popular",
"pending_change_removed": "Scheduled plan change removed.",
"pending_plan_badge": "Scheduled",
"pending_plan_change_description": "Your plan will switch to {{plan}} on {{date}}.",
"pending_plan_change_title": "Scheduled plan change",
"pending_plan_cta": "Scheduled",
"per_month": "per month",
"per_year": "per year",
"plan_change_applied": "Plan updated successfully.",
"plan_change_scheduled": "Plan change scheduled successfully.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Everything in Hobby",
"plan_feature_everything_in_pro": "Everything in Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "For individuals and small teams getting started with Formbricks Cloud.",
"plan_hobby_feature_responses": "250 responses / month",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "For growing teams that need higher limits, automations, and dynamic overages.",
"plan_pro_feature_responses": "2,000 responses / month (dynamic overage)",
"plan_pro_feature_workspaces": "3 workspaces",
"plan_scale": "Scale",
"plan_scale_description": "For larger teams that need more capacity, stronger governance, and higher response volume.",
"plan_scale_feature_responses": "5,000 responses / month (dynamic overage)",
"plan_scale_feature_workspaces": "5 workspaces",
"plan_selection_description": "Compare Hobby, Pro, and Scale, then switch plans directly from Formbricks.",
"plan_selection_title": "Choose your plan",
"plan_unknown": "Unknown",
"remove_branding": "Remove Branding",
"retry_setup": "Retry setup",
"scale_banner_description": "Unlock higher limits, team collaboration, and advanced security features with the Scale plan.",
"scale_banner_title": "Ready to scale up?",
"scale_feature_api": "Full API Access",
"scale_feature_quota": "Quota Management",
"scale_feature_spam": "Spam Protection",
"scale_feature_teams": "Teams & Access Roles",
"select_plan_header_subtitle": "No credit card required, no strings attached.",
"select_plan_header_title": "Ship professional, unbranded surveys today!",
"select_plan_header_title": "Seamlessly integrated surveys, 100% your brand.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "I want to stay on the Hobby plan",
"stripe_setup_incomplete": "Billing setup incomplete",
"stripe_setup_incomplete_description": "Billing setup did not complete successfully. Please retry to activate your subscription.",
"subscription": "Subscription",
"subscription_description": "Manage your subscription plan and monitor your usage",
"switch_at_period_end": "Switch at period end",
"switch_plan_now": "Switch plan now",
"this_includes": "This includes",
"trial_alert_description": "Add a payment method to keep access to all features.",
"trial_already_used": "A free trial has already been used for this email address. Please upgrade to a paid plan instead.",
"trial_feature_api_access": "Get full API access",
"trial_feature_collaboration": "All team & collaboration features",
"trial_feature_email_followups": "Setup email follow-ups",
"trial_feature_quotas": "Manage quotas",
"trial_feature_webhooks": "Setup custom webhooks",
"trial_feature_whitelabel": "Fully white-labeled surveys",
"trial_feature_api_access": "API Access",
"trial_feature_attribute_segmentation": "Attribute-based Segmentation",
"trial_feature_contact_segment_management": "Contact & Segment Management",
"trial_feature_email_followups": "Email Follow-ups",
"trial_feature_hide_branding": "Hide Formbricks Branding",
"trial_feature_mobile_sdks": "iOS & Android SDKs",
"trial_feature_respondent_identification": "Respondent Identification",
"trial_feature_unlimited_seats": "Unlimited Seats",
"trial_feature_webhooks": "Custom Webhooks",
"trial_no_credit_card": "14 days trial, no credit card required",
"trial_title": "Try Pro features for free!",
"trial_payment_method_added_description": "You're all set! Your Pro plan will continue automatically after the trial ends.",
"trial_title": "Get Formbricks Pro for free!",
"unlimited_responses": "Unlimited Responses",
"unlimited_workspaces": "Unlimited Workspaces",
"upgrade": "Upgrade",
"upgrade_now": "Upgrade now",
"usage_cycle": "Usage cycle",
"used": "used",
"yearly": "Yearly",
"yearly_checkout_unavailable": "Yearly checkout is not available yet. Add a payment method on a monthly plan first or contact support.",
"your_plan": "Your plan"
},
"domain": {
@@ -1031,6 +1081,25 @@
"enterprise_features": "Enterprise Features",
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
"license_feature_access_control": "Access control (RBAC)",
"license_feature_audit_logs": "Audit logs",
"license_feature_contacts": "Contacts & Segments",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remove branding",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam protection",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Two-factor authentication",
"license_feature_whitelabel": "White-label emails",
"license_features_table_access": "Access",
"license_features_table_description": "Enterprise features and limits currently available to this instance.",
"license_features_table_disabled": "Disabled",
"license_features_table_enabled": "Enabled",
"license_features_table_feature": "Feature",
"license_features_table_title": "Licensed Features",
"license_features_table_unlimited": "Unlimited",
"license_features_table_value": "Value",
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
"license_status": "License Status",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Custom hostname",
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone",
"expand_preview": "Expand Preview",
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options",
"reverse_order_occasionally": "Reverse order occasionally",
"reverse_order_occasionally_except_last": "Reverse order occasionally except last",
"roundness": "Roundness",
"roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"shrink_preview": "Shrink Preview",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Smiley",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Styling set to theme styles",
"subheading": "Subheading",
"subtract": "Subtract -",
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
"survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings",
"survey_placement": "Survey Placement",
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
@@ -3012,7 +3086,7 @@
"preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_question_2_subheader": "This is an example description.",
"preview_survey_question_open_text_headline": "Anything else you'd like to share?",
"preview_survey_question_open_text_headline": "Anything else you would like to share?",
"preview_survey_question_open_text_placeholder": "Type your answer here…",
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
"preview_survey_welcome_card_headline": "Welcome!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you'd like to add?",
"follow_up_label": "Is there anything else you would like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
"generate_button": "Generate workflow",
"heading": "What workflow do you want to create?",
+101 -27
View File
@@ -167,6 +167,7 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
@@ -174,6 +175,7 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar enlace",
"copy_to_environment": "Copiar a {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial",
"environment": "Entorno",
"environment_not_found": "Entorno no encontrado",
"environment_notice": "Actualmente estás en el entorno {environment}.",
"error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Encuestas inactivas",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date": "Fecha no válida",
"invalid_date_with_value": "Fecha no válida: {value}",
"invalid_file_name": "Nombre de archivo no válido, por favor renombre su archivo e inténtelo de nuevo",
"invalid_file_type": "Tipo de archivo no válido",
"invite": "Invitar",
"invite_them": "Invítales",
"javascript_required": "Se requiere JavaScript",
"javascript_required_description": "Formbricks requiere JavaScript para funcionar correctamente. Por favor, activa JavaScript en la configuración de tu navegador para continuar.",
"key": "Clave",
"label": "Etiqueta",
"language": "Idioma",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership": "Membresía",
"membership_not_found": "Membresía no encontrada",
"meta": "Meta",
"metadata": "Metadatos",
"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!",
@@ -294,6 +300,7 @@
"new": "Nuevo",
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
"next": "Siguiente",
"no_actions_found": "No se encontraron acciones",
"no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos",
@@ -319,10 +326,9 @@
"or": "o",
"organization": "Organización",
"organization_id": "ID de organización",
"organization_not_found": "Organización no encontrada",
"organization_settings": "Ajustes de la organización",
"organization_teams_not_found": "Equipos de la organización no encontrados",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
@@ -380,6 +387,7 @@
"select": "Seleccionar",
"select_all": "Seleccionar todo",
"select_filter": "Seleccionar filtro",
"select_language": "Seleccionar idioma",
"select_survey": "Seleccionar encuesta",
"select_teams": "Seleccionar equipos",
"selected": "Seleccionado",
@@ -412,7 +420,6 @@
"survey_id": "ID de encuesta",
"survey_languages": "Idiomas de la encuesta",
"survey_live": "Encuesta activa",
"survey_not_found": "Encuesta no encontrada",
"survey_paused": "Encuesta pausada.",
"survey_type": "Tipo de encuesta",
"surveys": "Encuestas",
@@ -427,13 +434,15 @@
"team_name": "Nombre del equipo",
"team_role": "Rol del equipo",
"teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
"time_to_finish": "Tiempo para finalizar",
"title": "Título",
"top_left": "Superior izquierda",
"top_right": "Superior derecha",
"trial_days_remaining": "{count} días restantes en tu prueba",
"trial_expired": "Tu prueba ha expirado",
"trial_one_day_remaining": "1 día restante en tu prueba",
"try_again": "Intentar de nuevo",
"type": "Tipo",
"unknown_survey": "Encuesta desconocida",
@@ -441,13 +450,13 @@
"update": "Actualizar",
"updated": "Actualizado",
"updated_at": "Actualizado el",
"upgrade_plan": "Mejorar plan",
"upload": "Subir",
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
"upload_input_description": "Haz clic o arrastra para subir archivos.",
"url": "URL",
"user": "Usuario",
"user_id": "ID de usuario",
"user_not_found": "Usuario no encontrado",
"variable": "Variable",
"variable_ids": "IDs de variables",
"variables": "Variables",
@@ -463,14 +472,13 @@
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Flujos de trabajo",
"workspace": "Espacio de trabajo",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
"workspace_id": "ID del proyecto",
"workspace_name": "Nombre del proyecto",
"workspace_name_placeholder": "p. ej. Formbricks",
"workspace_not_found": "Proyecto no encontrado",
"workspace_permission_not_found": "Permiso del proyecto no encontrado",
"workspaces": "Proyectos",
"years": "años",
"you": "Tú",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario 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",
@@ -846,9 +853,16 @@
"created_by_third_party": "Creado por un tercero",
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
"endpoint_bad_gateway_error": "Puerta de enlace incorrecta (502): Error de proxy o puerta de enlace, servicio no accesible",
"endpoint_gateway_timeout_error": "Tiempo de espera de la puerta de enlace agotado (504): Tiempo de espera de la puerta de enlace agotado, servicio no accesible",
"endpoint_internal_server_error": "Error interno del servidor (500): El servicio encontró un error inesperado",
"endpoint_method_not_allowed_error": "Método no permitido (405): El endpoint existe, pero no acepta solicitudes POST",
"endpoint_not_found_error": "No encontrado (404): El endpoint no existe",
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"endpoint_service_unavailable_error": "Servicio no disponible (503): El servicio está temporalmente caído",
"learn_to_verify": "Aprende a verificar las firmas de webhook",
"no_triggers": "Sin activadores",
"please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada",
@@ -968,44 +982,80 @@
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
},
"billing": {
"cancelling": "Cancelando",
"add_payment_method": "Añadir método de pago",
"add_payment_method_to_upgrade_tooltip": "Por favor, añade un método de pago arriba para mejorar a un plan de pago",
"billing_interval_toggle": "Intervalo de facturación",
"current_plan_badge": "Actual",
"current_plan_cta": "Plan actual",
"custom_plan_description": "Tu organización tiene una configuración de facturación personalizada. Aún puedes cambiar a uno de los planes estándar a continuación.",
"custom_plan_title": "Plan personalizado",
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
"manage_subscription": "Gestionar suscripción",
"keep_current_plan": "Mantener plan actual",
"manage_billing_details": "Gestionar datos de tarjeta y facturas",
"monthly": "Mensual",
"most_popular": "Más popular",
"pending_change_removed": "Cambio de plan programado eliminado.",
"pending_plan_badge": "Programado",
"pending_plan_change_description": "Tu plan cambiará a {{plan}} el {{date}}.",
"pending_plan_change_title": "Cambio de plan programado",
"pending_plan_cta": "Programado",
"per_month": "por mes",
"per_year": "por año",
"plan_change_applied": "Plan actualizado correctamente.",
"plan_change_scheduled": "Cambio de plan programado correctamente.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Todo lo de Hobby",
"plan_feature_everything_in_pro": "Todo lo de Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Para individuos y equipos pequeños que comienzan con Formbricks Cloud.",
"plan_hobby_feature_responses": "250 respuestas / mes",
"plan_hobby_feature_workspaces": "1 espacio de trabajo",
"plan_pro": "Pro",
"plan_pro_description": "Para equipos en crecimiento que necesitan límites más altos, automatizaciones y excesos dinámicos.",
"plan_pro_feature_responses": "2.000 respuestas / mes (uso excedente dinámico)",
"plan_pro_feature_workspaces": "3 espacios de trabajo",
"plan_scale": "Scale",
"plan_scale_description": "Para equipos más grandes que necesitan mayor capacidad, gobernanza más sólida y mayor volumen de respuestas.",
"plan_scale_feature_responses": "5.000 respuestas/mes (excedente dinámico)",
"plan_scale_feature_workspaces": "5 espacios de trabajo",
"plan_selection_description": "Compara Hobby, Pro y Scale, y cambia de plan directamente desde Formbricks.",
"plan_selection_title": "Elige tu plan",
"plan_unknown": "Desconocido",
"remove_branding": "Eliminar marca",
"retry_setup": "Reintentar configuración",
"scale_banner_description": "Desbloquea límites superiores, colaboración en equipo y funciones de seguridad avanzadas con el plan Scale.",
"scale_banner_title": "¿Listo para crecer?",
"scale_feature_api": "Acceso completo a la API",
"scale_feature_quota": "Gestión de cuota",
"scale_feature_spam": "Protección contra spam",
"scale_feature_teams": "Equipos y roles de acceso",
"select_plan_header_subtitle": "Sin tarjeta de crédito, sin compromisos.",
"select_plan_header_title": "¡Lanza encuestas profesionales sin marca hoy mismo!",
"select_plan_header_title": "Encuestas perfectamente integradas, 100% tu marca.",
"status_trialing": "Prueba",
"stay_on_hobby_plan": "Quiero quedarme en el plan Hobby",
"stripe_setup_incomplete": "Configuración de facturación incompleta",
"stripe_setup_incomplete_description": "La configuración de facturación no se completó correctamente. Por favor, vuelve a intentarlo para activar tu suscripción.",
"subscription": "Suscripción",
"subscription_description": "Gestiona tu plan de suscripción y monitorea tu uso",
"switch_at_period_end": "Cambiar al final del período",
"switch_plan_now": "Cambiar de plan ahora",
"this_includes": "Esto incluye",
"trial_alert_description": "Añade un método de pago para mantener el acceso a todas las funciones.",
"trial_already_used": "Ya se ha utilizado una prueba gratuita para esta dirección de correo electrónico. Por favor, actualiza a un plan de pago.",
"trial_feature_api_access": "Acceso completo a la API",
"trial_feature_collaboration": "Todas las funciones de equipo y colaboración",
"trial_feature_email_followups": "Configurar seguimientos por correo electrónico",
"trial_feature_quotas": "Gestionar cuotas",
"trial_feature_webhooks": "Configurar webhooks personalizados",
"trial_feature_whitelabel": "Encuestas totalmente personalizadas",
"trial_feature_api_access": "Acceso a la API",
"trial_feature_attribute_segmentation": "Segmentación basada en atributos",
"trial_feature_contact_segment_management": "Gestión de contactos y segmentos",
"trial_feature_email_followups": "Seguimientos por correo electrónico",
"trial_feature_hide_branding": "Ocultar la marca Formbricks",
"trial_feature_mobile_sdks": "SDKs para iOS y Android",
"trial_feature_respondent_identification": "Identificación de encuestados",
"trial_feature_unlimited_seats": "Asientos ilimitados",
"trial_feature_webhooks": "Webhooks personalizados",
"trial_no_credit_card": "Prueba de 14 días, sin tarjeta de crédito",
"trial_title": "¡Prueba las funciones Pro gratis!",
"trial_payment_method_added_description": "¡Todo listo! Tu plan Pro continuará automáticamente cuando termine el periodo de prueba.",
"trial_title": "¡Consigue Formbricks Pro gratis!",
"unlimited_responses": "Respuestas ilimitadas",
"unlimited_workspaces": "Proyectos ilimitados",
"upgrade": "Actualizar",
"upgrade_now": "Actualizar ahora",
"usage_cycle": "Usage cycle",
"used": "usados",
"yearly": "Anual",
"yearly_checkout_unavailable": "El pago anual aún no está disponible. Primero añade un método de pago en un plan mensual o contacta con soporte.",
"your_plan": "Tu plan"
},
"domain": {
@@ -1031,6 +1081,25 @@
"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_feature_access_control": "Control de acceso (RBAC)",
"license_feature_audit_logs": "Registros de auditoría",
"license_feature_contacts": "Contactos y segmentos",
"license_feature_projects": "Espacios de trabajo",
"license_feature_quotas": "Cuotas",
"license_feature_remove_branding": "Eliminar marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protección contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticación de dos factores",
"license_feature_whitelabel": "Correos sin marca",
"license_features_table_access": "Acceso",
"license_features_table_description": "Funciones y límites empresariales disponibles actualmente para esta instancia.",
"license_features_table_disabled": "Desactivado",
"license_features_table_enabled": "Activado",
"license_features_table_feature": "Función",
"license_features_table_title": "Funciones con licencia",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
"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",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Nombre de host personalizado",
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Error al guardar los cambios",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
"everyone": "Todos",
"expand_preview": "Expandir vista previa",
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
"fallback_missing": "Falta respaldo",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).",
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta",
"reverse_order_occasionally": "Invertir orden ocasionalmente",
"reverse_order_occasionally_except_last": "Invertir orden ocasionalmente excepto el último",
"roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas.",
"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.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
"shrink_preview": "Contraer vista previa",
"simple": "Simple",
"six_points": "6 puntos",
"smiley": "Emoticono",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
"subheading": "Subtítulo",
"subtract": "Restar -",
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
"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",
"survey_placement": "Ubicación de la encuesta",
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
+104 -30
View File
@@ -167,6 +167,7 @@
"connect": "Connecter",
"connect_formbricks": "Connecter Formbricks",
"connected": "Connecté",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continuer",
"copied": "Copié",
@@ -174,6 +175,7 @@
"copy": "Copier",
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"copy_to_environment": "Copier vers {{environment}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
"elements": "Éléments",
"email": "Email",
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
"environment": "Environnement",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Sondages inactifs",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date": "Date invalide",
"invalid_date_with_value": "Date invalide: {value}",
"invalid_file_name": "Nom de fichier invalide, veuillez renommer votre fichier et réessayer",
"invalid_file_type": "Type de fichier invalide",
"invite": "Inviter",
"invite_them": "Invitez-les",
"javascript_required": "JavaScript requis",
"javascript_required_description": "Formbricks nécessite JavaScript pour fonctionner correctement. Veuillez activer JavaScript dans les paramètres de votre navigateur pour continuer.",
"key": "Clé",
"label": "Étiquette",
"language": "Langue",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership": "Adhésion",
"membership_not_found": "Abonnement non trouvé",
"meta": "Méta",
"metadata": "Métadonnées",
"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!",
@@ -294,6 +300,7 @@
"new": "Nouveau",
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
"next": "Suivant",
"no_actions_found": "Aucune action trouvée",
"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é.",
@@ -319,10 +326,9 @@
"or": "ou",
"organization": "Organisation",
"organization_id": "Identifiant de l'organisation",
"organization_not_found": "Organisation non trouvée",
"organization_settings": "Paramètres de l'organisation",
"organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre",
"other_filters": "Autres filtres",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
@@ -380,6 +387,7 @@
"select": "Sélectionner",
"select_all": "Sélectionner tout",
"select_filter": "Sélectionner un filtre",
"select_language": "Sélectionner la langue",
"select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
@@ -399,7 +407,7 @@
"something_went_wrong": "Quelque chose s'est mal passé.",
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
"sort_by": "Trier par",
"start_free_trial": "Essayer gratuitement",
"start_free_trial": "Commencer l'essai gratuit",
"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",
@@ -412,7 +420,6 @@
"survey_id": "ID de l'enquête",
"survey_languages": "Langues de l'enquête",
"survey_live": "Sondage en direct",
"survey_not_found": "Sondage non trouvé",
"survey_paused": "Sondage en pause.",
"survey_type": "Type de sondage",
"surveys": "Enquêtes",
@@ -427,13 +434,15 @@
"team_name": "Nom de l'équipe",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
"time_to_finish": "Temps de finir",
"title": "Titre",
"top_left": "En haut à gauche",
"top_right": "En haut à droite",
"trial_days_remaining": "{count} jours restants dans votre période d'essai",
"trial_expired": "Votre période d'essai a expiré",
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
"try_again": "Réessayer",
"type": "Type",
"unknown_survey": "Enquête inconnue",
@@ -441,13 +450,13 @@
"update": "Mise à jour",
"updated": "Mise à jour",
"updated_at": "Mis à jour à",
"upgrade_plan": "Améliorer le forfait",
"upload": "Télécharger",
"upload_failed": "Échec du téléchargement. Veuillez réessayer.",
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
"url": "URL",
"user": "Utilisateur",
"user_id": "Identifiant d'utilisateur",
"user_not_found": "Utilisateur non trouvé",
"variable": "Variable",
"variable_ids": "Identifiants variables",
"variables": "Variables",
@@ -463,14 +472,13 @@
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace": "Espace de travail",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
"workspace_creation_description": "Organisez les enquêtes dans des projets pour un meilleur contrôle d'accès.",
"workspace_id": "ID du projet",
"workspace_name": "Nom du projet",
"workspace_name_placeholder": "par ex. Formbricks",
"workspace_not_found": "Projet introuvable",
"workspace_permission_not_found": "Permission du projet introuvable",
"workspaces": "Projets",
"years": "années",
"you": "Vous",
@@ -655,7 +663,6 @@
"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",
@@ -846,9 +853,16 @@
"created_by_third_party": "Créé par un tiers",
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
"endpoint_bad_gateway_error": "Mauvaise passerelle (502) : Erreur de proxy/passerelle, service inaccessible",
"endpoint_gateway_timeout_error": "Délai d'attente de la passerelle dépassé (504) : Le délai d'attente de la passerelle a expiré, service inaccessible",
"endpoint_internal_server_error": "Erreur interne du serveur (500) : Le service a rencontré une erreur inattendue",
"endpoint_method_not_allowed_error": "Méthode non autorisée (405) : Le point de terminaison existe, mais n'accepte pas les requêtes POST",
"endpoint_not_found_error": "Introuvable (404) : Le point de terminaison n'existe pas",
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
"endpoint_pinged_error": "Impossible de pinger le webhook !",
"endpoint_service_unavailable_error": "Service indisponible (503) : Le service est temporairement indisponible",
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
"no_triggers": "Aucun déclencheur",
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
"please_enter_a_url": "Veuillez entrer une URL.",
"response_created": "Réponse créée",
@@ -968,44 +982,80 @@
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
},
"billing": {
"cancelling": "Annulation en cours",
"add_payment_method": "Ajouter un moyen de paiement",
"add_payment_method_to_upgrade_tooltip": "Veuillez ajouter un moyen de paiement ci-dessus pour passer à un forfait payant",
"billing_interval_toggle": "Intervalle de facturation",
"current_plan_badge": "Actuel",
"current_plan_cta": "Formule actuelle",
"custom_plan_description": "Votre organisation dispose d'une configuration de facturation personnalisée. Tu peux toujours basculer vers l'une des formules standard ci-dessous.",
"custom_plan_title": "Formule personnalisée",
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
"manage_subscription": "Gérer l'abonnement",
"keep_current_plan": "Conserver la formule actuelle",
"manage_billing_details": "Gérer les détails de la carte et les factures",
"monthly": "Mensuel",
"most_popular": "Le plus populaire",
"pending_change_removed": "Changement de formule programmé supprimé.",
"pending_plan_badge": "Programmé",
"pending_plan_change_description": "Ta formule passera à {{plan}} le {{date}}.",
"pending_plan_change_title": "Changement de formule programmé",
"pending_plan_cta": "Programmé",
"per_month": "par mois",
"per_year": "par an",
"plan_change_applied": "Formule mise à jour avec succès.",
"plan_change_scheduled": "Changement de formule programmé avec succès.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tout ce qui est inclus dans Hobby",
"plan_feature_everything_in_pro": "Tout ce qui est inclus dans Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Pour les particuliers et les petites équipes qui débutent avec Formbricks Cloud.",
"plan_hobby_feature_responses": "250 réponses / mois",
"plan_hobby_feature_workspaces": "1 espace de travail",
"plan_pro": "Pro",
"plan_pro_description": "Pour les équipes en croissance qui ont besoin de limites plus élevées, d'automatisations et de dépassements dynamiques.",
"plan_pro_feature_responses": "2 000 réponses / mois (dépassement dynamique)",
"plan_pro_feature_workspaces": "3 espaces de travail",
"plan_scale": "Scale",
"plan_scale_description": "Pour les grandes équipes qui ont besoin de plus de capacité, d'une meilleure gouvernance et d'un volume de réponses plus élevé.",
"plan_scale_feature_responses": "5 000 réponses / mois (dépassement dynamique)",
"plan_scale_feature_workspaces": "5 espaces de travail",
"plan_selection_description": "Compare les formules Hobby, Pro et Scale, puis change de formule directement depuis Formbricks.",
"plan_selection_title": "Choisis ta formule",
"plan_unknown": "Inconnu",
"remove_branding": "Suppression du logo",
"retry_setup": "Réessayer la configuration",
"scale_banner_description": "Débloque des limites plus élevées, la collaboration en équipe et des fonctionnalités de sécurité avancées avec loffre Scale.",
"scale_banner_title": "Prêt à passer à la vitesse supérieure ?",
"scale_feature_api": "Accès API complet",
"scale_feature_quota": "Gestion des quotas",
"scale_feature_spam": "Protection contre le spam",
"scale_feature_teams": "Équipes & rôles daccès",
"select_plan_header_subtitle": "Aucune carte bancaire requise, aucun engagement.",
"select_plan_header_title": "Envoyez des sondages professionnels et personnalisés dès aujourd'hui !",
"select_plan_header_title": "Sondages parfaitement intégrés, 100 % à ton image.",
"status_trialing": "Essai",
"stay_on_hobby_plan": "Je veux rester sur le plan Hobby",
"stripe_setup_incomplete": "Configuration de la facturation incomplète",
"stripe_setup_incomplete_description": "La configuration de la facturation na pas abouti. Merci de réessayer pour activer ton abonnement.",
"subscription": "Abonnement",
"subscription_description": "Gère ton abonnement et surveille ta consommation",
"switch_at_period_end": "Changer à la fin de la période",
"switch_plan_now": "Changer de formule maintenant",
"this_includes": "Cela inclut",
"trial_alert_description": "Ajoute un moyen de paiement pour conserver l'accès à toutes les fonctionnalités.",
"trial_already_used": "Un essai gratuit a déjà été utilisé pour cette adresse e-mail. Passe plutôt à un plan payant.",
"trial_feature_api_access": "Accès complet à l'API",
"trial_feature_collaboration": "Toutes les fonctionnalités d'équipe et de collaboration",
"trial_feature_email_followups": "Configure des relances par e-mail",
"trial_feature_quotas": "Gère les quotas",
"trial_feature_webhooks": "Configure des webhooks personnalisés",
"trial_feature_whitelabel": "Enquêtes entièrement en marque blanche",
"trial_feature_api_access": "Accès API",
"trial_feature_attribute_segmentation": "Segmentation basée sur les attributs",
"trial_feature_contact_segment_management": "Gestion des contacts et segments",
"trial_feature_email_followups": "Relances par e-mail",
"trial_feature_hide_branding": "Masquer l'image de marque Formbricks",
"trial_feature_mobile_sdks": "SDKs iOS et Android",
"trial_feature_respondent_identification": "Identification des répondants",
"trial_feature_unlimited_seats": "Places illimitées",
"trial_feature_webhooks": "Webhooks personnalisés",
"trial_no_credit_card": "Essai de 14 jours, aucune carte bancaire requise",
"trial_title": "Essaie les fonctionnalités Pro gratuitement !",
"trial_payment_method_added_description": "Tout est prêt ! Votre abonnement Pro se poursuivra automatiquement après la fin de la période d'essai.",
"trial_title": "Obtenez Formbricks Pro gratuitement !",
"unlimited_responses": "Réponses illimitées",
"unlimited_workspaces": "Projets illimités",
"upgrade": "Mise à niveau",
"upgrade_now": "Passer à la formule supérieure maintenant",
"usage_cycle": "Usage cycle",
"used": "utilisé(s)",
"yearly": "Annuel",
"yearly_checkout_unavailable": "Le paiement annuel n'est pas encore disponible. Ajoute d'abord un moyen de paiement sur un forfait mensuel ou contacte le support.",
"your_plan": "Ton offre"
},
"domain": {
@@ -1031,6 +1081,25 @@
"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_feature_access_control": "Contrôle d'accès (RBAC)",
"license_feature_audit_logs": "Journaux d'audit",
"license_feature_contacts": "Contacts et segments",
"license_feature_projects": "Espaces de travail",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Retirer l'image de marque",
"license_feature_saml": "SSO SAML",
"license_feature_spam_protection": "Protection anti-spam",
"license_feature_sso": "SSO OIDC",
"license_feature_two_factor_auth": "Authentification à deux facteurs",
"license_feature_whitelabel": "E-mails en marque blanche",
"license_features_table_access": "Accès",
"license_features_table_description": "Fonctionnalités Enterprise et limites actuellement disponibles pour cette instance.",
"license_features_table_disabled": "Désactivé",
"license_features_table_enabled": "Activé",
"license_features_table_feature": "Fonctionnalité",
"license_features_table_title": "Fonctionnalités sous licence",
"license_features_table_unlimited": "Illimité",
"license_features_table_value": "Valeur",
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
"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",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Nom d'hôte personnalisé",
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
"delete_anyways": "Supprimer quand même",
"delete_block": "Supprimer le bloc",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
"everyone": "Tout le monde",
"expand_preview": "Agrandir l'aperçu",
"external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher lhameçonnage.",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse",
"reverse_order_occasionally": "Inverser l'ordre occasionnellement",
"reverse_order_occasionally_except_last": "Inverser l'ordre occasionnellement sauf le dernier",
"roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins.",
"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.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
"shrink_preview": "Réduire l'aperçu",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Sourire",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
"subheading": "Sous-titre",
"subtract": "Soustraire -",
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
"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",
"survey_placement": "Placement de l'enquête",
"survey_preview": "Aperçu du sondage 👀",
"survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
@@ -3012,7 +3086,7 @@
"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_question_open_text_headline": "Autre chose que vous aimeriez partager?",
"preview_survey_question_open_text_headline": "Souhaitez-vous partager autre chose ?",
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
"preview_survey_welcome_card_headline": "Bienvenue !",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
"coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Y a-t-il autre chose que vous aimeriez ajouter?",
"follow_up_label": "Souhaitez-vous ajouter quelque chose ?",
"follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?",
"generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?",
+147 -73
View File
@@ -167,6 +167,7 @@
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
"connected": "Kapcsolódva",
"contact": "Kapcsolat",
"contacts": "Partnerek",
"continue": "Folytatás",
"copied": "Másolva",
@@ -174,8 +175,9 @@
"copy": "Másolás",
"copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása",
"copy_to_environment": "Másolás ide: {{environment}}",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} kontakt}} other {{count} kontakt}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
"elements": "Elemek",
"email": "E-mail",
"ending_card": "Befejező kártya",
"enter_url": "URL megadása",
"enterprise_license": "Vállalati licenc",
"environment": "Környezet",
"environment_not_found": "A környezet nem található",
"environment_notice": "Ön jelenleg a(z) {environment} környezetben van.",
"error": "Hiba",
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Inaktív kérdőívek",
"integration": "integráció",
"integrations": "Integrációk",
"invalid_date": "Érvénytelen dátum",
"invalid_date_with_value": "Érvénytelen dátum: {value}",
"invalid_file_name": "Érvénytelen fájlnév, nevezze át a fájlt, és próbálja újra",
"invalid_file_type": "Érvénytelen fájltípus",
"invite": "Meghívás",
"invite_them": "Meghívó nekik",
"javascript_required": "JavaScript szükséges",
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Tagok",
"members_and_teams": "Tagok és csapatok",
"membership": "Tagság",
"membership_not_found": "A tagság nem található",
"meta": "Meta",
"metadata": "Metaadatok",
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
"mobile_overlay_surveys_look_good": "Ne aggódjon a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
@@ -294,6 +300,7 @@
"new": "Új",
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
"next": "Következő",
"no_actions_found": "Nem találhatók műveletek",
"no_background_image_found": "Nem található háttérkép.",
"no_code": "Kód nélkül",
"no_files_uploaded": "Nem lettek fájlok feltöltve",
@@ -319,10 +326,9 @@
"or": "vagy",
"organization": "Szervezet",
"organization_id": "Szervezetazonosító",
"organization_not_found": "A szervezet nem található",
"organization_settings": "Szervezet beállításai",
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"others": "Mások",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet",
"please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót",
"please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet",
"preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek",
@@ -360,7 +367,7 @@
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
"replace": "Csere",
"report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbalicenc kérése",
"request_trial_license": "Próbaidőszaki licenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"response": "Válasz",
"response_id": "Válaszazonosító",
@@ -380,6 +387,7 @@
"select": "Kiválasztás",
"select_all": "Összes kiválasztása",
"select_filter": "Szűrő kiválasztása",
"select_language": "Nyelv kiválasztása",
"select_survey": "Kérdőív kiválasztása",
"select_teams": "Csapatok kiválasztása",
"selected": "Kiválasztva",
@@ -399,7 +407,7 @@
"something_went_wrong": "Valami probléma történt",
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
"sort_by": "Rendezési sorrend",
"start_free_trial": "Ingyenes próba indítása",
"start_free_trial": "Ingyenes próbaidőszak indítása",
"status": "Állapot",
"step_by_step_manual": "Lépésenkénti kézikönyv",
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
@@ -412,7 +420,6 @@
"survey_id": "Kérdőív-azonosító",
"survey_languages": "Kérdőív nyelvei",
"survey_live": "A kérdőív élő",
"survey_not_found": "A kérdőív nem található",
"survey_paused": "A kérdőív szüneteltetve.",
"survey_type": "Kérdőív típusa",
"surveys": "Kérdőívek",
@@ -427,13 +434,15 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"teams_not_found": "A csapatok nem találhatók",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
"title": "Cím",
"top_left": "Balra fent",
"top_right": "Jobbra fent",
"trial_days_remaining": "{count} nap van hátra a próbaidőszakából",
"trial_expired": "A próbaidőszaka lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
"try_again": "Próbálja újra",
"type": "Típus",
"unknown_survey": "Ismeretlen kérdőív",
@@ -441,13 +450,13 @@
"update": "Frissítés",
"updated": "Frissítve",
"updated_at": "Frissítve",
"upgrade_plan": "Magasabb csomagra váltás",
"upload": "Feltöltés",
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.",
"upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
"url": "URL",
"user": "Felhasználó",
"user_id": "Felhasználó-azonosító",
"user_not_found": "A felhasználó nem található",
"variable": "Változó",
"variable_ids": "Változóazonosítók",
"variables": "Változók",
@@ -463,14 +472,13 @@
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workflows": "Munkafolyamatok",
"workspace": "Munkaterület",
"workspace_configuration": "Munkaterület beállítása",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
"workspace_id": "Munkaterület-azonosító",
"workspace_name": "Munkaterület neve",
"workspace_name_placeholder": "például Formbricks",
"workspace_not_found": "A munkaterület nem található",
"workspace_permission_not_found": "A munkaterület-jogosultság nem található",
"workspaces": "Munkaterületek",
"years": "év",
"you": "Ön",
@@ -533,7 +541,7 @@
"survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése",
"text_variable": "Szöveg változó",
"verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:",
"verification_email_heading": "Már majdnem megvagyunk!",
"verification_email_heading": "Már majdnem kész vagyunk!",
"verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
@@ -601,15 +609,15 @@
"test_match": "Illeszkedés tesztelése",
"test_your_url": "URL tesztelése",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.",
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet akkor fog aktiválódni, miután a felhasználó a megadott ideig az oldalon tartózkodik.",
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet azután lesz aktiválva, hogy a felhasználó az oldalon marad a megadott időtartamig.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
"time_in_seconds": "Idő másodpercben",
"time_in_seconds_placeholder": "pl. 10",
"time_in_seconds_placeholder": "például 10",
"time_in_seconds_with_unit": "{seconds} mp",
"time_on_page": "Oldalon töltött idő",
"time_on_page": "Idő az oldalon",
"track_new_user_action": "Új felhasználói művelet követése",
"track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.",
"url": "URL",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
"contact_deleted_successfully": "A partner sikeresen törölve",
"contact_not_found": "Nem található ilyen partner",
"contacts_table_refresh": "Partnerek frissítése",
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
"create_attribute": "Attribútum létrehozása",
@@ -846,9 +853,16 @@
"created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -968,44 +982,80 @@
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
},
"billing": {
"cancelling": "Lemondás folyamatban",
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
"manage_subscription": "Előfizetés kezelése",
"plan_custom": "Custom",
"plan_hobby": "Hobby",
"add_payment_method": "Fizetési mód hozzáadása",
"add_payment_method_to_upgrade_tooltip": "Adjon hozzá fizetési módot fent, hogy fizetős csomagra váltson",
"billing_interval_toggle": "Számlázási időköz",
"current_plan_badge": "Jelenlegi",
"current_plan_cta": "Jelenlegi csomag",
"custom_plan_description": "A szervezete egyéni számlázási beállítással rendelkezik. Ugyanakkor áttérhet az alábbi szabványos csomagok egyikére.",
"custom_plan_title": "Egyéni csomag",
"failed_to_start_trial": "Nem sikerült a próbaidőszak indítása. Próbálja meg újra.",
"keep_current_plan": "Jelenlegi csomag megtartása",
"manage_billing_details": "Kártyarészletek és számlák kezelése",
"monthly": "Havi",
"most_popular": "Legnépszerűbb",
"pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
"pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {{plan}} csomagra fog váltani ekkor: {{date}}.",
"pending_plan_change_title": "Ütemezett csomagváltoztatás",
"pending_plan_cta": "Ütemezett",
"per_month": "havonta",
"per_year": "évente",
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
"plan_custom": "Egyéni",
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
"plan_hobby": "Hobbi",
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
"plan_hobby_feature_responses": "250 válasz/hónap",
"plan_hobby_feature_workspaces": "1 munkaterület",
"plan_pro": "Pro",
"plan_scale": "Scale",
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Méretezés",
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek több kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Csomag kiválasztása",
"plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása",
"retry_setup": "Újrapróbálkozás a beállítással",
"scale_banner_description": "Nagyobb limitek, csapatmunka és fejlett biztonsági funkciók a Scale csomaggal.",
"scale_banner_title": "Készen áll a növekedésre?",
"scale_feature_api": "Teljes API hozzáférés",
"scale_feature_quota": "Keretkezelés",
"scale_feature_spam": "Spamvédelem",
"scale_feature_teams": "Csapatok és hozzáférési szerepkörök",
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
"select_plan_header_title": "Küldjön professzionális, márkajelzés nélküli felméréseket még ma!",
"status_trialing": "Próbaverzió",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
"stripe_setup_incomplete": "Számlázás beállítása nem teljes",
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.",
"retry_setup": "Beállítás újrapróbálása",
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
"status_trialing": "Próbaidőszak",
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
"subscription": "Előfizetés",
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot",
"trial_already_used": "Ehhez az e-mail címhez már igénybe vettek ingyenes próbaidőszakot. Kérjük, válasszon helyette fizetős csomagot.",
"trial_feature_api_access": "Teljes API-hozzáférés megszerzése",
"trial_feature_collaboration": "Minden csapat- és együttműködési funkció",
"trial_feature_email_followups": "E-mail követések beállítása",
"trial_feature_quotas": "Kvóták kezelése",
"trial_feature_webhooks": "Egyéni webhookok beállítása",
"trial_feature_whitelabel": "Teljesen fehércímkés felmérések",
"trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül",
"trial_title": "Próbálja ki a Pro funkciókat ingyen!",
"subscription_description": "Az előfizetési csomag kezelése és a használat felügyelete",
"switch_at_period_end": "Váltás az időszak végén",
"switch_plan_now": "Csomag váltása most",
"this_includes": "Ezeket tartalmazza",
"trial_alert_description": "Fizetési mód hozzáadása az összes funkcióhoz való hozzáférés megtartásához.",
"trial_already_used": "Ehhez az e-mail-címhez már használatban van egy ingyenes próbaidőszak. Váltson inkább fizetős csomagra.",
"trial_feature_api_access": "API-hozzáférés",
"trial_feature_attribute_segmentation": "Attribútumalapú szakaszolás",
"trial_feature_contact_segment_management": "Partner- és szakaszkezelés",
"trial_feature_email_followups": "E-mailes utókövetések",
"trial_feature_hide_branding": "Formbricks márkajel elrejtése",
"trial_feature_mobile_sdks": "iOS és Android SDK-k",
"trial_feature_respondent_identification": "Válaszadó-azonosítás",
"trial_feature_unlimited_seats": "Korlátlan számú hely",
"trial_feature_webhooks": "Egyéni webhorgok",
"trial_no_credit_card": "14 napos próbaidőszak, nincs szükség hitelkártyára",
"trial_payment_method_added_description": "Mindent beállított! A Pro csomagja a próbaidőszak vége után automatikusan folytatódik.",
"trial_title": "Szerezze meg a Formbricks Pro csomagot ingyen!",
"unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés",
"usage_cycle": "Usage cycle",
"used": "felhasználva",
"upgrade_now": "Frissítés most",
"usage_cycle": "Használati ciklus",
"used": "használva",
"yearly": "Évente",
"yearly_checkout_unavailable": "Az éves fizetési lehetőség még nem érhető el. Először adjon hozzá fizetési módot egy havi csomaghoz, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
"your_plan": "Az Ön csomagja"
},
"domain": {
@@ -1031,29 +1081,48 @@
"enterprise_features": "Vállalati funkciók",
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks példányhoz van kötve. Amennyiben ez a telepítés újra lett építve vagy áthelyezésre került, kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző példány kötését.",
"license_feature_access_control": "Hozzáférés-vezérlés (RBAC)",
"license_feature_audit_logs": "Auditálási naplók",
"license_feature_contacts": "Partnerek és szakaszok",
"license_feature_projects": "Munkaterületek",
"license_feature_quotas": "Kvóták",
"license_feature_remove_branding": "Márkajel eltávolítása",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Szemét elleni védekezés",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
"license_feature_whitelabel": "Fehér címkés e-mailek",
"license_features_table_access": "Hozzáférés",
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_disabled": "Letiltva",
"license_features_table_enabled": "Engedélyezve",
"license_features_table_feature": "Funkció",
"license_features_table_title": "Licencelt funkciók",
"license_features_table_unlimited": "Korlátlan",
"license_features_table_value": "Érték",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks-példányhoz van kötve. Ha ezt a telepítést újraépítették vagy áthelyezték, akkor kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi példányhoz való kötést.",
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
"license_status": "Licencállapot",
"license_status_active": "Aktív",
"license_status_description": "A vállalati licenc állapota.",
"license_status_expired": "Lejárt",
"license_status_instance_mismatch": "Másik Példányhoz Kötve",
"license_status_instance_mismatch": "Másik példányhoz kötve",
"license_status_invalid": "Érvénytelen licenc",
"license_status_unreachable": "Nem érhető el",
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos ingyenes próbalicencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos próbaidőszaki licencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
"no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)",
"on_request": "Kérésre",
"organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)",
"questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:",
"recheck_license": "Licenc újraellenőrzése",
"recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.",
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks példányhoz van kötve. Kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző kötést.",
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks-példányhoz van kötve. Kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi kötést.",
"recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.",
"recheck_license_success": "A licencellenőrzés sikeres",
"recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.",
"rechecking": "Újraellenőrzés…",
"request_30_day_trial_license": "30 napos ingyenes licenc kérése",
"request_30_day_trial_license": "30 napos próbaidőszaki licenc kérése",
"saml_sso": "SAML SSO",
"service_level_agreement": "Szolgáltatási megállapodás",
"soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Egyéni gépnév",
"customize_survey_logo": "A kérdőív logójának személyre szabása",
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
"date_format": "Dátumformátum",
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
"delete_anyways": "Törlés mindenképp",
"delete_block": "Blokk törlése",
@@ -1390,21 +1458,22 @@
"error_saving_changes": "Hiba a változtatások mentésekor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).",
"everyone": "Mindenki",
"external_urls_paywall_tooltip": "Kérjük, váltson fizetős csomagra, hogy testre szabhassa a külső URL-eket. Ez segít megelőzni az adathalászatot.",
"expand_preview": "Előnézet kinyitása",
"external_urls_paywall_tooltip": "Váltson a magasabb fizetős csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
"fallback_missing": "Tartalék hiányzik",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában",
"field_name_eg_score_price": "Mező neve, például pontszám, ár",
"first_name": "Keresztnév",
"five_points_recommended": "5 pont (ajánlott)",
"follow_ups": "Követések",
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt a követést?",
"follow_ups_delete_modal_title": "Törli a követést?",
"follow_ups": "Utókövetések",
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt az utókövetést?",
"follow_ups_delete_modal_title": "Törli az utókövetést?",
"follow_ups_empty_description": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak.",
"follow_ups_empty_heading": "Automatikus követések küldése",
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van a követésekben. A törlése eltávolítja az összes követésből. Biztosan törölni szeretné?",
"follow_ups_empty_heading": "Automatikus utókövetések küldése",
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van az utókövetésekben. A törlése eltávolítja az összes utókövetésből. Biztosan törölni szeretné?",
"follow_ups_ending_card_delete_modal_title": "Törli a befejező kártyát?",
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy követésben. Először távolítsa el a követésből.",
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy utókövetésben. Először távolítsa el az utókövetésből.",
"follow_ups_include_hidden_fields": "Rejtett mezők értékeinek felvétele",
"follow_ups_include_variables": "Változó értékeinek felvétele",
"follow_ups_item_ending_tag": "Befejezések",
@@ -1428,21 +1497,21 @@
"follow_ups_modal_action_to_description": "Az az e-mail-cím, ahova az e-mail elküldésre kerül",
"follow_ups_modal_action_to_label": "Címzett",
"follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt",
"follow_ups_modal_create_heading": "Új követés létrehozása",
"follow_ups_modal_created_successfull_toast": "A követés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_modal_edit_heading": "A követés szerkesztése",
"follow_ups_modal_edit_no_id": "Nincs kérdőívkövetési azonosító megadva, nem lehet frissíteni a kérdőívkövetést",
"follow_ups_modal_name_label": "Követés neve",
"follow_ups_modal_name_placeholder": "A követés elnevezése",
"follow_ups_modal_create_heading": "Új utókövetés létrehozása",
"follow_ups_modal_created_successfull_toast": "Az utókövetés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_modal_edit_heading": "Az utókövetés szerkesztése",
"follow_ups_modal_edit_no_id": "Nincs kérdőív-utókövetési azonosító megadva, nem lehet frissíteni a kérdőív utókövetését",
"follow_ups_modal_name_label": "Utókövetés neve",
"follow_ups_modal_name_placeholder": "Az utókövetés elnevezése",
"follow_ups_modal_subheading": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak",
"follow_ups_modal_trigger_description": "Mikor kell ezt a követést aktiválni?",
"follow_ups_modal_trigger_description": "Mikor kell ezt az utókövetést aktiválni?",
"follow_ups_modal_trigger_label": "Aktiváló",
"follow_ups_modal_trigger_type_ending": "A válaszadó egy adott befejezést lát",
"follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ",
"follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát",
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új követés",
"follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új utókövetés",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont",
"heading": "Címsor",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
"shrink_preview": "Előnézet összecsukása",
"simple": "Egyszerű",
"six_points": "6 pont",
"smiley": "Hangulatjel",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
"subheading": "Alcím",
"subtract": "Kivonás -",
"survey_closed_message_heading_required": "Címsor hozzáadása az egyéni kérdőív záró üzenetéhez.",
"survey_completed_heading": "A kérdőív kitöltve",
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
"survey_placement": "Kérdőív elhelyezése",
"survey_preview": "Kérdőív előnézete 👀",
"survey_styling": "Kérdőív stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
@@ -2724,8 +2798,8 @@
"evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…",
"evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?",
"evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…",
"fake_door_follow_up_description": "Követés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
"fake_door_follow_up_name": "„Fake door” követés",
"fake_door_follow_up_description": "Utókövetés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
"fake_door_follow_up_name": "„Fake door” utókövetés",
"fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?",
"fake_door_follow_up_question_1_lower_label": "Nem fontos",
"fake_door_follow_up_question_1_upper_label": "Nagyon fontos",
@@ -2734,7 +2808,7 @@
"fake_door_follow_up_question_2_choice_3": "3. szempont",
"fake_door_follow_up_question_2_choice_4": "4. szempont",
"fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?",
"feature_chaser_description": "Követés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
"feature_chaser_description": "Utókövetés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
"feature_chaser_name": "Funkcióvadász",
"feature_chaser_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?",
"feature_chaser_question_1_lower_label": "Nem fontos",
+101 -27
View File
@@ -167,6 +167,7 @@
"connect": "接続",
"connect_formbricks": "Formbricksを接続",
"connected": "接続済み",
"contact": "連絡先",
"contacts": "連絡先",
"continue": "続行",
"copied": "コピーしました",
@@ -174,6 +175,7 @@
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"copy_to_environment": "{{environment}} にコピー",
"count_attributes": "{count, plural, other {{count} 個の属性}}",
"count_contacts": "{count, plural, other {{count} 件の連絡先}}",
"count_members": "{count, plural, other {{count} 名のメンバー}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
"elements": "要素",
"email": "メールアドレス",
"ending_card": "終了カード",
"enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス",
"environment": "環境",
"environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。",
"error": "エラー",
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
@@ -255,11 +257,13 @@
"inactive_surveys": "非アクティブなフォーム",
"integration": "連携",
"integrations": "連携",
"invalid_date": "無効な日付です",
"invalid_date_with_value": "無効な日付です: {value}",
"invalid_file_name": "ファイル名が無効です。ファイル名を変更して再試行してください",
"invalid_file_type": "無効なファイルタイプです",
"invite": "招待",
"invite_them": "招待する",
"javascript_required": "JavaScriptが必要です",
"javascript_required_description": "Formbricksを正常に動作させるには、JavaScriptが必要です。続行するには、ブラウザの設定でJavaScriptを有効にしてください。",
"key": "キー",
"label": "ラベル",
"language": "言語",
@@ -280,7 +284,9 @@
"marketing": "マーケティング",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership": "メンバーシップ",
"membership_not_found": "メンバーシップが見つかりません",
"meta": "メタ",
"metadata": "メタデータ",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
@@ -294,6 +300,7 @@
"new": "新規",
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
"next": "次へ",
"no_actions_found": "アクションが見つかりません",
"no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません",
@@ -319,10 +326,9 @@
"or": "または",
"organization": "組織",
"organization_id": "組織ID",
"organization_not_found": "組織が見つかりません",
"organization_settings": "組織設定",
"organization_teams_not_found": "組織のチームが見つかりません",
"other": "その他",
"other_filters": "その他のフィルター",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
"please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー",
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
@@ -380,6 +387,7 @@
"select": "選択",
"select_all": "すべて選択",
"select_filter": "フィルターを選択",
"select_language": "言語を選択",
"select_survey": "フォームを選択",
"select_teams": "チームを選択",
"selected": "選択済み",
@@ -412,7 +420,6 @@
"survey_id": "フォームID",
"survey_languages": "フォームの言語",
"survey_live": "フォーム公開中",
"survey_not_found": "フォームが見つかりません",
"survey_paused": "フォームは一時停止中です。",
"survey_type": "フォームの種類",
"surveys": "フォーム",
@@ -427,13 +434,15 @@
"team_name": "チーム名",
"team_role": "チームの役割",
"teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
"time_to_finish": "所要時間",
"title": "タイトル",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "トライアル期間の残り{count}日",
"trial_expired": "トライアル期間が終了しました",
"trial_one_day_remaining": "トライアル期間の残り1日",
"try_again": "もう一度お試しください",
"type": "種類",
"unknown_survey": "不明なフォーム",
@@ -441,13 +450,13 @@
"update": "更新",
"updated": "更新済み",
"updated_at": "更新日時",
"upgrade_plan": "プランをアップグレード",
"upload": "アップロード",
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
"url": "URL",
"user": "ユーザー",
"user_id": "ユーザーID",
"user_not_found": "ユーザーが見つかりません",
"variable": "変数",
"variable_ids": "変数ID",
"variables": "変数",
@@ -463,14 +472,13 @@
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "ワークフロー",
"workspace": "ワークスペース",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
"workspace_id": "ワークスペースID",
"workspace_name": "ワークスペース名",
"workspace_name_placeholder": "例: Formbricks",
"workspace_not_found": "ワークスペースが見つかりません",
"workspace_permission_not_found": "ワークスペースの権限が見つかりません",
"workspaces": "ワークスペース",
"years": "年",
"you": "あなた",
@@ -655,7 +663,6 @@
"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": "属性を作成",
@@ -846,9 +853,16 @@
"created_by_third_party": "サードパーティによって作成",
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
"endpoint_bad_gateway_error": "不正なゲートウェイ (502): プロキシまたはゲートウェイのエラーにより、サービスに到達できません",
"endpoint_gateway_timeout_error": "ゲートウェイタイムアウト (504): ゲートウェイのタイムアウトにより、サービスに到達できません",
"endpoint_internal_server_error": "内部サーバーエラー (500): サービスで予期しないエラーが発生しました",
"endpoint_method_not_allowed_error": "許可されていないメソッド (405): エンドポイントは存在しますが、POST リクエストを受け付けません",
"endpoint_not_found_error": "見つかりません (404): エンドポイントが存在しません",
"endpoint_pinged": "成功!Webhook に ping できました。",
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
"endpoint_service_unavailable_error": "サービス利用不可 (503): サービスは一時的に停止しています",
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
"no_triggers": "トリガーなし",
"please_check_console": "詳細はコンソールを確認してください",
"please_enter_a_url": "URL を入力してください",
"response_created": "回答作成",
@@ -968,44 +982,80 @@
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
},
"billing": {
"cancelling": "キャンセル中",
"add_payment_method": "支払い方法を追加",
"add_payment_method_to_upgrade_tooltip": "有料プランにアップグレードするには、上記で支払い方法を追加してください",
"billing_interval_toggle": "請求間隔",
"current_plan_badge": "現在のプラン",
"current_plan_cta": "現在のプラン",
"custom_plan_description": "あなたの組織はカスタム請求設定を利用しています。以下の標準プランに切り替えることもできます。",
"custom_plan_title": "カスタムプラン",
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
"manage_subscription": "サブスクリプションを管理",
"keep_current_plan": "現在のプランを継続",
"manage_billing_details": "カード情報と請求書を管理",
"monthly": "月払い",
"most_popular": "人気",
"pending_change_removed": "予定されていたプラン変更を取り消しました。",
"pending_plan_badge": "変更予定",
"pending_plan_change_description": "{{date}}に{{plan}}へ切り替わります。",
"pending_plan_change_title": "プラン変更の予定",
"pending_plan_cta": "変更予定",
"per_month": "/月",
"per_year": "/年",
"plan_change_applied": "プランを更新しました。",
"plan_change_scheduled": "プラン変更を予約しました。",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Hobbyプランの全機能",
"plan_feature_everything_in_pro": "Proプランの全機能",
"plan_hobby": "Hobby",
"plan_hobby_description": "Formbricks Cloudを始める個人や小規模チーム向けのプランです。",
"plan_hobby_feature_responses": "月250回の回答",
"plan_hobby_feature_workspaces": "1ワークスペース",
"plan_pro": "Pro",
"plan_pro_description": "より高い制限、自動化、動的なオーバーエージが必要な成長中のチーム向け。",
"plan_pro_feature_responses": "月2,000回の回答(超過分は従量制)",
"plan_pro_feature_workspaces": "3つのワークスペース",
"plan_scale": "Scale",
"plan_scale_description": "より多くの容量、強力なガバナンス、高いレスポンス量が必要な大規模チーム向け。",
"plan_scale_feature_responses": "月間5,000レスポンス(動的な超過課金)",
"plan_scale_feature_workspaces": "5つのワークスペース",
"plan_selection_description": "Hobby、Pro、Scaleプランを比較して、Formbricksから直接プランを切り替えられます。",
"plan_selection_title": "プランを選択",
"plan_unknown": "不明",
"remove_branding": "ブランディングを削除",
"retry_setup": "セットアップを再試行",
"scale_banner_description": "Scaleプランで、上限の引き上げ、チームでのコラボレーション、高度なセキュリティ機能を利用しましょう。",
"scale_banner_title": "スケールアップの準備はできていますか?",
"scale_feature_api": "APIフルアクセス",
"scale_feature_quota": "クォータ管理",
"scale_feature_spam": "スパム防止機能",
"scale_feature_teams": "チーム&アクセス権限管理",
"select_plan_header_subtitle": "クレジットカード不要、縛りなし。",
"select_plan_header_title": "今すぐプロフェッショナルなブランドフリーのアンケートを配信しよう!",
"select_plan_header_title": "シームレスに統合されたアンケート、100%あなたのブランド。",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Hobbyプランを継続する",
"stripe_setup_incomplete": "請求情報の設定が未完了",
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
"subscription": "サブスクリプション",
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
"switch_at_period_end": "期間終了時に切り替え",
"switch_plan_now": "今すぐプランを切り替え",
"this_includes": "これには以下が含まれます",
"trial_alert_description": "すべての機能へのアクセスを維持するには、支払い方法を追加してください。",
"trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。",
"trial_feature_api_access": "フルAPIアクセスを利用",
"trial_feature_collaboration": "すべてのチーム・コラボレーション機能",
"trial_feature_email_followups": "メールフォローアップの設定",
"trial_feature_quotas": "クォータの管理",
"trial_feature_webhooks": "カスタムWebhookの設定",
"trial_feature_whitelabel": "完全ホワイトラベル対応のアンケート",
"trial_feature_api_access": "APIアクセス",
"trial_feature_attribute_segmentation": "属性ベースのセグメンテーション",
"trial_feature_contact_segment_management": "連絡先とセグメントの管理",
"trial_feature_email_followups": "メールフォローアップ",
"trial_feature_hide_branding": "Formbricksブランディングを非表示",
"trial_feature_mobile_sdks": "iOS & Android SDK",
"trial_feature_respondent_identification": "回答者の識別",
"trial_feature_unlimited_seats": "無制限のシート数",
"trial_feature_webhooks": "カスタムWebhook",
"trial_no_credit_card": "14日間トライアル、クレジットカード不要",
"trial_title": "Pro機能を無料でお試し!",
"trial_payment_method_added_description": "準備完了です!トライアル終了後、Proプランが自動的に継続されます。",
"trial_title": "Formbricks Proを無料で入手しよう!",
"unlimited_responses": "無制限の回答",
"unlimited_workspaces": "無制限ワークスペース",
"upgrade": "アップグレード",
"upgrade_now": "今すぐアップグレード",
"usage_cycle": "Usage cycle",
"used": "使用済み",
"yearly": "年間",
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
"your_plan": "ご利用プラン"
},
"domain": {
@@ -1031,6 +1081,25 @@
"enterprise_features": "エンタープライズ機能",
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
"license_feature_access_control": "アクセス制御(RBAC",
"license_feature_audit_logs": "監査ログ",
"license_feature_contacts": "連絡先とセグメント",
"license_feature_projects": "ワークスペース",
"license_feature_quotas": "クォータ",
"license_feature_remove_branding": "ブランディングの削除",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "スパム保護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "二要素認証",
"license_feature_whitelabel": "ホワイトラベルメール",
"license_features_table_access": "アクセス",
"license_features_table_description": "このインスタンスで現在利用可能なエンタープライズ機能と制限。",
"license_features_table_disabled": "無効",
"license_features_table_enabled": "有効",
"license_features_table_feature": "機能",
"license_features_table_title": "ライセンス機能",
"license_features_table_unlimited": "無制限",
"license_features_table_value": "値",
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
"license_status": "ライセンスステータス",
@@ -1352,7 +1421,6 @@
"custom_hostname": "カスタムホスト名",
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
"delete_anyways": "削除する",
"delete_block": "ブロックを削除",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
"everyone": "全員",
"expand_preview": "プレビューを展開",
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。",
"fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション",
"reverse_order_occasionally": "順序をランダムに逆転",
"reverse_order_occasionally_except_last": "最後以外の順序をランダムに逆転",
"roundness": "丸み",
"roundness_description": "角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
"simple": "シンプル",
"six_points": "6点",
"smiley": "スマイリー",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
"subheading": "サブ見出し",
"subtract": "減算 -",
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
"survey_completed_heading": "フォームが完了しました",
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定",
"survey_placement": "フォームの配置",
"survey_preview": "アンケートプレビュー 👀",
"survey_styling": "フォームのスタイル",
"survey_trigger": "フォームのトリガー",
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
+104 -30
View File
@@ -167,6 +167,7 @@
"connect": "Verbinden",
"connect_formbricks": "Sluit Formbricks aan",
"connected": "Aangesloten",
"contact": "Contact",
"contacts": "Contacten",
"continue": "Doorgaan",
"copied": "Gekopieerd",
@@ -174,6 +175,7 @@
"copy": "Kopiëren",
"copy_code": "Kopieer code",
"copy_link": "Kopieer link",
"copy_to_environment": "Kopiëren naar {{environment}}",
"count_attributes": "{count, plural, one {{count} attribuut} other {{count} attributen}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacten}}",
"count_members": "{count, plural, one {{count} lid} other {{count} leden}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
"elements": "Elementen",
"email": "E-mail",
"ending_card": "Einde kaart",
"enter_url": "URL invoeren",
"enterprise_license": "Enterprise-licentie",
"environment": "Omgeving",
"environment_not_found": "Omgeving niet gevonden",
"environment_notice": "U bevindt zich momenteel in de {environment}-omgeving.",
"error": "Fout",
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Inactieve enquêtes",
"integration": "integratie",
"integrations": "Integraties",
"invalid_date": "Ongeldige datum",
"invalid_date_with_value": "Ongeldige datum: {value}",
"invalid_file_name": "Ongeldige bestandsnaam. Hernoem uw bestand en probeer het opnieuw",
"invalid_file_type": "Ongeldig bestandstype",
"invite": "Uitnodiging",
"invite_them": "Nodig ze uit",
"javascript_required": "JavaScript vereist",
"javascript_required_description": "Formbricks heeft JavaScript nodig om correct te functioneren. Schakel JavaScript in je browserinstellingen in om door te gaan.",
"key": "Sleutel",
"label": "Label",
"language": "Taal",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership": "Lidmaatschap",
"membership_not_found": "Lidmaatschap niet gevonden",
"meta": "Meta",
"metadata": "Metagegevens",
"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!",
@@ -294,6 +300,7 @@
"new": "Nieuw",
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
"next": "Volgende",
"no_actions_found": "Geen acties gevonden",
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
"no_code": "Geen code",
"no_files_uploaded": "Er zijn geen bestanden geüpload",
@@ -319,10 +326,9 @@
"or": "of",
"organization": "Organisatie",
"organization_id": "Organisatie-ID",
"organization_not_found": "Organisatie niet gevonden",
"organization_settings": "Organisatie-instellingen",
"organization_teams_not_found": "Organisatieteams niet gevonden",
"other": "Ander",
"other_filters": "Overige filters",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
"please_upgrade_your_plan": "Upgrade je abonnement",
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
"preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
@@ -380,6 +387,7 @@
"select": "Selecteer",
"select_all": "Selecteer alles",
"select_filter": "Filter selecteren",
"select_language": "Selecteer taal",
"select_survey": "Selecteer Enquête",
"select_teams": "Selecteer teams",
"selected": "Gekozen",
@@ -399,7 +407,7 @@
"something_went_wrong": "Er is iets misgegaan",
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
"sort_by": "Sorteer op",
"start_free_trial": "Gratis proefperiode starten",
"start_free_trial": "Start gratis proefperiode",
"status": "Status",
"step_by_step_manual": "Stap voor stap handleiding",
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
@@ -412,7 +420,6 @@
"survey_id": "Enquête-ID",
"survey_languages": "Enquêtetalen",
"survey_live": "Enquête live",
"survey_not_found": "Enquête niet gevonden",
"survey_paused": "Enquête onderbroken.",
"survey_type": "Enquêtetype",
"surveys": "Enquêtes",
@@ -427,13 +434,15 @@
"team_name": "Teamnaam",
"team_role": "Teamrol",
"teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
"time_to_finish": "Tijd om af te ronden",
"title": "Titel",
"top_left": "Linksboven",
"top_right": "Rechtsboven",
"trial_days_remaining": "{count} dagen over in je proefperiode",
"trial_expired": "Je proefperiode is verlopen",
"trial_one_day_remaining": "1 dag over in je proefperiode",
"try_again": "Probeer het opnieuw",
"type": "Type",
"unknown_survey": "Onbekende enquête",
@@ -441,13 +450,13 @@
"update": "Update",
"updated": "Bijgewerkt",
"updated_at": "Bijgewerkt op",
"upgrade_plan": "Abonnement upgraden",
"upload": "Uploaden",
"upload_failed": "Upload mislukt. Probeer het opnieuw.",
"upload_input_description": "Klik of sleep om bestanden te uploaden.",
"url": "URL",
"user": "Gebruiker",
"user_id": "Gebruikers-ID",
"user_not_found": "Gebruiker niet gevonden",
"variable": "Variabel",
"variable_ids": "Variabele ID's",
"variables": "Variabelen",
@@ -463,14 +472,13 @@
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace": "Werkruimte",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
"workspace_id": "Werkruimte-ID",
"workspace_name": "Werkruimtenaam",
"workspace_name_placeholder": "bijv. Formbricks",
"workspace_not_found": "Werkruimte niet gevonden",
"workspace_permission_not_found": "Werkruimte-machtiging niet gevonden",
"workspaces": "Werkruimtes",
"years": "jaren",
"you": "Jij",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
"attributes_msg_userid_already_exists": "De gebruikers-ID 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",
@@ -846,9 +853,16 @@
"created_by_third_party": "Gemaakt door een derde partij",
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
"endpoint_bad_gateway_error": "Ongeldige gateway (502): Proxy-/gatewayfout, service niet bereikbaar",
"endpoint_gateway_timeout_error": "Gateway-time-out (504): Gateway-time-out, service niet bereikbaar",
"endpoint_internal_server_error": "Interne serverfout (500): De service is een onverwachte fout tegengekomen",
"endpoint_method_not_allowed_error": "Methode niet toegestaan (405): Het endpoint bestaat, maar accepteert geen POST-verzoeken",
"endpoint_not_found_error": "Niet gevonden (404): Het endpoint bestaat niet",
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
"endpoint_pinged_error": "Kan de webhook niet pingen!",
"endpoint_service_unavailable_error": "Service niet beschikbaar (503): De service is tijdelijk niet beschikbaar",
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
"no_triggers": "Geen triggers",
"please_check_console": "Controleer de console voor meer details",
"please_enter_a_url": "Voer een URL in",
"response_created": "Reactie gemaakt",
@@ -968,44 +982,80 @@
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
},
"billing": {
"cancelling": "Bezig met annuleren",
"add_payment_method": "Betaalmethode toevoegen",
"add_payment_method_to_upgrade_tooltip": "Voeg hierboven een betaalmethode toe om te upgraden naar een betaald abonnement",
"billing_interval_toggle": "Factureringsinterval",
"current_plan_badge": "Huidig",
"current_plan_cta": "Huidig abonnement",
"custom_plan_description": "Je organisatie heeft een aangepaste factureringsopzet. Je kunt nog steeds overstappen naar een van de standaard abonnementen hieronder.",
"custom_plan_title": "Aangepast abonnement",
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
"manage_subscription": "Abonnement beheren",
"keep_current_plan": "Huidig abonnement behouden",
"manage_billing_details": "Kaartgegevens en facturen beheren",
"monthly": "Maandelijks",
"most_popular": "Meest populair",
"pending_change_removed": "Geplande abonnementswijziging verwijderd.",
"pending_plan_badge": "Gepland",
"pending_plan_change_description": "Je abonnement wordt op {{date}} omgezet naar {{plan}}.",
"pending_plan_change_title": "Geplande abonnementswijziging",
"pending_plan_cta": "Gepland",
"per_month": "per maand",
"per_year": "per jaar",
"plan_change_applied": "Abonnement succesvol bijgewerkt.",
"plan_change_scheduled": "Abonnementswijziging succesvol ingepland.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Alles in Hobby",
"plan_feature_everything_in_pro": "Alles in Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Voor individuen en kleine teams die aan de slag gaan met Formbricks Cloud.",
"plan_hobby_feature_responses": "250 reacties / maand",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "Voor groeiende teams die hogere limieten, automatiseringen en dynamische overschrijdingen nodig hebben.",
"plan_pro_feature_responses": "2.000 reacties / maand (dynamische overschrijding)",
"plan_pro_feature_workspaces": "3 werkruimtes",
"plan_scale": "Scale",
"plan_scale_description": "Voor grotere teams die meer capaciteit, beter bestuur en een hoger responsvolume nodig hebben.",
"plan_scale_feature_responses": "5.000 reacties / maand (dynamische overbrugging)",
"plan_scale_feature_workspaces": "5 werkruimtes",
"plan_selection_description": "Vergelijk Hobby, Pro en Scale, en schakel direct vanuit Formbricks tussen abonnementen.",
"plan_selection_title": "Kies je abonnement",
"plan_unknown": "Onbekend",
"remove_branding": "Branding verwijderen",
"retry_setup": "Opnieuw proberen",
"scale_banner_description": "Ontgrendel hogere limieten, team samenwerking, en geavanceerde beveiligingsfuncties met het Scale-abonnement.",
"scale_banner_title": "Klaar om op te schalen?",
"scale_feature_api": "Volledige API-toegang",
"scale_feature_quota": "Quotabeheer",
"scale_feature_spam": "Spam-beveiliging",
"scale_feature_teams": "Teams & toegangsrollen",
"select_plan_header_subtitle": "Geen creditcard vereist, geen verplichtingen.",
"select_plan_header_title": "Verstuur vandaag nog professionele, ongemerkte enquêtes!",
"select_plan_header_title": "Naadloos geïntegreerde enquêtes, 100% jouw merk.",
"status_trialing": "Proefperiode",
"stay_on_hobby_plan": "Ik wil op het Hobby-abonnement blijven",
"stripe_setup_incomplete": "Facturatie-instelling niet voltooid",
"stripe_setup_incomplete_description": "Het instellen van de facturatie is niet gelukt. Probeer het opnieuw om je abonnement te activeren.",
"subscription": "Abonnement",
"subscription_description": "Beheer je abonnement en houd je gebruik bij",
"switch_at_period_end": "Schakel aan het einde van de periode",
"switch_plan_now": "Schakel nu van abonnement",
"this_includes": "Dit omvat",
"trial_alert_description": "Voeg een betaalmethode toe om toegang te houden tot alle functies.",
"trial_already_used": "Er is al een gratis proefperiode gebruikt voor dit e-mailadres. Upgrade in plaats daarvan naar een betaald abonnement.",
"trial_feature_api_access": "Krijg volledige API-toegang",
"trial_feature_collaboration": "Alle team- en samenwerkingsfuncties",
"trial_feature_email_followups": "E-mail follow-ups instellen",
"trial_feature_quotas": "Quota's beheren",
"trial_feature_webhooks": "Aangepaste webhooks instellen",
"trial_feature_whitelabel": "Volledig white-label enquêtes",
"trial_feature_api_access": "API-toegang",
"trial_feature_attribute_segmentation": "Segmentatie op basis van attributen",
"trial_feature_contact_segment_management": "Contact- en segmentbeheer",
"trial_feature_email_followups": "E-mail follow-ups",
"trial_feature_hide_branding": "Verberg Formbricks-branding",
"trial_feature_mobile_sdks": "iOS- en Android-SDK's",
"trial_feature_respondent_identification": "Identificatie van respondenten",
"trial_feature_unlimited_seats": "Onbeperkt aantal gebruikers",
"trial_feature_webhooks": "Aangepaste webhooks",
"trial_no_credit_card": "14 dagen proefperiode, geen creditcard vereist",
"trial_title": "Probeer Pro-functies gratis!",
"trial_payment_method_added_description": "Je bent helemaal klaar! Je Pro-abonnement wordt automatisch voortgezet na afloop van de proefperiode.",
"trial_title": "Krijg Formbricks Pro gratis!",
"unlimited_responses": "Onbeperkte reacties",
"unlimited_workspaces": "Onbeperkt werkruimtes",
"upgrade": "Upgraden",
"upgrade_now": "Nu upgraden",
"usage_cycle": "Usage cycle",
"used": "gebruikt",
"yearly": "Jaarlijks",
"yearly_checkout_unavailable": "Jaarlijkse checkout is nog niet beschikbaar. Voeg eerst een betaalmethode toe bij een maandelijks abonnement of neem contact op met support.",
"your_plan": "Jouw abonnement"
},
"domain": {
@@ -1031,6 +1081,25 @@
"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_feature_access_control": "Toegangscontrole (RBAC)",
"license_feature_audit_logs": "Auditlogboeken",
"license_feature_contacts": "Contacten & Segmenten",
"license_feature_projects": "Werkruimtes",
"license_feature_quotas": "Quota's",
"license_feature_remove_branding": "Branding verwijderen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spambescherming",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tweefactorauthenticatie",
"license_feature_whitelabel": "Whitelabel-e-mails",
"license_features_table_access": "Toegang",
"license_features_table_description": "Enterprise-functies en limieten die momenteel beschikbaar zijn voor deze instantie.",
"license_features_table_disabled": "Uitgeschakeld",
"license_features_table_enabled": "Ingeschakeld",
"license_features_table_feature": "Functie",
"license_features_table_title": "Gelicentieerde Functies",
"license_features_table_unlimited": "Onbeperkt",
"license_features_table_value": "Waarde",
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
"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",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Aangepaste hostnaam",
"customize_survey_logo": "Pas het enquêtelogo aan",
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
"date_format": "Datumformaat",
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
"delete_anyways": "Toch verwijderen",
"delete_block": "Blok verwijderen",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Fout bij het opslaan van wijzigingen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
"everyone": "Iedereen",
"expand_preview": "Voorbeeld uitvouwen",
"external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.",
"fallback_missing": "Terugval ontbreekt",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).",
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
"response_options": "Reactieopties",
"reverse_order_occasionally": "Volgorde af en toe omkeren",
"reverse_order_occasionally_except_last": "Volgorde af en toe omkeren behalve laatste",
"roundness": "Rondheid",
"roundness_description": "Bepaalt hoe afgerond de hoeken zijn.",
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Toon onderzoek maximaal",
"show_survey_to_users": "Enquête tonen aan % van de gebruikers",
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
"shrink_preview": "Voorbeeld invouwen",
"simple": "Eenvoudig",
"six_points": "6 punten",
"smiley": "Smiley",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
"subheading": "Ondertitel",
"subtract": "Aftrekken -",
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
"survey_completed_heading": "Enquête voltooid",
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen",
"survey_placement": "Enquête plaatsing",
"survey_preview": "Enquêtevoorbeeld 👀",
"survey_styling": "Vorm styling",
"survey_trigger": "Enquêtetrigger",
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
@@ -3012,7 +3086,7 @@
"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_question_open_text_headline": "Wil je nog iets delen?",
"preview_survey_question_open_text_headline": "Wilt u nog iets anders delen?",
"preview_survey_question_open_text_placeholder": "Typ hier je antwoord...",
"preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.",
"preview_survey_welcome_card_headline": "Welkom!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
"coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat je wilt toevoegen?",
"follow_up_label": "Is er nog iets dat u wilt toevoegen?",
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
"generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?",
+104 -30
View File
@@ -167,6 +167,7 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "conectado",
"contact": "Contato",
"contacts": "Contatos",
"continue": "Continuar",
"copied": "Copiado",
@@ -174,6 +175,7 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"copy_to_environment": "Copiar para {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contato} other {{count} contatos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Pesquisas inativas",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
"invalid_date_with_value": "Data inválida: {value}",
"invalid_file_name": "Nome de arquivo inválido, por favor renomeie seu arquivo e tente novamente",
"invalid_file_type": "Tipo de arquivo inválido",
"invite": "convidar",
"invite_them": "Convida eles",
"javascript_required": "JavaScript Necessário",
"javascript_required_description": "O Formbricks precisa do JavaScript para funcionar corretamente. Por favor, ative o JavaScript nas configurações do seu navegador para continuar.",
"key": "Chave",
"label": "Etiqueta",
"language": "Língua",
@@ -280,7 +284,9 @@
"marketing": "marketing",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership": "Associação",
"membership_not_found": "Assinatura não encontrada",
"meta": "Meta",
"metadata": "metadados",
"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!",
@@ -294,6 +300,7 @@
"new": "Novo",
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
"next": "Próximo",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado",
@@ -319,10 +326,9 @@
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipes da organização não encontradas",
"other": "outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Prévia",
"preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade",
@@ -380,6 +387,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Pesquisa",
"select_teams": "Selecionar times",
"selected": "Selecionado",
@@ -399,7 +407,7 @@
"something_went_wrong": "Algo deu errado",
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
"sort_by": "Ordenar por",
"start_free_trial": "Iniciar Teste Grátis",
"start_free_trial": "Iniciar teste gratuito",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
@@ -412,7 +420,6 @@
"survey_id": "ID da Pesquisa",
"survey_languages": "Idiomas da Pesquisa",
"survey_live": "Pesquisa ao vivo",
"survey_not_found": "Pesquisa não encontrada",
"survey_paused": "Pesquisa pausada.",
"survey_type": "Tipo de Pesquisa",
"surveys": "Pesquisas",
@@ -427,13 +434,15 @@
"team_name": "Nome da equipe",
"team_role": "Função na equipe",
"teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
"time_to_finish": "Hora de terminar",
"title": "Título",
"top_left": "Canto superior esquerdo",
"top_right": "Canto Superior Direito",
"trial_days_remaining": "{count} dias restantes no seu período de teste",
"trial_expired": "Seu período de teste expirou",
"trial_one_day_remaining": "1 dia restante no seu período de teste",
"try_again": "Tenta de novo",
"type": "Tipo",
"unknown_survey": "Pesquisa desconhecida",
@@ -441,13 +450,13 @@
"update": "atualizar",
"updated": "atualizado",
"updated_at": "Atualizado em",
"upgrade_plan": "Fazer upgrade do plano",
"upload": "Enviar",
"upload_failed": "Falha no upload. Tente novamente.",
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
"url": "URL",
"user": "Usuário",
"user_id": "ID do usuário",
"user_not_found": "Usuário não encontrado",
"variable": "variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
@@ -463,14 +472,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_name_placeholder": "ex: Formbricks",
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de usuário 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",
@@ -846,9 +853,16 @@
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway esgotado (504): Tempo limite do gateway esgotado, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita solicitações POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
"endpoint_pinged_error": "Não consegui pingar o webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
"no_triggers": "Nenhum Gatilho",
"please_check_console": "Por favor, verifica o console para mais detalhes",
"please_enter_a_url": "Por favor, insira uma URL",
"response_created": "Resposta Criada",
@@ -968,44 +982,80 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"cancelling": "Cancelando",
"add_payment_method": "Adicionar forma de pagamento",
"add_payment_method_to_upgrade_tooltip": "Por favor, adicione uma forma de pagamento acima para fazer upgrade para um plano pago",
"billing_interval_toggle": "Intervalo de cobrança",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "Sua organização está em uma configuração de cobrança personalizada. Você ainda pode mudar para um dos planos padrão abaixo.",
"custom_plan_title": "Plano personalizado",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
"manage_subscription": "Gerenciar assinatura",
"keep_current_plan": "Manter plano atual",
"manage_billing_details": "Gerenciar detalhes do cartão e faturas",
"monthly": "Mensal",
"most_popular": "Mais popular",
"pending_change_removed": "Mudança de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "Seu plano mudará para {{plan}} em {{date}}.",
"pending_plan_change_title": "Mudança de plano agendada",
"pending_plan_cta": "Agendado",
"per_month": "por mês",
"per_year": "por ano",
"plan_change_applied": "Plano atualizado com sucesso.",
"plan_change_scheduled": "Mudança de plano agendada com sucesso.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tudo do Hobby",
"plan_feature_everything_in_pro": "Tudo do Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Para indivíduos e pequenas equipes começando com o Formbricks Cloud.",
"plan_hobby_feature_responses": "250 respostas / mês",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "Para equipes em crescimento que precisam de limites maiores, automações e excedentes dinâmicos.",
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
"plan_pro_feature_workspaces": "3 espaços de trabalho",
"plan_scale": "Scale",
"plan_scale_description": "Para equipes maiores que precisam de mais capacidade, governança mais forte e maior volume de respostas.",
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
"plan_scale_feature_workspaces": "5 espaços de trabalho",
"plan_selection_description": "Compare os planos Hobby, Pro e Scale e mude de plano diretamente no Formbricks.",
"plan_selection_title": "Escolha seu plano",
"plan_unknown": "desconhecido",
"remove_branding": "Remover Marca",
"retry_setup": "Tentar novamente",
"scale_banner_description": "Desbloqueie limites maiores, colaboração em equipe e recursos avançados de segurança com o plano Scale.",
"scale_banner_title": "Pronto para expandir?",
"scale_feature_api": "Acesso completo à API",
"scale_feature_quota": "Gestão de cota",
"scale_feature_spam": "Proteção contra spam",
"scale_feature_teams": "Equipes e papéis de acesso",
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
"select_plan_header_title": "Envie pesquisas profissionais e sem marca hoje mesmo!",
"select_plan_header_title": "Pesquisas perfeitamente integradas, 100% sua marca.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Quero continuar no plano Hobby",
"stripe_setup_incomplete": "Configuração de cobrança incompleta",
"stripe_setup_incomplete_description": "A configuração de cobrança não foi concluída com sucesso. Tente novamente para ativar sua assinatura.",
"subscription": "Assinatura",
"subscription_description": "Gerencie seu plano de assinatura e acompanhe seu uso",
"switch_at_period_end": "Mudar no final do período",
"switch_plan_now": "Mudar de plano agora",
"this_includes": "Isso inclui",
"trial_alert_description": "Adicione uma forma de pagamento para manter o acesso a todos os recursos.",
"trial_already_used": "Um período de teste gratuito já foi usado para este endereço de e-mail. Por favor, faça upgrade para um plano pago.",
"trial_feature_api_access": "Obtenha acesso completo à API",
"trial_feature_collaboration": "Todos os recursos de equipe e colaboração",
"trial_feature_email_followups": "Configure acompanhamentos por e-mail",
"trial_feature_quotas": "Gerencie cotas",
"trial_feature_webhooks": "Configure webhooks personalizados",
"trial_feature_whitelabel": "Pesquisas totalmente personalizadas",
"trial_feature_api_access": "Acesso à API",
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
"trial_feature_contact_segment_management": "Gerenciamento de Contatos e Segmentos",
"trial_feature_email_followups": "Follow-ups por E-mail",
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
"trial_feature_respondent_identification": "Identificação de Respondentes",
"trial_feature_unlimited_seats": "Assentos Ilimitados",
"trial_feature_webhooks": "Webhooks Personalizados",
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
"trial_title": "Experimente os recursos Pro gratuitamente!",
"trial_payment_method_added_description": "Tudo pronto! Seu plano Pro continuará automaticamente após o término do período de teste.",
"trial_title": "Ganhe o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
"used": "usado",
"yearly": "Anual",
"yearly_checkout_unavailable": "O checkout anual ainda não está disponível. Adicione um método de pagamento em um plano mensal primeiro ou entre em contato com o suporte.",
"your_plan": "Seu plano"
},
"domain": {
@@ -1031,6 +1081,25 @@
"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_feature_access_control": "Controle de acesso (RBAC)",
"license_feature_audit_logs": "Logs de auditoria",
"license_feature_contacts": "Contatos e Segmentos",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Cotas",
"license_feature_remove_branding": "Remover identidade visual",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails white-label",
"license_features_table_access": "Acesso",
"license_features_table_description": "Recursos empresariais e limites disponíveis atualmente para esta instância.",
"license_features_table_disabled": "Desabilitado",
"license_features_table_enabled": "Habilitado",
"license_features_table_feature": "Recurso",
"license_features_table_title": "Recursos Licenciados",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
"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",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Hostname personalizado",
"customize_survey_logo": "Personalizar o logo da pesquisa",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
"delete_anyways": "Excluir mesmo assim",
"delete_block": "Excluir bloco",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
"everyone": "Todo mundo",
"expand_preview": "Expandir prévia",
"external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Circularidade",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Mostrar no máximo",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"shrink_preview": "Recolher prévia",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
"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",
"survey_placement": "Posicionamento da Pesquisa",
"survey_preview": "Prévia da pesquisa 👀",
"survey_styling": "Estilização de Formulários",
"survey_trigger": "Gatilho de Pesquisa",
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
@@ -3012,7 +3086,7 @@
"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_question_open_text_headline": "Tem mais alguma coisa que você gostaria de compartilhar?",
"preview_survey_question_open_text_headline": "Há algo mais que você gostaria de compartilhar?",
"preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...",
"preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de adicionar?",
"follow_up_label": "Há algo mais que você gostaria de acrescentar?",
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?",
+103 -29
View File
@@ -167,6 +167,7 @@
"connect": "Conectar",
"connect_formbricks": "Ligar Formbricks",
"connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
@@ -174,6 +175,7 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"copy_to_environment": "Copiar para {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Inquéritos inativos",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
"invalid_date_with_value": "Data inválida: {value}",
"invalid_file_name": "Nome de ficheiro inválido, por favor renomeie o seu ficheiro e tente novamente",
"invalid_file_type": "Tipo de ficheiro inválido",
"invite": "Convidar",
"invite_them": "Convide-os",
"javascript_required": "JavaScript Necessário",
"javascript_required_description": "O Formbricks necessita de JavaScript para funcionar corretamente. Por favor, ativa o JavaScript nas definições do teu navegador para continuar.",
"key": "Chave",
"label": "Etiqueta",
"language": "Idioma",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership": "Subscrição",
"membership_not_found": "Associação não encontrada",
"meta": "Meta",
"metadata": "Metadados",
"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ã!",
@@ -294,6 +300,7 @@
"new": "Novo",
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
"next": "Seguinte",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado",
@@ -319,10 +326,9 @@
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipas da organização não encontradas",
"other": "Outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade",
@@ -380,6 +387,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Inquérito",
"select_teams": "Selecionar equipas",
"selected": "Selecionado",
@@ -399,7 +407,7 @@
"something_went_wrong": "Algo correu mal",
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
"sort_by": "Ordem",
"start_free_trial": "Iniciar Teste Grátis",
"start_free_trial": "Iniciar teste gratuito",
"status": "Estado",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
@@ -412,7 +420,6 @@
"survey_id": "ID do Inquérito",
"survey_languages": "Idiomas da Pesquisa",
"survey_live": "Inquérito ao vivo",
"survey_not_found": "Inquérito não encontrado",
"survey_paused": "Inquérito pausado.",
"survey_type": "Tipo de Inquérito",
"surveys": "Inquéritos",
@@ -427,13 +434,15 @@
"team_name": "Nome da equipa",
"team_role": "Função na equipa",
"teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
"time_to_finish": "Tempo para concluir",
"title": "Título",
"top_left": "Superior Esquerdo",
"top_right": "Superior Direito",
"trial_days_remaining": "{count} dias restantes no teu período de teste",
"trial_expired": "O teu período de teste expirou",
"trial_one_day_remaining": "1 dia restante no teu período de teste",
"try_again": "Tente novamente",
"type": "Tipo",
"unknown_survey": "Inquérito desconhecido",
@@ -441,13 +450,13 @@
"update": "Atualizar",
"updated": "Atualizado",
"updated_at": "Atualizado em",
"upgrade_plan": "Fazer upgrade do plano",
"upload": "Carregar",
"upload_failed": "Falha no carregamento. Por favor, tente novamente.",
"upload_input_description": "Clique ou arraste para carregar ficheiros.",
"url": "URL",
"user": "Utilizador",
"user_id": "ID do Utilizador",
"user_not_found": "Utilizador não encontrado",
"variable": "Variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
@@ -463,14 +472,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize inquéritos em projetos para melhor controlo de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_name_placeholder": "ex. Formbricks",
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de utilizador 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",
@@ -846,9 +853,16 @@
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway excedido (504): Tempo limite do gateway excedido, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita pedidos POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
"no_triggers": "Sem Acionadores",
"please_check_console": "Por favor, verifique a consola para mais detalhes",
"please_enter_a_url": "Por favor, insira um URL",
"response_created": "Resposta Criada",
@@ -968,44 +982,80 @@
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"cancelling": "A cancelar",
"add_payment_method": "Adicionar método de pagamento",
"add_payment_method_to_upgrade_tooltip": "Por favor, adiciona um método de pagamento acima para fazeres upgrade para um plano pago",
"billing_interval_toggle": "Intervalo de faturação",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "A tua organização tem uma configuração de faturação personalizada. Podes mudar para um dos planos padrão abaixo.",
"custom_plan_title": "Plano personalizado",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
"manage_subscription": "Gerir subscrição",
"keep_current_plan": "Manter plano atual",
"manage_billing_details": "Gerir detalhes do cartão e faturas",
"monthly": "Mensal",
"most_popular": "Mais popular",
"pending_change_removed": "Alteração de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "O teu plano mudará para {{plan}} em {{date}}.",
"pending_plan_change_title": "Alteração de plano agendada",
"pending_plan_cta": "Agendado",
"per_month": "por mês",
"per_year": "por ano",
"plan_change_applied": "Plano atualizado com sucesso.",
"plan_change_scheduled": "Alteração de plano agendada com sucesso.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tudo no Hobby",
"plan_feature_everything_in_pro": "Tudo no Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Para indivíduos e pequenas equipas que estão a começar com o Formbricks Cloud.",
"plan_hobby_feature_responses": "250 respostas / mês",
"plan_hobby_feature_workspaces": "1 workspace",
"plan_pro": "Pro",
"plan_pro_description": "Para equipas em crescimento que precisam de limites mais elevados, automatizações e excedentes dinâmicos.",
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
"plan_pro_feature_workspaces": "3 áreas de trabalho",
"plan_scale": "Scale",
"plan_scale_description": "Para equipas maiores que precisam de mais capacidade, maior controlo e um volume de respostas mais elevado.",
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
"plan_scale_feature_workspaces": "5 áreas de trabalho",
"plan_selection_description": "Compara Hobby, Pro e Scale, e depois muda de plano diretamente no Formbricks.",
"plan_selection_title": "Escolhe o teu plano",
"plan_unknown": "Desconhecido",
"remove_branding": "Possibilidade de remover o logo",
"retry_setup": "Tentar novamente configurar",
"scale_banner_description": "Desbloqueia limites mais elevados, colaboração em equipa e funcionalidades avançadas de segurança com o plano Scale.",
"scale_banner_title": "Preparado para aumentar a escala?",
"scale_feature_api": "Acesso total à API",
"scale_feature_quota": "Gestão de quotas",
"scale_feature_spam": "Proteção contra spam",
"scale_feature_teams": "Equipas e papéis de acesso",
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
"select_plan_header_title": "Envia inquéritos profissionais sem marca hoje!",
"select_plan_header_title": "Inquéritos perfeitamente integrados, 100% da tua marca.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Quero manter o plano Hobby",
"stripe_setup_incomplete": "Configuração de faturação incompleta",
"stripe_setup_incomplete_description": "A configuração de faturação não foi concluída com sucesso. Por favor, tenta novamente para ativar a tua subscrição.",
"subscription": "Subscrição",
"subscription_description": "Gere o teu plano de subscrição e acompanha a tua utilização",
"switch_at_period_end": "Mudar no fim do período",
"switch_plan_now": "Mudar de plano agora",
"this_includes": "Isto inclui",
"trial_alert_description": "Adiciona um método de pagamento para manteres acesso a todas as funcionalidades.",
"trial_already_used": "Já foi utilizado um período de teste gratuito para este endereço de email. Por favor, atualiza para um plano pago.",
"trial_feature_api_access": "Obtém acesso completo à API",
"trial_feature_collaboration": "Todas as funcionalidades de equipa e colaboração",
"trial_feature_email_followups": "Configura acompanhamentos por email",
"trial_feature_quotas": "Gere quotas",
"trial_feature_webhooks": "Configura webhooks personalizados",
"trial_feature_whitelabel": "Inquéritos totalmente personalizados",
"trial_feature_api_access": "Acesso à API",
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
"trial_feature_contact_segment_management": "Gestão de Contactos e Segmentos",
"trial_feature_email_followups": "Seguimentos por E-mail",
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
"trial_feature_respondent_identification": "Identificação de Inquiridos",
"trial_feature_unlimited_seats": "Lugares Ilimitados",
"trial_feature_webhooks": "Webhooks Personalizados",
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
"trial_title": "Experimenta as funcionalidades Pro gratuitamente!",
"trial_payment_method_added_description": "Está tudo pronto! O teu plano Pro continuará automaticamente após o fim do período experimental.",
"trial_title": "Obtém o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
"used": "utilizado",
"yearly": "Anual",
"yearly_checkout_unavailable": "O pagamento anual ainda não está disponível. Adiciona primeiro um método de pagamento num plano mensal ou contacta o suporte.",
"your_plan": "O teu plano"
},
"domain": {
@@ -1031,6 +1081,25 @@
"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_feature_access_control": "Controlo de acesso (RBAC)",
"license_feature_audit_logs": "Registos de auditoria",
"license_feature_contacts": "Contactos e Segmentos",
"license_feature_projects": "Áreas de trabalho",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remover marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails personalizados",
"license_features_table_access": "Acesso",
"license_features_table_description": "Funcionalidades e limites empresariais atualmente disponíveis para esta instância.",
"license_features_table_disabled": "Desativado",
"license_features_table_enabled": "Ativado",
"license_features_table_feature": "Funcionalidade",
"license_features_table_title": "Funcionalidades Licenciadas",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
"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",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Nome do host personalizado",
"customize_survey_logo": "Personalizar o logótipo do inquérito",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
"delete_anyways": "Eliminar mesmo assim",
"delete_block": "Eliminar bloco",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
"everyone": "Todos",
"expand_preview": "Expandir pré-visualização",
"external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Arredondamento",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Mostrar inquérito máximo de",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"shrink_preview": "Reduzir pré-visualização",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Estilo definido para estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
"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",
"survey_placement": "Colocação do Inquérito",
"survey_preview": "Pré-visualização do questionário 👀",
"survey_styling": "Estilo do formulário",
"survey_trigger": "Desencadeador de Inquérito",
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
@@ -3012,7 +3086,7 @@
"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_question_open_text_headline": "Mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_headline": "Há mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...",
"preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
+104 -30
View File
@@ -167,6 +167,7 @@
"connect": "Conectează",
"connect_formbricks": "Conectează Formbricks",
"connected": "Conectat",
"contact": "Contact",
"contacts": "Contacte",
"continue": "Continuă",
"copied": "Copiat",
@@ -174,6 +175,7 @@
"copy": "Copiază",
"copy_code": "Copiază codul",
"copy_link": "Copiază legătura",
"copy_to_environment": "Copiază în {{environment}}",
"count_attributes": "{count, plural, one {{count} atribut} few {{count} atribute} other {{count} de atribute}}",
"count_contacts": "{count, plural, one {{count} contact} few {{count} contacte} other {{count} de contacte}}",
"count_members": "{count, plural, one {{count} membru} few {{count} membri} other {{count} de membri}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
"elements": "Elemente",
"email": "Email",
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere",
"environment": "Mediu",
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
"error": "Eroare",
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Sondaje inactive",
"integration": "integrare",
"integrations": "Integrări",
"invalid_date": "Dată invalidă",
"invalid_date_with_value": "Dată invalidă: {value}",
"invalid_file_name": "Nume de fișier invalid, vă rugăm să redenumiți fișierul și să încercați din nou",
"invalid_file_type": "Tip de fișier nevalid",
"invite": "Invită",
"invite_them": "Invită-i",
"javascript_required": "JavaScript necesar",
"javascript_required_description": "Formbricks necesită JavaScript pentru a funcționa corect. Te rugăm să activezi JavaScript în setările browserului tău pentru a continua.",
"key": "Cheie",
"label": "Etichetă",
"language": "Limba",
@@ -280,7 +284,9 @@
"marketing": "Marketing",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership": "Abonament",
"membership_not_found": "Apartenența nu a fost găsită",
"meta": "Meta",
"metadata": "Metadate",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
@@ -294,6 +300,7 @@
"new": "Nou",
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
"next": "Următorul",
"no_actions_found": "Nu au fost găsite acțiuni",
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
"no_code": "Fără Cod",
"no_files_uploaded": "Nu au fost încărcate fișiere",
@@ -319,10 +326,9 @@
"or": "sau",
"organization": "Organizație",
"organization_id": "ID Organizație",
"organization_not_found": "Organizația nu a fost găsită",
"organization_settings": "Setări Organizație",
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
"other": "Altele",
"other_filters": "Alte Filtre",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"powered_by_formbricks": "Oferit de Formbricks",
"preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
@@ -380,6 +387,7 @@
"select": "Selectați",
"select_all": "Selectați toate",
"select_filter": "Selectați filtrul",
"select_language": "Selectează limba",
"select_survey": "Selectați chestionar",
"select_teams": "Selectați echipele",
"selected": "Selectat",
@@ -399,7 +407,7 @@
"something_went_wrong": "Ceva nu a mers bine",
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
"sort_by": "Sortare după",
"start_free_trial": "Începe perioada de testare gratuită",
"start_free_trial": "Începe perioada de probă gratuită",
"status": "Stare",
"step_by_step_manual": "Manual pas cu pas",
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
@@ -412,7 +420,6 @@
"survey_id": "ID Chestionar",
"survey_languages": "Limbi chestionar",
"survey_live": "Chestionar activ",
"survey_not_found": "Sondajul nu a fost găsit",
"survey_paused": "Chestionar oprit.",
"survey_type": "Tip Chestionar",
"surveys": "Sondaje",
@@ -427,13 +434,15 @@
"team_name": "Nume echipă",
"team_role": "Rol în echipă",
"teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
"time_to_finish": "Timp până la finalizare",
"title": "Titlu",
"top_left": "Stânga Sus",
"top_right": "Dreapta Sus",
"trial_days_remaining": "{count} zile rămase în perioada ta de probă",
"trial_expired": "Perioada ta de probă a expirat",
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
"try_again": "Încearcă din nou",
"type": "Tip",
"unknown_survey": "Chestionar necunoscut",
@@ -441,13 +450,13 @@
"update": "Actualizare",
"updated": "Actualizat",
"updated_at": "Actualizat la",
"upgrade_plan": "Actualizează planul",
"upload": "Încărcați",
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.",
"upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
"url": "URL",
"user": "Utilizator",
"user_id": "ID Utilizator",
"user_not_found": "Utilizatorul nu a fost găsit",
"variable": "Variabilă",
"variable_ids": "ID-uri variabile",
"variables": "Variante",
@@ -463,14 +472,13 @@
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace": "Spațiu de lucru",
"workspace_configuration": "Configurare workspace",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
"workspace_id": "ID workspace",
"workspace_name": "Nume workspace",
"workspace_name_placeholder": "ex: Formbricks",
"workspace_not_found": "Workspace-ul nu a fost găsit",
"workspace_permission_not_found": "Permisiunea pentru workspace nu a fost găsită",
"workspaces": "Workspaces",
"years": "ani",
"you": "Tu",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
"contact_deleted_successfully": "Contact șters cu succes",
"contact_not_found": "Nu a fost găsit niciun contact",
"contacts_table_refresh": "Reîmprospătare contacte",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"create_attribute": "Creează atribut",
@@ -846,9 +853,16 @@
"created_by_third_party": "Creat de o Parte Terță",
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
"endpoint_bad_gateway_error": "Gateway invalid (502): Eroare de proxy/gateway, serviciul nu este accesibil",
"endpoint_gateway_timeout_error": "Timp de așteptare gateway depășit (504): Timpul de așteptare al gateway-ului a fost depășit, serviciul nu este accesibil",
"endpoint_internal_server_error": "Eroare internă de server (500): Serviciul a întâmpinat o eroare neașteptată",
"endpoint_method_not_allowed_error": "Metodă nepermisă (405): Endpointul există, dar nu acceptă cereri POST",
"endpoint_not_found_error": "Negăsit (404): Endpointul nu există",
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
"endpoint_service_unavailable_error": "Serviciu indisponibil (503): Serviciul este temporar indisponibil",
"learn_to_verify": "Află cum să verifici semnăturile webhook",
"no_triggers": "Fără declanșatori",
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
"please_enter_a_url": "Vă rugăm să introduceți un URL",
"response_created": "Răspuns creat",
@@ -968,44 +982,80 @@
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
},
"billing": {
"cancelling": "Anulare în curs",
"add_payment_method": "Adaugă o metodă de plată",
"add_payment_method_to_upgrade_tooltip": "Te rugăm să adaugi o metodă de plată mai sus pentru a trece la un plan plătit",
"billing_interval_toggle": "Interval de facturare",
"current_plan_badge": "Curent",
"current_plan_cta": "Plan curent",
"custom_plan_description": "Organizația ta folosește o configurație de facturare personalizată. Poți totuși să treci la unul dintre planurile standard de mai jos.",
"custom_plan_title": "Plan personalizat",
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.",
"manage_subscription": "Gestionează abonamentul",
"keep_current_plan": "Păstrează planul curent",
"manage_billing_details": "Gestionează detaliile cardului și facturile",
"monthly": "Lunar",
"most_popular": "Cel mai popular",
"pending_change_removed": "Schimbarea de plan programată a fost anulată.",
"pending_plan_badge": "Programat",
"pending_plan_change_description": "Planul tău va trece la {{plan}} pe {{date}}.",
"pending_plan_change_title": "Schimbare de plan programată",
"pending_plan_cta": "Programat",
"per_month": "pe lună",
"per_year": "pe an",
"plan_change_applied": "Planul a fost actualizat cu succes.",
"plan_change_scheduled": "Schimbarea de plan a fost programată cu succes.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tot ce include Hobby",
"plan_feature_everything_in_pro": "Tot ce include Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "Pentru persoane individuale și echipe mici care încep să folosească Formbricks Cloud.",
"plan_hobby_feature_responses": "250 de răspunsuri / lună",
"plan_hobby_feature_workspaces": "1 spațiu de lucru",
"plan_pro": "Pro",
"plan_pro_description": "Pentru echipele în creștere care au nevoie de limite mai mari, automatizări și depășiri dinamice.",
"plan_pro_feature_responses": "2.000 de răspunsuri / lună (depășire dinamică)",
"plan_pro_feature_workspaces": "3 spații de lucru",
"plan_scale": "Scală",
"plan_scale_description": "Pentru echipe mai mari care au nevoie de mai multă capacitate, guvernanță mai puternică și volum mai mare de răspunsuri.",
"plan_scale_feature_responses": "5.000 răspunsuri / lună (suprataxă dinamică)",
"plan_scale_feature_workspaces": "5 spații de lucru",
"plan_selection_description": "Compară Hobby, Pro și Scale, apoi schimbă planurile direct din Formbricks.",
"plan_selection_title": "Alege-ți planul",
"plan_unknown": "Necunoscut",
"remove_branding": "Eliminare branding",
"retry_setup": "Încearcă din nou configurarea",
"scale_banner_description": "Deblochează limite mai mari, colaborare în echipă și funcții avansate de securitate cu pachetul Scale.",
"scale_banner_title": "Gata să treci la nivelul următor?",
"scale_feature_api": "Acces complet API",
"scale_feature_quota": "Gestionare cote",
"scale_feature_spam": "Protecție anti-spam",
"scale_feature_teams": "Echipe și roluri de acces",
"select_plan_header_subtitle": "Nu este necesar card de credit, fără obligații.",
"select_plan_header_title": "Lansează chestionare profesionale, fără branding, astăzi!",
"select_plan_header_title": "Sondaje integrate perfect, 100% brandul tău.",
"status_trialing": "Trial",
"stay_on_hobby_plan": "Vreau să rămân pe planul Hobby",
"stripe_setup_incomplete": "Configurare facturare incompletă",
"stripe_setup_incomplete_description": "Configurarea facturării nu a fost finalizată cu succes. Încearcă din nou pentru a activa abonamentul.",
"subscription": "Abonament",
"subscription_description": "Gestionează-ți abonamentul și monitorizează-ți consumul",
"switch_at_period_end": "Schimbă la sfârșitul perioadei",
"switch_plan_now": "Schimbă planul acum",
"this_includes": "Aceasta include",
"trial_alert_description": "Adaugă o metodă de plată pentru a păstra accesul la toate funcționalitățile.",
"trial_already_used": "O perioadă de probă gratuită a fost deja utilizată pentru această adresă de email. Te rugăm să treci la un plan plătit în schimb.",
"trial_feature_api_access": "Obține acces complet la API",
"trial_feature_collaboration": "Toate funcțiile de echipă și colaborare",
"trial_feature_email_followups": "Configurează urmăriri prin email",
"trial_feature_quotas": "Gestionează cotele",
"trial_feature_webhooks": "Configurează webhook-uri personalizate",
"trial_feature_whitelabel": "Chestionare complet personalizate (white-label)",
"trial_feature_api_access": "Acces API",
"trial_feature_attribute_segmentation": "Segmentare bazată pe atribute",
"trial_feature_contact_segment_management": "Gestionare contacte și segmente",
"trial_feature_email_followups": "Urmăriri prin email",
"trial_feature_hide_branding": "Ascunde branding-ul Formbricks",
"trial_feature_mobile_sdks": "SDK-uri iOS și Android",
"trial_feature_respondent_identification": "Identificarea respondenților",
"trial_feature_unlimited_seats": "Locuri nelimitate",
"trial_feature_webhooks": "Webhook-uri personalizate",
"trial_no_credit_card": "14 zile de probă, fără card necesar",
"trial_title": "Încearcă funcțiile Pro gratuit!",
"trial_payment_method_added_description": "Totul este pregătit! Planul tău Pro va continua automat după ce se încheie perioada de probă.",
"trial_title": "Obține Formbricks Pro gratuit!",
"unlimited_responses": "Răspunsuri nelimitate",
"unlimited_workspaces": "Workspaces nelimitate",
"upgrade": "Actualizare",
"upgrade_now": "Actualizează acum",
"usage_cycle": "Usage cycle",
"used": "utilizat",
"yearly": "Anual",
"yearly_checkout_unavailable": "Plata anuală nu este disponibilă încă. Adaugă mai întâi o metodă de plată pe un abonament lunar sau contactează asistența.",
"your_plan": "Planul tău"
},
"domain": {
@@ -1031,6 +1081,25 @@
"enterprise_features": "Funcții Enterprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
"license_feature_access_control": "Control acces (RBAC)",
"license_feature_audit_logs": "Jurnale de audit",
"license_feature_contacts": "Contacte și segmente",
"license_feature_projects": "Spații de lucru",
"license_feature_quotas": "Cote",
"license_feature_remove_branding": "Elimină branding-ul",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protecție spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autentificare cu doi factori",
"license_feature_whitelabel": "E-mailuri white-label",
"license_features_table_access": "Acces",
"license_features_table_description": "Funcționalități și limite enterprise disponibile în prezent pentru această instanță.",
"license_features_table_disabled": "Dezactivat",
"license_features_table_enabled": "Activat",
"license_features_table_feature": "Funcționalitate",
"license_features_table_title": "Funcționalități licențiate",
"license_features_table_unlimited": "Nelimitat",
"license_features_table_value": "Valoare",
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
"license_status": "Stare licență",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Gazdă personalizată",
"customize_survey_logo": "Personalizează logo-ul chestionarului",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
"delete_anyways": "Șterge oricum",
"delete_block": "Șterge blocul",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
"everyone": "Toată lumea",
"expand_preview": "Extinde previzualizarea",
"external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.",
"fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni răspuns",
"reverse_order_occasionally": "Inversare ordine ocazional",
"reverse_order_occasionally_except_last": "Inversare ordine ocazional cu excepția ultimului",
"roundness": "Rotunjire",
"roundness_description": "Controlează cât de rotunjite sunt colțurile.",
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Afișează sondajul de maxim",
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
"shrink_preview": "Restrânge previzualizarea",
"simple": "Simplu",
"six_points": "6 puncte",
"smiley": "Smiley",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
"subheading": "Subtitlu",
"subtract": "Scade -",
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
"survey_completed_heading": "Sondaj Completat",
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
"survey_display_settings": "Setări de afișare a sondajului",
"survey_placement": "Amplasarea sondajului",
"survey_preview": "Previzualizare chestionar 👀",
"survey_styling": "Stilizare formular",
"survey_trigger": "Declanșator sondaj",
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
@@ -3012,7 +3086,7 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
"preview_survey_question_open_text_headline": "Mai vrei să împărtășești ceva?",
"preview_survey_question_open_text_headline": "Mai aveți ceva de adăugat?",
"preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...",
"preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
"preview_survey_welcome_card_headline": "Bun venit!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
"coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ai vrea să adaugi?",
"follow_up_label": "Mai este ceva ce ați dori să adăugi?",
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
"generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?",
+103 -29
View File
@@ -167,6 +167,7 @@
"connect": "Подключить",
"connect_formbricks": "Подключить Formbricks",
"connected": "Подключено",
"contact": "Контакт",
"contacts": "Контакты",
"continue": "Продолжить",
"copied": "Скопировано",
@@ -174,6 +175,7 @@
"copy": "Копировать",
"copy_code": "Скопировать код",
"copy_link": "Скопировать ссылку",
"copy_to_environment": "Копировать в {{environment}}",
"count_attributes": "{count, plural, one {{count} атрибут} few {{count} атрибута} many {{count} атрибутов} other {{count} атрибута}}",
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контакта}}",
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
"elements": "Элементы",
"email": "Email",
"ending_card": "Завершающая карточка",
"enter_url": "Введите URL",
"enterprise_license": "Корпоративная лицензия",
"environment": "Окружение",
"environment_not_found": "Среда не найдена",
"environment_notice": "В данный момент вы находитесь в среде {environment}.",
"error": "Ошибка",
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Неактивные опросы",
"integration": "интеграция",
"integrations": "Интеграции",
"invalid_date": "Неверная дата",
"invalid_date_with_value": "Неверная дата: {value}",
"invalid_file_name": "Недопустимое имя файла, переименуйте файл и попробуйте снова",
"invalid_file_type": "Недопустимый тип файла",
"invite": "Пригласить",
"invite_them": "Пригласить их",
"javascript_required": "Требуется JavaScript",
"javascript_required_description": "Для корректной работы Formbricks необходим JavaScript. Пожалуйста, включите JavaScript в настройках вашего браузера, чтобы продолжить.",
"key": "Ключ",
"label": "Метка",
"language": "Язык",
@@ -280,7 +284,9 @@
"marketing": "Маркетинг",
"members": "Участники",
"members_and_teams": "Участники и команды",
"membership": "Членство",
"membership_not_found": "Участие не найдено",
"meta": "Мета",
"metadata": "Метаданные",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
@@ -294,6 +300,7 @@
"new": "Новый",
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
"next": "Далее",
"no_actions_found": "Действия не найдены",
"no_background_image_found": "Фоновое изображение не найдено.",
"no_code": "Нет кода",
"no_files_uploaded": "Файлы не были загружены",
@@ -319,10 +326,9 @@
"or": "или",
"organization": "Организация",
"organization_id": "ID организации",
"organization_not_found": "Организация не найдена",
"organization_settings": "Настройки организации",
"organization_teams_not_found": "Команды организации не найдены",
"other": "Другое",
"other_filters": "Другие фильтры",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
@@ -380,6 +387,7 @@
"select": "Выбрать",
"select_all": "Выбрать все",
"select_filter": "Выбрать фильтр",
"select_language": "Выберите язык",
"select_survey": "Выбрать опрос",
"select_teams": "Выбрать команды",
"selected": "Выбрано",
@@ -412,7 +420,6 @@
"survey_id": "ID опроса",
"survey_languages": "Языки опроса",
"survey_live": "Опрос активен",
"survey_not_found": "Опрос не найден",
"survey_paused": "Опрос приостановлен.",
"survey_type": "Тип опроса",
"surveys": "Опросы",
@@ -427,13 +434,15 @@
"team_name": "Название команды",
"team_role": "Роль в команде",
"teams": "Команды",
"teams_not_found": "Команды не найдены",
"text": "Текст",
"time": "Время",
"time_to_finish": "Время до завершения",
"title": "Заголовок",
"top_left": "Вверху слева",
"top_right": "Вверху справа",
"trial_days_remaining": "{count, plural, one {Остался # день пробного периода} few {Осталось # дня пробного периода} many {Осталось # дней пробного периода} other {Осталось # дней пробного периода}}",
"trial_expired": "Пробный период истёк",
"trial_one_day_remaining": "Остался 1 день пробного периода",
"try_again": "Попробуйте ещё раз",
"type": "Тип",
"unknown_survey": "Неизвестный опрос",
@@ -441,13 +450,13 @@
"update": "Обновить",
"updated": "Обновлено",
"updated_at": "Обновлено",
"upgrade_plan": "Перейти на другой тариф",
"upload": "Загрузить",
"upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.",
"upload_input_description": "Кликните или перетащите файлы для загрузки.",
"url": "URL",
"user": "Пользователь",
"user_id": "ID пользователя",
"user_not_found": "Пользователь не найден",
"variable": "Переменная",
"variable_ids": "ID переменных",
"variables": "Переменные",
@@ -463,14 +472,13 @@
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Воркфлоу",
"workspace": "Рабочее пространство",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
"workspace_id": "ID рабочего пространства",
"workspace_name": "Название рабочего пространства",
"workspace_name_placeholder": "например, Formbricks",
"workspace_not_found": "Рабочее пространство не найдено",
"workspace_permission_not_found": "Разрешение на рабочее пространство не найдено",
"workspaces": "Рабочие пространства",
"years": "годы",
"you": "Вы",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
"contact_deleted_successfully": "Контакт успешно удалён",
"contact_not_found": "Такой контакт не найден",
"contacts_table_refresh": "Обновить контакты",
"contacts_table_refresh_success": "Контакты успешно обновлены",
"create_attribute": "Создать атрибут",
@@ -846,9 +853,16 @@
"created_by_third_party": "Создано сторонней организацией",
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
"endpoint_bad_gateway_error": "Ошибка шлюза (502): Ошибка прокси/шлюза, сервис недоступен",
"endpoint_gateway_timeout_error": "Тайм-аут шлюза (504): Тайм-аут шлюза, сервис недоступен",
"endpoint_internal_server_error": "Внутренняя ошибка сервера (500): Сервис столкнулся с непредвиденной ошибкой",
"endpoint_method_not_allowed_error": "Метод не разрешен (405): Конечная точка существует, но не принимает POST-запросы",
"endpoint_not_found_error": "Не найдено (404): Конечная точка не существует",
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
"endpoint_service_unavailable_error": "Сервис недоступен (503): Сервис временно недоступен",
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
"no_triggers": "Нет триггеров",
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
"please_enter_a_url": "Пожалуйста, введите URL",
"response_created": "Ответ создан",
@@ -968,44 +982,80 @@
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
},
"billing": {
"cancelling": "Отмена",
"add_payment_method": "Добавить способ оплаты",
"add_payment_method_to_upgrade_tooltip": "Пожалуйста, добавьте способ оплаты выше, чтобы перейти на платный тариф",
"billing_interval_toggle": "Интервал выставления счетов",
"current_plan_badge": "Текущий",
"current_plan_cta": "Текущий тариф",
"custom_plan_description": "Ваша организация использует индивидуальные настройки оплаты. Вы все равно можете переключиться на один из стандартных тарифов ниже.",
"custom_plan_title": "Индивидуальный тариф",
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
"manage_subscription": "Управление подпиской",
"keep_current_plan": "Оставить текущий тариф",
"manage_billing_details": "Управление данными карты и счетами",
"monthly": "Ежемесячно",
"most_popular": "Самый популярный",
"pending_change_removed": "Запланированное изменение тарифа отменено.",
"pending_plan_badge": "Запланирован",
"pending_plan_change_description": "Ваш тариф изменится на {{plan}} {{date}}.",
"pending_plan_change_title": "Запланированное изменение тарифа",
"pending_plan_cta": "Запланирован",
"per_month": "в месяц",
"per_year": "в год",
"plan_change_applied": "Тариф успешно обновлен.",
"plan_change_scheduled": "Изменение тарифа успешно запланировано.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Все возможности Hobby",
"plan_feature_everything_in_pro": "Все возможности Pro",
"plan_hobby": "Хобби",
"plan_hobby_description": "Для частных лиц и небольших команд, начинающих работу с Formbricks Cloud.",
"plan_hobby_feature_responses": "250 ответов в месяц",
"plan_hobby_feature_workspaces": "1 рабочее пространство",
"plan_pro": "Pro",
"plan_pro_description": "Для растущих команд, которым нужны более высокие лимиты, автоматизация и динамические дополнительные ресурсы.",
"plan_pro_feature_responses": "2 000 ответов в месяц (динамическое превышение)",
"plan_pro_feature_workspaces": "3 рабочих пространства",
"plan_scale": "Scale",
"plan_scale_description": "Для крупных команд, которым нужно больше возможностей, строгое управление и больший объем ответов.",
"plan_scale_feature_responses": "5 000 ответов / месяц (динамический перерасход)",
"plan_scale_feature_workspaces": "5 рабочих пространств",
"plan_selection_description": "Сравни планы Hobby, Pro и Scale, а затем переключайся между ними прямо в Formbricks.",
"plan_selection_title": "Выбери свой план",
"plan_unknown": "Неизвестно",
"remove_branding": "Удалить брендинг",
"retry_setup": "Повторить настройку",
"scale_banner_description": "Откройте новые лимиты, командную работу и расширенные функции безопасности с тарифом Scale.",
"scale_banner_title": "Готовы развиваться?",
"scale_feature_api": "Полный доступ к API",
"scale_feature_quota": "Управление квотами",
"scale_feature_spam": "Защита от спама",
"scale_feature_teams": "Команды и роли доступа",
"select_plan_header_subtitle": "Кредитная карта не требуется, никаких обязательств.",
"select_plan_header_title": "Создавайте профессиональные опросы без брендинга уже сегодня!",
"select_plan_header_title": "Бесшовно интегрированные опросы, 100% ваш бренд.",
"status_trialing": "Пробный",
"stay_on_hobby_plan": "Я хочу остаться на тарифе Hobby",
"stripe_setup_incomplete": "Настройка оплаты не завершена",
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
"subscription": "Подписка",
"subscription_description": "Управляйте своим тарифом и следите за использованием",
"switch_at_period_end": "Переключить в конце периода",
"switch_plan_now": "Переключить план сейчас",
"this_includes": "Это включает",
"trial_alert_description": "Добавьте способ оплаты, чтобы сохранить доступ ко всем функциям.",
"trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.",
"trial_feature_api_access": "Получите полный доступ к API",
"trial_feature_collaboration": "Все функции для работы в команде и совместной работы",
"trial_feature_email_followups": "Настройте последующие письма",
"trial_feature_quotas": "Управляйте квотами",
"trial_feature_webhooks": "Настройте собственные вебхуки",
"trial_feature_whitelabel": "Полностью персонализированные опросы без брендинга",
"trial_feature_api_access": "Доступ к API",
"trial_feature_attribute_segmentation": "Сегментация на основе атрибутов",
"trial_feature_contact_segment_management": "Управление контактами и сегментами",
"trial_feature_email_followups": "Email-уведомления",
"trial_feature_hide_branding": "Скрыть брендинг Formbricks",
"trial_feature_mobile_sdks": "iOS и Android SDK",
"trial_feature_respondent_identification": "Идентификация респондентов",
"trial_feature_unlimited_seats": "Неограниченное количество мест",
"trial_feature_webhooks": "Пользовательские вебхуки",
"trial_no_credit_card": "14 дней пробного периода, кредитная карта не требуется",
"trial_title": "Попробуйте Pro функции бесплатно!",
"trial_payment_method_added_description": "Всё готово! Твой тарифный план Pro продолжится автоматически после окончания пробного периода.",
"trial_title": "Получите Formbricks Pro бесплатно!",
"unlimited_responses": "Неограниченное количество ответов",
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
"upgrade": "Обновить",
"upgrade_now": "Обновить сейчас",
"usage_cycle": "Usage cycle",
"used": "использовано",
"yearly": "Годовой",
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
"your_plan": "Ваш тариф"
},
"domain": {
@@ -1031,6 +1081,25 @@
"enterprise_features": "Функции для предприятий",
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
"license_feature_access_control": "Управление доступом (RBAC)",
"license_feature_audit_logs": "Журналы аудита",
"license_feature_contacts": "Контакты и сегменты",
"license_feature_projects": "Рабочие пространства",
"license_feature_quotas": "Квоты",
"license_feature_remove_branding": "Удаление брендирования",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Защита от спама",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Двухфакторная аутентификация",
"license_feature_whitelabel": "Электронные письма без брендирования",
"license_features_table_access": "Доступ",
"license_features_table_description": "Корпоративные функции и ограничения, доступные для этого экземпляра.",
"license_features_table_disabled": "Отключено",
"license_features_table_enabled": "Включено",
"license_features_table_feature": "Функция",
"license_features_table_title": "Лицензированные функции",
"license_features_table_unlimited": "Без ограничений",
"license_features_table_value": "Значение",
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
"license_status": "Статус лицензии",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Пользовательский хостнейм",
"customize_survey_logo": "Настроить логотип опроса",
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
"date_format": "Формат даты",
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
"delete_anyways": "Удалить в любом случае",
"delete_block": "Удалить блок",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Ошибка при сохранении изменений",
"even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).",
"everyone": "Все",
"expand_preview": "Развернуть предпросмотр",
"external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.",
"fallback_missing": "Запасное значение отсутствует",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Лимит ответов должен превышать количество полученных ответов ({responseCount}).",
"response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.",
"response_options": "Параметры ответа",
"reverse_order_occasionally": "Иногда обращать порядок",
"reverse_order_occasionally_except_last": "Иногда обращать порядок кроме последнего",
"roundness": "Скругление",
"roundness_description": "Определяет степень скругления углов.",
"row_used_in_logic_error": "Эта строка используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите её из логики.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Показать опрос максимум",
"show_survey_to_users": "Показать опрос % пользователей",
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
"shrink_preview": "Свернуть предпросмотр",
"simple": "Простой",
"six_points": "6 баллов",
"smiley": "Смайлик",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
"subheading": "Подзаголовок",
"subtract": "Вычесть -",
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
"survey_completed_heading": "Опрос завершён",
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
"survey_display_settings": "Настройки отображения опроса",
"survey_placement": "Размещение опроса",
"survey_preview": "Предпросмотр опроса 👀",
"survey_styling": "Оформление формы",
"survey_trigger": "Триггер опроса",
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
@@ -3012,7 +3086,7 @@
"preview_survey_question_2_choice_2_label": "Нет, спасибо!",
"preview_survey_question_2_headline": "Хотите быть в курсе событий?",
"preview_survey_question_2_subheader": "Это пример описания.",
"preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?",
"preview_survey_question_open_text_headline": "Хотите ли вы чем-то ещё поделиться?",
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
"preview_survey_welcome_card_headline": "Добро пожаловать!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хочешь что-то ещё добавить?",
"follow_up_label": "Хотите ли вы что-нибудь добавить?",
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
"generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?",
+103 -29
View File
@@ -167,6 +167,7 @@
"connect": "Anslut",
"connect_formbricks": "Anslut Formbricks",
"connected": "Ansluten",
"contact": "Kontakt",
"contacts": "Kontakter",
"continue": "Fortsätt",
"copied": "Kopierad",
@@ -174,6 +175,7 @@
"copy": "Kopiera",
"copy_code": "Kopiera kod",
"copy_link": "Kopiera länk",
"copy_to_environment": "Kopiera till {{environment}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attribut}}",
"count_contacts": "{count, plural, one {{count} kontakt} other {{count} kontakter}}",
"count_members": "{count, plural, one {{count} medlem} other {{count} medlemmar}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
"elements": "Element",
"email": "E-post",
"ending_card": "Avslutningskort",
"enter_url": "Ange URL",
"enterprise_license": "Företagslicens",
"environment": "Miljö",
"environment_not_found": "Miljö hittades inte",
"environment_notice": "Du är för närvarande i {environment}-miljön.",
"error": "Fel",
"error_component_description": "Denna resurs finns inte eller så har du inte de nödvändiga rättigheterna för att komma åt den.",
@@ -255,11 +257,13 @@
"inactive_surveys": "Inaktiva enkäter",
"integration": "integration",
"integrations": "Integrationer",
"invalid_date": "Ogiltigt datum",
"invalid_date_with_value": "Ogiltigt datum: {value}",
"invalid_file_name": "Ogiltigt filnamn, vänligen byt namn på din fil och försök igen",
"invalid_file_type": "Ogiltig filtyp",
"invite": "Bjud in",
"invite_them": "Bjud in dem",
"javascript_required": "JavaScript krävs",
"javascript_required_description": "Formbricks kräver JavaScript för att fungera korrekt. Vänligen aktivera JavaScript i dina webbläsarinställningar för att fortsätta.",
"key": "Nyckel",
"label": "Etikett",
"language": "Språk",
@@ -280,7 +284,9 @@
"marketing": "Marknadsföring",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership": "Medlemskap",
"membership_not_found": "Medlemskap hittades inte",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
"mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
@@ -294,6 +300,7 @@
"new": "Ny",
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
"next": "Nästa",
"no_actions_found": "Inga åtgärder hittades",
"no_background_image_found": "Ingen bakgrundsbild hittades.",
"no_code": "Ingen kod",
"no_files_uploaded": "Inga filer laddades upp",
@@ -319,10 +326,9 @@
"or": "eller",
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation hittades inte",
"organization_settings": "Organisationsinställningar",
"organization_teams_not_found": "Organisationsteam hittades inte",
"other": "Annat",
"other_filters": "Andra filter",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
"powered_by_formbricks": "Drivs av Formbricks",
"preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy",
@@ -380,6 +387,7 @@
"select": "Välj",
"select_all": "Välj alla",
"select_filter": "Välj filter",
"select_language": "Välj språk",
"select_survey": "Välj enkät",
"select_teams": "Välj team",
"selected": "Vald",
@@ -412,7 +420,6 @@
"survey_id": "Enkät-ID",
"survey_languages": "Enkätspråk",
"survey_live": "Enkät live",
"survey_not_found": "Enkät hittades inte",
"survey_paused": "Enkät pausad.",
"survey_type": "Enkättyp",
"surveys": "Enkäter",
@@ -427,13 +434,15 @@
"team_name": "Teamnamn",
"team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"teams_not_found": "Team hittades inte",
"text": "Text",
"time": "Tid",
"time_to_finish": "Tid att slutföra",
"title": "Titel",
"top_left": "Övre vänster",
"top_right": "Övre höger",
"trial_days_remaining": "{count} dagar kvar av din provperiod",
"trial_expired": "Din provperiod har gått ut",
"trial_one_day_remaining": "1 dag kvar av din provperiod",
"try_again": "Försök igen",
"type": "Typ",
"unknown_survey": "Okänd enkät",
@@ -441,13 +450,13 @@
"update": "Uppdatera",
"updated": "Uppdaterad",
"updated_at": "Uppdaterad",
"upgrade_plan": "Uppgradera plan",
"upload": "Ladda upp",
"upload_failed": "Uppladdning misslyckades. Vänligen försök igen.",
"upload_input_description": "Klicka eller dra för att ladda upp filer.",
"url": "URL",
"user": "Användare",
"user_id": "Användar-ID",
"user_not_found": "Användare hittades inte",
"variable": "Variabel",
"variable_ids": "Variabel-ID:n",
"variables": "Variabler",
@@ -463,14 +472,13 @@
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Arbetsflöden",
"workspace": "Arbetsyta",
"workspace_configuration": "Arbetsytans konfiguration",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
"workspace_id": "Arbetsyte-ID",
"workspace_name": "Arbetsytans namn",
"workspace_name_placeholder": "t.ex. Formbricks",
"workspace_not_found": "Arbetsyta hittades inte",
"workspace_permission_not_found": "Arbetsytebehörighet hittades inte",
"workspaces": "Arbetsytor",
"years": "år",
"you": "Du",
@@ -655,7 +663,6 @@
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
"contact_deleted_successfully": "Kontakt borttagen",
"contact_not_found": "Ingen sådan kontakt hittades",
"contacts_table_refresh": "Uppdatera kontakter",
"contacts_table_refresh_success": "Kontakter uppdaterade",
"create_attribute": "Skapa attribut",
@@ -846,9 +853,16 @@
"created_by_third_party": "Skapad av tredje part",
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
"endpoint_bad_gateway_error": "Felaktig gateway (502): Proxy-/gatewayfel, tjänsten kan inte nås",
"endpoint_gateway_timeout_error": "Gateway-timeout (504): Gateway-timeout, tjänsten kan inte nås",
"endpoint_internal_server_error": "Internt serverfel (500): Tjänsten stötte på ett oväntat fel",
"endpoint_method_not_allowed_error": "Metoden tillåts inte (405): Endpointen finns, men accepterar inte POST-förfrågningar",
"endpoint_not_found_error": "Hittades inte (404): Endpointen finns inte",
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
"endpoint_pinged_error": "Kunde inte nå webhooken!",
"endpoint_service_unavailable_error": "Tjänsten är inte tillgänglig (503): Tjänsten är tillfälligt nere",
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
"no_triggers": "Inga utlösare",
"please_check_console": "Vänligen kontrollera konsolen för mer information",
"please_enter_a_url": "Vänligen ange en URL",
"response_created": "Svar skapat",
@@ -968,44 +982,80 @@
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
},
"billing": {
"cancelling": "Avbryter",
"add_payment_method": "Lägg till betalningsmetod",
"add_payment_method_to_upgrade_tooltip": "Lägg till en betalningsmetod ovan för att uppgradera till en betald plan",
"billing_interval_toggle": "Faktureringsintervall",
"current_plan_badge": "Nuvarande",
"current_plan_cta": "Nuvarande abonnemang",
"custom_plan_description": "Din organisation har en anpassad faktureringslösning. Du kan fortfarande byta till något av standardabonnemangen nedan.",
"custom_plan_title": "Anpassat abonnemang",
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
"manage_subscription": "Hantera prenumeration",
"keep_current_plan": "Behåll nuvarande abonnemang",
"manage_billing_details": "Hantera kortuppgifter & fakturor",
"monthly": "Månatlig",
"most_popular": "Mest populär",
"pending_change_removed": "Schemalagd abonnemangsändring har tagits bort.",
"pending_plan_badge": "Schemalagd",
"pending_plan_change_description": "Ditt abonnemang kommer att ändras till {{plan}} den {{date}}.",
"pending_plan_change_title": "Schemalagd abonnemangsändring",
"pending_plan_cta": "Schemalagd",
"per_month": "per månad",
"per_year": "per år",
"plan_change_applied": "Abonnemanget har uppdaterats.",
"plan_change_scheduled": "Abonnemangsändring har schemalagts.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Allt i Hobby",
"plan_feature_everything_in_pro": "Allt i Pro",
"plan_hobby": "Hobby",
"plan_hobby_description": "För privatpersoner och små team som kommer igång med Formbricks Cloud.",
"plan_hobby_feature_responses": "250 svar / månad",
"plan_hobby_feature_workspaces": "1 arbetsyta",
"plan_pro": "Pro",
"plan_pro_description": "För växande team som behöver högre gränser, automationer och dynamiska överskott.",
"plan_pro_feature_responses": "2 000 svar / månad (dynamisk överförbrukning)",
"plan_pro_feature_workspaces": "3 arbetsytor",
"plan_scale": "Skala",
"plan_scale_description": "För större team som behöver mer kapacitet, starkare styrning och högre svarsvolym.",
"plan_scale_feature_responses": "5 000 svar / månad (dynamisk överförbrukning)",
"plan_scale_feature_workspaces": "5 arbetsytor",
"plan_selection_description": "Jämför Hobby, Pro och Scale och byt sedan plan direkt från Formbricks.",
"plan_selection_title": "Välj din plan",
"plan_unknown": "Okänd",
"remove_branding": "Ta bort varumärke",
"retry_setup": "Försök igen med inställningen",
"scale_banner_description": "Lås upp högre gränser, samarbete i team och avancerade säkerhetsfunktioner med Scale-planen.",
"scale_banner_title": "Redo att växla upp?",
"scale_feature_api": "Full API-åtkomst",
"scale_feature_quota": "Kvot­hantering",
"scale_feature_spam": "Spamskydd",
"scale_feature_teams": "Team & åtkomstroller",
"select_plan_header_subtitle": "Inget kreditkort krävs, inga villkor.",
"select_plan_header_title": "Skicka professionella undersökningar utan varumärke idag!",
"select_plan_header_title": "Sömlöst integrerade undersökningar, 100% ditt varumärke.",
"status_trialing": "Testperiod",
"stay_on_hobby_plan": "Jag vill behålla Hobby-planen",
"stripe_setup_incomplete": "Faktureringsinställningar ofullständiga",
"stripe_setup_incomplete_description": "Faktureringsinställningen slutfördes inte riktigt. Försök igen för att aktivera ditt abonnemang.",
"subscription": "Abonnemang",
"subscription_description": "Hantera din abonnemangsplan och följ din användning",
"switch_at_period_end": "Byt vid periodens slut",
"switch_plan_now": "Byt plan nu",
"this_includes": "Detta inkluderar",
"trial_alert_description": "Lägg till en betalningsmetod för att behålla tillgång till alla funktioner.",
"trial_already_used": "En gratis provperiod har redan använts för denna e-postadress. Uppgradera till en betald plan istället.",
"trial_feature_api_access": "Få full API-åtkomst",
"trial_feature_collaboration": "Alla team- och samarbetsfunktioner",
"trial_feature_email_followups": "Konfigurera uppföljningsmejl",
"trial_feature_quotas": "Hantera kvoter",
"trial_feature_webhooks": "Konfigurera anpassade webhooks",
"trial_feature_whitelabel": "Helt white-label-anpassade enkäter",
"trial_feature_api_access": "API-åtkomst",
"trial_feature_attribute_segmentation": "Attributbaserad segmentering",
"trial_feature_contact_segment_management": "Kontakt- och segmenthantering",
"trial_feature_email_followups": "E-postuppföljningar",
"trial_feature_hide_branding": "Dölj Formbricks-branding",
"trial_feature_mobile_sdks": "iOS- och Android-SDK:er",
"trial_feature_respondent_identification": "Respondentidentifiering",
"trial_feature_unlimited_seats": "Obegränsade platser",
"trial_feature_webhooks": "Anpassade webhooks",
"trial_no_credit_card": "14 dagars provperiod, inget kreditkort krävs",
"trial_title": "Testa Pro-funktioner gratis!",
"trial_payment_method_added_description": "Du är redo! Din Pro-plan kommer att fortsätta automatiskt efter att provperioden slutar.",
"trial_title": "Få Formbricks Pro gratis!",
"unlimited_responses": "Obegränsade svar",
"unlimited_workspaces": "Obegränsat antal arbetsytor",
"upgrade": "Uppgradera",
"upgrade_now": "Uppgradera nu",
"usage_cycle": "Usage cycle",
"used": "använt",
"yearly": "Årligen",
"yearly_checkout_unavailable": "Årlig betalning är inte tillgänglig ännu. Lägg till en betalningsmetod på en månatlig plan först eller kontakta support.",
"your_plan": "Din plan"
},
"domain": {
@@ -1031,6 +1081,25 @@
"enterprise_features": "Enterprise-funktioner",
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
"license_feature_access_control": "Åtkomstkontroll (RBAC)",
"license_feature_audit_logs": "Granskningsloggar",
"license_feature_contacts": "Kontakter & Segment",
"license_feature_projects": "Arbetsytor",
"license_feature_quotas": "Kvoter",
"license_feature_remove_branding": "Ta bort varumärkning",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Skräppostskydd",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tvåfaktorsautentisering",
"license_feature_whitelabel": "White-label-mejl",
"license_features_table_access": "Åtkomst",
"license_features_table_description": "Företagsfunktioner och begränsningar som för närvarande är tillgängliga för den här instansen.",
"license_features_table_disabled": "Inaktiverad",
"license_features_table_enabled": "Aktiverad",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Licensierade funktioner",
"license_features_table_unlimited": "Obegränsad",
"license_features_table_value": "Värde",
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
"license_status": "Licensstatus",
@@ -1352,7 +1421,6 @@
"custom_hostname": "Anpassat värdnamn",
"customize_survey_logo": "Anpassa undersökningens logotyp",
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
"date_format": "Datumformat",
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
"delete_anyways": "Ta bort ändå",
"delete_block": "Ta bort block",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "Fel vid sparande av ändringar",
"even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).",
"everyone": "Alla",
"expand_preview": "Expandera förhandsgranskning",
"external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
"fallback_missing": "Reservvärde saknas",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).",
"response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.",
"response_options": "Svarsalternativ",
"reverse_order_occasionally": "Vänd ordning ibland",
"reverse_order_occasionally_except_last": "Vänd ordning ibland utom sista",
"roundness": "Rundhet",
"roundness_description": "Styr hur rundade hörnen är.",
"row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "Visa enkät maximalt",
"show_survey_to_users": "Visa enkät för % av användare",
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
"shrink_preview": "Minimera förhandsgranskning",
"simple": "Enkel",
"six_points": "6 poäng",
"smiley": "Smiley",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "Styling inställd på temastil",
"subheading": "Underrubrik",
"subtract": "Subtrahera -",
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
"survey_completed_heading": "Enkät slutförd",
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
"survey_display_settings": "Visningsinställningar för enkät",
"survey_placement": "Enkätplacering",
"survey_preview": "Enkätförhandsgranskning 👀",
"survey_styling": "Formulärstil",
"survey_trigger": "Enkätutlösare",
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
@@ -3012,7 +3086,7 @@
"preview_survey_question_2_choice_2_label": "Nej, tack!",
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
"preview_survey_question_open_text_headline": "Något mer du vill dela med dig av?",
"preview_survey_question_open_text_headline": "Finns det något annat du vill dela med dig av?",
"preview_survey_question_open_text_placeholder": "Skriv ditt svar här...",
"preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.",
"preview_survey_welcome_card_headline": "Välkommen!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
"coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Är det något mer du vill lägga till?",
"follow_up_label": "Finns det något annat du vill lägga till?",
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?",
+104 -30
View File
@@ -167,6 +167,7 @@
"connect": "连接",
"connect_formbricks": "连接 Formbricks",
"connected": "已连接",
"contact": "联系人",
"contacts": "联系人",
"continue": "继续",
"copied": "已复制",
@@ -174,6 +175,7 @@
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"copy_to_environment": "复制到{{environment}}",
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人} }",
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(副本 {copyNumber}",
"e_commerce": "电子商务",
"edit": "编辑",
"elements": "元素",
"email": "邮箱",
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
"enterprise_license": "企业 许可证",
"environment": "环境",
"environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。",
"error": "错误",
"error_component_description": "这个资源不存在或您没有权限访问它。",
@@ -255,11 +257,13 @@
"inactive_surveys": "不 活跃 调查",
"integration": "集成",
"integrations": "集成",
"invalid_date": "无效 日期",
"invalid_date_with_value": "无效 日期: {value}",
"invalid_file_name": "文件名无效,请重命名文件后重试",
"invalid_file_type": "无效 的 文件 类型",
"invite": "邀请",
"invite_them": "邀请 他们",
"javascript_required": "需要启用 JavaScript",
"javascript_required_description": "Formbricks 需要 JavaScript 才能正常运行。请在浏览器设置中启用 JavaScript 以继续。",
"key": "键",
"label": "标签",
"language": "语言",
@@ -280,7 +284,9 @@
"marketing": "市场营销",
"members": "成员",
"members_and_teams": "成员和团队",
"membership": "会员资格",
"membership_not_found": "未找到会员资格",
"meta": "元数据",
"metadata": "元数据",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
@@ -294,6 +300,7 @@
"new": "新建",
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
"next": "下一步",
"no_actions_found": "未找到操作",
"no_background_image_found": "未找到 背景 图片。",
"no_code": "无代码",
"no_files_uploaded": "没有 文件 被 上传",
@@ -319,10 +326,9 @@
"or": "或",
"organization": "组织",
"organization_id": "组织 ID",
"organization_not_found": "组织 未找到",
"organization_settings": "组织 设置",
"organization_teams_not_found": "未找到 组织 团队",
"other": "其他",
"other_filters": "其他筛选条件",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "请选择至少 一个调查",
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
"please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览",
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
@@ -380,6 +387,7 @@
"select": "选择",
"select_all": "选择 全部",
"select_filter": "选择过滤器",
"select_language": "选择语言",
"select_survey": "选择 调查",
"select_teams": "选择 团队",
"selected": "已选择",
@@ -399,7 +407,7 @@
"something_went_wrong": "出错了",
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
"sort_by": "排序 依据",
"start_free_trial": "开始 免费试用",
"start_free_trial": "开始免费试用",
"status": "状态",
"step_by_step_manual": "分步 手册",
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
@@ -412,7 +420,6 @@
"survey_id": "调查 ID",
"survey_languages": "调查 语言",
"survey_live": "调查 运行中",
"survey_not_found": "调查 未找到",
"survey_paused": "调查 暂停。",
"survey_type": "调查 类型",
"surveys": "调查",
@@ -427,13 +434,15 @@
"team_name": "团队 名称",
"team_role": "团队角色",
"teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
"time_to_finish": "完成 时间",
"title": "标题",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "试用期还剩 {count} 天",
"trial_expired": "您的试用期已过期",
"trial_one_day_remaining": "试用期还剩 1 天",
"try_again": "再试一次",
"type": "类型",
"unknown_survey": "未知调查",
@@ -441,13 +450,13 @@
"update": "更新",
"updated": "已更新",
"updated_at": "更新 于",
"upgrade_plan": "升级套餐",
"upload": "上传",
"upload_failed": "上传失败,请重试。",
"upload_input_description": "点击 或 拖动 上传 文件",
"url": "URL",
"user": "用户",
"user_id": "用户 ID",
"user_not_found": "用户 不存在",
"variable": "变量",
"variable_ids": "变量 ID",
"variables": "变量",
@@ -463,14 +472,13 @@
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "工作流",
"workspace": "工作区",
"workspace_configuration": "工作区配置",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
"workspace_id": "工作区 ID",
"workspace_name": "工作区名称",
"workspace_name_placeholder": "例如:Formbricks",
"workspace_not_found": "未找到工作区",
"workspace_permission_not_found": "未找到工作区权限",
"workspaces": "工作区",
"years": "年",
"you": "你 ",
@@ -655,7 +663,6 @@
"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": "创建属性",
@@ -846,9 +853,16 @@
"created_by_third_party": "由 第三方 创建",
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
"endpoint_bad_gateway_error": "错误网关 (502):代理/网关错误,服务不可达",
"endpoint_gateway_timeout_error": "网关超时 (504):网关超时,服务不可达",
"endpoint_internal_server_error": "内部服务器错误 (500):服务遇到了意外错误",
"endpoint_method_not_allowed_error": "方法不被允许 (405):该端点存在,但不接受 POST 请求",
"endpoint_not_found_error": "未找到 (404):该端点不存在",
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
"endpoint_pinged_error": "无法 ping 该 webhook",
"endpoint_service_unavailable_error": "服务不可用 (503):服务暂时不可用",
"learn_to_verify": "了解如何验证 webhook 签名",
"no_triggers": "无触发器",
"please_check_console": "请查看控制台以获取更多详情",
"please_enter_a_url": "请输入一个 URL",
"response_created": "创建 响应",
@@ -968,44 +982,80 @@
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
},
"billing": {
"cancelling": "正在取消",
"add_payment_method": "添加支付方式",
"add_payment_method_to_upgrade_tooltip": "请先在上方添加付款方式以升级到付费套餐",
"billing_interval_toggle": "账单周期",
"current_plan_badge": "当前",
"current_plan_cta": "当前方案",
"custom_plan_description": "您的组织使用的是自定义计费设置。您仍然可以切换到下面的标准方案。",
"custom_plan_title": "自定义方案",
"failed_to_start_trial": "试用启动失败,请重试。",
"manage_subscription": "管理订阅",
"keep_current_plan": "保持当前方案",
"manage_billing_details": "管理卡片详情与发票",
"monthly": "按月",
"most_popular": "最受欢迎",
"pending_change_removed": "已取消预定的方案变更。",
"pending_plan_badge": "已预定",
"pending_plan_change_description": "您的方案将在 {{date}} 切换至 {{plan}}。",
"pending_plan_change_title": "预定的方案变更",
"pending_plan_cta": "已预定",
"per_month": "每月",
"per_year": "每年",
"plan_change_applied": "方案更新成功。",
"plan_change_scheduled": "方案变更预定成功。",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
"plan_hobby": "兴趣版",
"plan_hobby_description": "适合开始使用 Formbricks Cloud 的个人和小团队。",
"plan_hobby_feature_responses": "250 条回复 / 月",
"plan_hobby_feature_workspaces": "1 个工作区",
"plan_pro": "专业版",
"plan_pro_description": "适合需要更高限额、自动化功能和动态超额使用的成长型团队。",
"plan_pro_feature_responses": "2,000 条回复 / 月(动态超额)",
"plan_pro_feature_workspaces": "3 个工作区",
"plan_scale": "规模版",
"plan_scale_description": "适合需要更大容量、更强治理能力和更高响应量的大型团队。",
"plan_scale_feature_responses": "每月 5,000 次响应(动态超额)",
"plan_scale_feature_workspaces": "5 个工作区",
"plan_selection_description": "比较 Hobby、Pro 和 Scale 套餐,然后直接从 Formbricks 切换套餐。",
"plan_selection_title": "选择您的套餐",
"plan_unknown": "未知",
"remove_branding": "移除 品牌",
"retry_setup": "重试设置",
"scale_banner_description": "升级到 Scale 套餐,解锁更高额度、团队协作和高级安全功能。",
"scale_banner_title": "准备好扩容了吗?",
"scale_feature_api": "完整 API 访问权限",
"scale_feature_quota": "额度管理",
"scale_feature_spam": "垃圾防护",
"scale_feature_teams": "团队与访问角色",
"select_plan_header_subtitle": "无需信用卡,没有任何附加条件。",
"select_plan_header_title": "立即发布专业的无品牌调查!",
"select_plan_header_title": "无缝集成的调查问卷,100% 展现您的品牌。",
"status_trialing": "试用版",
"stay_on_hobby_plan": "我想继续使用免费版计划",
"stripe_setup_incomplete": "账单设置未完成",
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
"subscription": "订阅",
"subscription_description": "管理你的订阅套餐并监控用量",
"switch_at_period_end": "在周期结束时切换",
"switch_plan_now": "立即切换套餐",
"this_includes": "包含以下内容",
"trial_alert_description": "添加支付方式以继续使用所有功能。",
"trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。",
"trial_feature_api_access": "获取完整 API 访问权限",
"trial_feature_collaboration": "所有团队和协作功能",
"trial_feature_email_followups": "设置邮件跟进",
"trial_feature_quotas": "管理配额",
"trial_feature_webhooks": "设置自定义 Webhook",
"trial_feature_whitelabel": "完全白标化的问卷调查",
"trial_feature_api_access": "API 访问",
"trial_feature_attribute_segmentation": "基于属性的细分",
"trial_feature_contact_segment_management": "联系人和细分管理",
"trial_feature_email_followups": "电子邮件跟进",
"trial_feature_hide_branding": "隐藏 Formbricks 品牌标识",
"trial_feature_mobile_sdks": "iOS 和 Android SDK",
"trial_feature_respondent_identification": "受访者识别",
"trial_feature_unlimited_seats": "无限席位",
"trial_feature_webhooks": "自定义 Webhook",
"trial_no_credit_card": "14 天试用,无需信用卡",
"trial_title": "免费试用专业版功能!",
"trial_payment_method_added_description": "一切就绪!试用期结束后,您的专业版计划将自动继续。",
"trial_title": "免费获取 Formbricks Pro!",
"unlimited_responses": "无限反馈",
"unlimited_workspaces": "无限工作区",
"upgrade": "升级",
"upgrade_now": "立即升级",
"usage_cycle": "Usage cycle",
"used": "已用",
"yearly": "按年付费",
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
"your_plan": "你的套餐"
},
"domain": {
@@ -1031,6 +1081,25 @@
"enterprise_features": "企业 功能",
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
"license_feature_access_control": "访问控制(RBAC",
"license_feature_audit_logs": "审计日志",
"license_feature_contacts": "联系人与细分",
"license_feature_projects": "工作空间",
"license_feature_quotas": "配额",
"license_feature_remove_branding": "移除品牌标识",
"license_feature_saml": "SAML 单点登录",
"license_feature_spam_protection": "垃圾信息防护",
"license_feature_sso": "OIDC 单点登录",
"license_feature_two_factor_auth": "双因素认证",
"license_feature_whitelabel": "白标电子邮件",
"license_features_table_access": "访问权限",
"license_features_table_description": "此实例当前可用的企业功能和限制。",
"license_features_table_disabled": "已禁用",
"license_features_table_enabled": "已启用",
"license_features_table_feature": "功能",
"license_features_table_title": "许可功能",
"license_features_table_unlimited": "无限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
"license_status": "许可证状态",
@@ -1352,7 +1421,6 @@
"custom_hostname": "自 定 义 主 机 名",
"customize_survey_logo": "自定义调查 logo",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
"delete_anyways": "仍然删除",
"delete_block": "删除区块",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
"everyone": "所有 人",
"expand_preview": "展开预览",
"external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。",
"fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 {responseCount})。",
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
"response_options": "响应 选项",
"reverse_order_occasionally": "偶尔反转顺序",
"reverse_order_occasionally_except_last": "偶尔反转顺序(最后一项除外)",
"roundness": "圆度",
"roundness_description": "控制圆角的弧度。",
"row_used_in_logic_error": "\"这个 行 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "显示 调查 最大 一次",
"show_survey_to_users": "显示 问卷 给 % 的 用户",
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
"shrink_preview": "收起预览",
"simple": "简单",
"six_points": "6 分",
"smiley": "笑脸",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "样式 设置 为 主题 风格",
"subheading": "子标题",
"subtract": "减 -",
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
"survey_completed_heading": "调查 完成",
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
"survey_display_settings": "调查显示设置",
"survey_placement": "调查 放置",
"survey_preview": "问卷预览 👀",
"survey_styling": "表单 样式",
"survey_trigger": "调查 触发",
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
@@ -3012,7 +3086,7 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_question_2_subheader": "这是一个示例描述。",
"preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?",
"preview_survey_question_open_text_headline": "还有其他想分享的内容吗?",
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
"preview_survey_welcome_card_headline": "欢迎!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!",
"follow_up_label": "还有其他想补充的吗?",
"follow_up_label": "还有其他想补充的内容吗?",
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
"generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?",
+103 -29
View File
@@ -167,6 +167,7 @@
"connect": "連線",
"connect_formbricks": "連線 Formbricks",
"connected": "已連線",
"contact": "聯絡人",
"contacts": "聯絡人",
"continue": "繼續",
"copied": "已 複製",
@@ -174,6 +175,7 @@
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"copy_to_environment": "複製到{{environment}}",
"count_attributes": "{count, plural, other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 位聯絡人}}",
"count_members": "{count, plural, other {{count} 位成員}}",
@@ -213,12 +215,12 @@
"duplicate_copy_number": "(複製 {copyNumber}",
"e_commerce": "電子商務",
"edit": "編輯",
"elements": "元素",
"email": "電子郵件",
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
"enterprise_license": "企業授權",
"environment": "環境",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
@@ -255,11 +257,13 @@
"inactive_surveys": "停用中的問卷",
"integration": "整合",
"integrations": "整合",
"invalid_date": "無效日期",
"invalid_date_with_value": "無效日期: {value}",
"invalid_file_name": "檔案名稱無效,請重新命名檔案後再試一次",
"invalid_file_type": "無效的檔案類型",
"invite": "邀請",
"invite_them": "邀請他們",
"javascript_required": "需要 JavaScript",
"javascript_required_description": "Formbricks 需要 JavaScript 才能正常運作。請在瀏覽器設定中啟用 JavaScript 以繼續使用。",
"key": "金鑰",
"label": "標籤",
"language": "語言",
@@ -280,7 +284,9 @@
"marketing": "行銷",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership": "會員資格",
"membership_not_found": "找不到成員資格",
"meta": "Meta",
"metadata": "元數據",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
@@ -294,6 +300,7 @@
"new": "新增",
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
"next": "下一步",
"no_actions_found": "找不到動作",
"no_background_image_found": "找不到背景圖片。",
"no_code": "無程式碼",
"no_files_uploaded": "沒有上傳任何檔案",
@@ -319,10 +326,9 @@
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
"organization_not_found": "找不到組織",
"organization_settings": "組織設定",
"organization_teams_not_found": "找不到組織團隊",
"other": "其他",
"other_filters": "其他篩選條件",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
@@ -339,6 +345,7 @@
"please_select_at_least_one_survey": "請選擇至少一個問卷",
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
"please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽",
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
@@ -380,6 +387,7 @@
"select": "選擇",
"select_all": "全選",
"select_filter": "選擇篩選器",
"select_language": "選擇語言",
"select_survey": "選擇問卷",
"select_teams": "選擇 團隊",
"selected": "已選取",
@@ -412,7 +420,6 @@
"survey_id": "問卷 ID",
"survey_languages": "問卷語言",
"survey_live": "問卷已上線",
"survey_not_found": "找不到問卷",
"survey_paused": "問卷已暫停。",
"survey_type": "問卷類型",
"surveys": "問卷",
@@ -427,13 +434,15 @@
"team_name": "團隊名稱",
"team_role": "團隊角色",
"teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
"time_to_finish": "完成時間",
"title": "標題",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "試用期剩餘 {count} 天",
"trial_expired": "您的試用期已結束",
"trial_one_day_remaining": "試用期剩餘 1 天",
"try_again": "再試一次",
"type": "類型",
"unknown_survey": "未知問卷",
@@ -441,13 +450,13 @@
"update": "更新",
"updated": "已更新",
"updated_at": "更新時間",
"upgrade_plan": "升級方案",
"upload": "上傳",
"upload_failed": "上傳失敗。請再試一次。",
"upload_input_description": "點擊或拖曳以上傳檔案。",
"url": "網址",
"user": "使用者",
"user_id": "使用者 ID",
"user_not_found": "找不到使用者",
"variable": "變數",
"variable_ids": "變數 ID",
"variables": "變數",
@@ -463,14 +472,13 @@
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "工作流程",
"workspace": "工作區",
"workspace_configuration": "工作區設定",
"workspace_created_successfully": "工作區已成功建立",
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
"workspace_id": "工作區 ID",
"workspace_name": "工作區名稱",
"workspace_name_placeholder": "例如:Formbricks",
"workspace_not_found": "找不到工作區",
"workspace_permission_not_found": "找不到工作區權限",
"workspaces": "工作區",
"years": "年",
"you": "您",
@@ -655,7 +663,6 @@
"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": "建立屬性",
@@ -846,9 +853,16 @@
"created_by_third_party": "由第三方建立",
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
"endpoint_bad_gateway_error": "錯誤的閘道 (502):代理/閘道錯誤,服務無法連線",
"endpoint_gateway_timeout_error": "閘道逾時 (504):閘道逾時,服務無法連線",
"endpoint_internal_server_error": "內部伺服器錯誤 (500):服務遇到了未預期的錯誤",
"endpoint_method_not_allowed_error": "不允許的方法 (405):該端點存在,但不接受 POST 請求",
"endpoint_not_found_error": "找不到 (404):該端點不存在",
"endpoint_pinged": "耶!我們能夠 ping Webhook",
"endpoint_pinged_error": "無法 ping Webhook",
"endpoint_service_unavailable_error": "服務無法使用 (503):服務暫時無法使用",
"learn_to_verify": "了解如何驗證 webhook 簽章",
"no_triggers": "無觸發條件",
"please_check_console": "請檢查主控台以取得更多詳細資料",
"please_enter_a_url": "請輸入網址",
"response_created": "已建立回應",
@@ -968,44 +982,80 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"cancelling": "正在取消",
"add_payment_method": "新增付款方式",
"add_payment_method_to_upgrade_tooltip": "請先在上方新增付款方式以升級至付費方案",
"billing_interval_toggle": "帳單週期",
"current_plan_badge": "目前",
"current_plan_cta": "目前方案",
"custom_plan_description": "您的組織使用自訂計費設定。您仍可切換至下方的標準方案。",
"custom_plan_title": "自訂方案",
"failed_to_start_trial": "無法開始試用。請再試一次。",
"manage_subscription": "管理訂閱",
"keep_current_plan": "保留目前方案",
"manage_billing_details": "管理卡片資訊與發票",
"monthly": "每月",
"most_popular": "最受歡迎",
"pending_change_removed": "已取消預定的方案變更。",
"pending_plan_badge": "已排程",
"pending_plan_change_description": "您的方案將於 {{date}} 切換至 {{plan}}。",
"pending_plan_change_title": "已排程的方案變更",
"pending_plan_cta": "已排程",
"per_month": "每月",
"per_year": "每年",
"plan_change_applied": "方案更新成功。",
"plan_change_scheduled": "方案變更已成功排程。",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
"plan_hobby": "興趣版",
"plan_hobby_description": "適合個人與小型團隊開始使用 Formbricks Cloud。",
"plan_hobby_feature_responses": "每月 250 次回應",
"plan_hobby_feature_workspaces": "1 個工作區",
"plan_pro": "專業版",
"plan_pro_description": "適合需要更高限制、自動化功能和彈性超量使用的成長中團隊。",
"plan_pro_feature_responses": "每月 2,000 次回應(動態超量計費)",
"plan_pro_feature_workspaces": "3 個工作區",
"plan_scale": "規模版",
"plan_scale_description": "適合需要更大容量、更強管理機制和更高回應量的大型團隊。",
"plan_scale_feature_responses": "每月 5,000 則回應(動態超額計費)",
"plan_scale_feature_workspaces": "5 個工作區",
"plan_selection_description": "比較 Hobby、Pro 和 Scale 方案,然後直接在 Formbricks 中切換方案。",
"plan_selection_title": "選擇您的方案",
"plan_unknown": "未知",
"remove_branding": "移除品牌",
"retry_setup": "重新設定",
"scale_banner_description": "加入 Scale 方案,解鎖更高限制、團隊協作和進階安全功能。",
"scale_banner_title": "準備好升級規模了嗎?",
"scale_feature_api": "完整 API 存取",
"scale_feature_quota": "額度管理",
"scale_feature_spam": "垃圾訊息防護",
"scale_feature_teams": "團隊與存取權限",
"select_plan_header_subtitle": "無需信用卡,完全沒有附加條件。",
"select_plan_header_title": "立即發送專業、無品牌標記的問卷調查!",
"select_plan_header_title": "完美整合的問卷調查,100% 展現你的品牌。",
"status_trialing": "試用版",
"stay_on_hobby_plan": "我想繼續使用 Hobby 方案",
"stripe_setup_incomplete": "帳單設定尚未完成",
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
"subscription": "訂閱",
"subscription_description": "管理您的訂閱方案並監控用量",
"switch_at_period_end": "週期結束時切換",
"switch_plan_now": "立即切換方案",
"this_includes": "包含內容",
"trial_alert_description": "新增付款方式以繼續使用所有功能。",
"trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。",
"trial_feature_api_access": "獲得完整 API 存取權限",
"trial_feature_collaboration": "所有團隊與協作功能",
"trial_feature_email_followups": "設定電子郵件追蹤",
"trial_feature_quotas": "管理配額",
"trial_feature_webhooks": "設定自訂 Webhook",
"trial_feature_whitelabel": "完全白標問卷調查",
"trial_feature_api_access": "API 存取",
"trial_feature_attribute_segmentation": "基於屬性的分群",
"trial_feature_contact_segment_management": "聯絡人與分群管理",
"trial_feature_email_followups": "電子郵件追蹤",
"trial_feature_hide_branding": "隱藏 Formbricks 品牌標識",
"trial_feature_mobile_sdks": "iOS 與 Android SDK",
"trial_feature_respondent_identification": "受訪者識別",
"trial_feature_unlimited_seats": "無限座位數",
"trial_feature_webhooks": "自訂 Webhook",
"trial_no_credit_card": "14 天試用,無需信用卡",
"trial_title": "免費試用 Pro 功能!",
"trial_payment_method_added_description": "一切就緒!試用期結束後,您的 Pro 方案將自動繼續。",
"trial_title": "免費獲得 Formbricks Pro",
"unlimited_responses": "無限回應",
"unlimited_workspaces": "無限工作區",
"upgrade": "升級",
"upgrade_now": "立即升級",
"usage_cycle": "Usage cycle",
"used": "已使用",
"yearly": "年繳",
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
"your_plan": "您的方案"
},
"domain": {
@@ -1031,6 +1081,25 @@
"enterprise_features": "企業版功能",
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
"license_feature_access_control": "存取控制 (RBAC)",
"license_feature_audit_logs": "稽核日誌",
"license_feature_contacts": "聯絡人與區隔",
"license_feature_projects": "工作區",
"license_feature_quotas": "配額",
"license_feature_remove_branding": "移除品牌標識",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "垃圾訊息防護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "雙重驗證",
"license_feature_whitelabel": "白標電子郵件",
"license_features_table_access": "存取權限",
"license_features_table_description": "此執行個體目前可使用的企業功能與限制。",
"license_features_table_disabled": "已停用",
"license_features_table_enabled": "已啟用",
"license_features_table_feature": "功能",
"license_features_table_title": "授權功能",
"license_features_table_unlimited": "無限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
"license_status": "授權狀態",
@@ -1352,7 +1421,6 @@
"custom_hostname": "自訂主機名稱",
"customize_survey_logo": "自訂問卷標誌",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
"delete_anyways": "仍要刪除",
"delete_block": "刪除區塊",
@@ -1390,6 +1458,7 @@
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
"everyone": "所有人",
"expand_preview": "展開預覽",
"external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1615,6 +1684,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。",
"response_limits_redirections_and_more": "回應限制、重新導向等。",
"response_options": "回應選項",
"reverse_order_occasionally": "偶爾反轉順序",
"reverse_order_occasionally_except_last": "偶爾反轉順序(最後一項除外)",
"roundness": "圓角",
"roundness_description": "調整邊角的圓潤程度。",
"row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1643,6 +1714,7 @@
"show_survey_maximum_of": "最多顯示問卷",
"show_survey_to_users": "將問卷顯示給 % 的使用者",
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
"shrink_preview": "收合預覽",
"simple": "簡單",
"six_points": "6 分",
"smiley": "表情符號",
@@ -1658,10 +1730,12 @@
"styling_set_to_theme_styles": "樣式設定為主題樣式",
"subheading": "副標題",
"subtract": "減 -",
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
"survey_completed_heading": "問卷已完成",
"survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定",
"survey_placement": "問卷位置",
"survey_preview": "問卷預覽 👀",
"survey_styling": "表單樣式設定",
"survey_trigger": "問卷觸發器",
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
@@ -3012,7 +3086,7 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_question_2_subheader": "這是一個範例說明。",
"preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎",
"preview_survey_question_open_text_headline": "還有其他想分享的嗎?",
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
"preview_survey_welcome_card_headline": "歡迎!",
@@ -3267,7 +3341,7 @@
"workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!",
"follow_up_label": "還有什麼想補充的嗎",
"follow_up_label": "還有其他想補充的嗎?",
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
"generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?",
@@ -1,4 +1,5 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -18,6 +19,7 @@ interface LanguageDropdownProps {
}
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
const { t } = useTranslation();
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
if (enabledLanguages.length <= 1) {
@@ -27,7 +29,10 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" title="Select Language" aria-label="Select Language">
<Button
variant="secondary"
title={t("common.select_language")}
aria-label={t("common.select_language")}>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
@@ -2,6 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
@@ -68,7 +69,7 @@ export const createTagToResponseAction = authenticatedActionClient
const tagEnvironment = await getTag(parsedInput.tagId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
throw new ResourceNotFoundError("Environment", null);
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
@@ -113,7 +114,7 @@ export const deleteTagOnResponseAction = authenticatedActionClient
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
throw new ResourceNotFoundError("Environment", null);
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
@@ -5,7 +5,9 @@ import { useTranslation } from "react-i18next";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -15,6 +17,7 @@ interface ElementSkipProps {
elements: TSurveyElement[];
isFirstElementAnswered?: boolean;
responseData: TResponseData;
locale: TUserLocale;
}
export const ElementSkip = ({
@@ -23,8 +26,10 @@ export const ElementSkip = ({
elements,
isFirstElementAnswered,
responseData,
locale,
}: ElementSkipProps) => {
const { t } = useTranslation();
const dateFormats = getSurveyDateFormatMap(elements);
return (
<div>
{skippedElements && (
@@ -81,7 +86,11 @@ export const ElementSkip = ({
},
"default"
),
responseData
responseData,
undefined,
false,
locale,
dateFormats
)
)}
</p>
@@ -120,7 +129,11 @@ export const ElementSkip = ({
},
"default"
),
responseData
responseData,
undefined,
false,
locale,
dateFormats
)
)}
</p>
@@ -3,11 +3,12 @@ import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
@@ -21,6 +22,7 @@ interface RenderResponseProps {
element: TSurveyElement;
survey: TSurvey;
language: string | null;
locale: TUserLocale;
isExpanded?: boolean;
showId: boolean;
}
@@ -30,6 +32,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
element,
survey,
language,
locale,
isExpanded = true,
showId,
}) => {
@@ -63,9 +66,8 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break;
case TSurveyElementTypeEnum.Date:
if (typeof responseData === "string") {
const parsedDate = new Date(responseData);
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
const formattedDate =
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
}

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