Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent cacedbd03a fix: prevent custom scripts from accessing document.body before React hydration
- Add ensureBodyExists() to wait for document.body availability
- Wrap inline script content to defer execution until DOM is ready
- Add defensive checks to prevent race conditions
- Skip wrapping for scripts that already have DOM-ready checks
- Only wrap inline scripts, not external scripts with src attribute

Fixes FORMBRICKS-RW
2026-03-12 20:59:00 +00:00
111 changed files with 1033 additions and 6042 deletions
+1
View File
@@ -150,6 +150,7 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PRICING_TABLE_ID=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -1,12 +1,8 @@
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;
@@ -26,16 +22,6 @@ 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} />;
};
@@ -28,7 +28,6 @@ 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";
@@ -168,20 +167,6 @@ 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 (
@@ -256,13 +241,6 @@ 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>
@@ -60,7 +60,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[
{
text: IS_FORMBRICKS_CLOUD
? t("common.upgrade_plan")
? t("common.start_free_trial")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing`
@@ -64,17 +64,15 @@ 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,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
projectId: parsedInput.projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { project } = useEnvironment();
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,6 +128,7 @@ 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.upgrade_plan") : t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
@@ -50,7 +50,6 @@ 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",
},
}));
@@ -139,7 +138,6 @@ 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,7 +212,6 @@ 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, result.survey);
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -1,14 +1,12 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { 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
@@ -26,14 +24,6 @@ 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",
@@ -108,13 +98,6 @@ 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);
@@ -214,315 +197,4 @@ 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,15 +1,12 @@
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { 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,
oldSurvey?: TSurvey
organization: TOrganization
): Promise<Response | null> => {
if (surveyData.recaptcha?.enabled) {
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id);
@@ -25,46 +22,5 @@ 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;
};
@@ -1,15 +1,12 @@
"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({
@@ -36,16 +33,6 @@ 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;
+17 -57
View File
@@ -372,7 +372,7 @@ checksums:
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/sort_by: 8adf3dbc5668379558957662f0c43563
common/start_free_trial: e346e4ed7d138dcc873db187922369da
common/start_free_trial: 4fab76a3fc5d5c94e3248cd279cfdd14
common/status: 4e1fcce15854d824919b4a582c697c90
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
@@ -407,9 +407,6 @@ checksums:
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
@@ -417,7 +414,6 @@ 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
@@ -917,80 +913,44 @@ 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/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/cancelling: 6e46e789720395bfa1e3a4b3b1519634
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
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/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
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: b15a9d86b819a7fae8e956a50572184c
environments/settings/billing/select_plan_header_title: d851e9fa093ddb248924cf99e1d79b4e
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: 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_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_no_credit_card: 01c70aa6e1001815a9a11951394923ca
environments/settings/billing/trial_payment_method_added_description: 872a5c557f56bafc9b7ec4895f9c33e8
environments/settings/billing/trial_title: f2c3791c1fb2970617ec0f2d243a931b
environments/settings/billing/trial_title: 23d0d2cbe306ae0f784b8289bf66a2c7
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
+2
View File
@@ -85,6 +85,7 @@ 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(
@@ -202,6 +203,7 @@ 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,
+6 -1
View File
@@ -308,7 +308,12 @@ export const deleteOrganization = async (organizationId: string) => {
const stripeCustomerId = deletedOrganization.billing?.stripeCustomerId;
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
await cleanupStripeCustomer(stripeCustomerId);
cleanupStripeCustomer(stripeCustomerId).catch((error) => {
logger.error(
{ error, organizationId, stripeCustomerId },
"Failed to clean up Stripe customer after organization deletion"
);
});
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
+17 -57
View File
@@ -399,7 +399,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": "Kostenlose Testversion starten",
"start_free_trial": "Kostenlos starten",
"status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"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",
"cancelling": "Wird storniert",
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
"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.",
"manage_subscription": "Abonnement verwalten",
"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": "Nahtlos integrierte Umfragen, 100% deine Marke.",
"select_plan_header_title": "Versende noch heute professionelle Umfragen ohne Branding!",
"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": "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_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_no_credit_card": "14 Tage Testversion, keine Kreditkarte erforderlich",
"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!",
"trial_title": "Pro-Funktionen kostenlos testen!",
"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": {
+17 -57
View File
@@ -399,7 +399,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",
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"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",
"cancelling": "Cancelling",
"failed_to_start_trial": "Failed to start trial. Please try again.",
"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.",
"manage_subscription": "Manage subscription",
"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": "Seamlessly integrated surveys, 100% your brand.",
"select_plan_header_title": "Ship professional, unbranded surveys today!",
"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": "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_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_no_credit_card": "14 days trial, no credit card required",
"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!",
"trial_title": "Try Pro features 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": {
+16 -56
View File
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
},
"billing": {
"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",
"cancelling": "Cancelando",
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
"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.",
"manage_subscription": "Gestionar suscripción",
"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": "Encuestas perfectamente integradas, 100% tu marca.",
"select_plan_header_title": "¡Lanza encuestas profesionales sin marca hoy mismo!",
"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 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_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_no_credit_card": "Prueba de 14 días, sin tarjeta de crédito",
"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!",
"trial_title": "¡Prueba las funciones 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": {
+17 -57
View File
@@ -399,7 +399,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": "Commencer l'essai gratuit",
"start_free_trial": "Essayer gratuitement",
"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",
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
},
"billing": {
"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",
"cancelling": "Annulation en cours",
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
"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.",
"manage_subscription": "Gérer l'abonnement",
"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": "Sondages parfaitement intégrés, 100 % à ton image.",
"select_plan_header_title": "Envoyez des sondages professionnels et personnalisés dès aujourd'hui !",
"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 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_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_no_credit_card": "Essai de 14 jours, aucune carte bancaire requise",
"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 !",
"trial_title": "Essaie les fonctionnalités 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": {
+17 -57
View File
@@ -399,7 +399,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óbaverzió indítása",
"start_free_trial": "Ingyenes próba 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",
@@ -434,9 +434,6 @@
"title": "Cím",
"top_left": "Balra fent",
"top_right": "Jobbra fent",
"trial_days_remaining": "{count} nap van hátra a próbaidőszakból",
"trial_expired": "A próbaidőszak lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakból",
"try_again": "Próbálja újra",
"type": "Típus",
"unknown_survey": "Ismeretlen kérdőív",
@@ -444,7 +441,6 @@
"update": "Frissítés",
"updated": "Frissítve",
"updated_at": "Frissítve",
"upgrade_plan": "Csomag frissítése",
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
},
"billing": {
"add_payment_method": "Fizetési mód hozzáadása",
"add_payment_method_to_upgrade_tooltip": "Kérjük, adjon hozzá egy fizetési módot fent a fizetős csomagra való frissítéshez",
"billing_interval_toggle": "Számlázási időszak",
"current_plan_badge": "Jelenlegi",
"current_plan_cta": "Jelenlegi csomag",
"custom_plan_description": "A szervezete egyedi számlázási beállítással rendelkezik. Továbbra is válthat az alábbi standard csomagok egyikére.",
"custom_plan_title": "Egyedi csomag",
"cancelling": "Lemondás folyamatban",
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
"keep_current_plan": "Jelenlegi csomag megtartása",
"manage_billing_details": "Kártyaadatok és számlák kezelése",
"monthly": "Havi",
"most_popular": "Legnépszerűbb",
"pending_change_removed": "Az ütemezett csomagváltás eltávolítva.",
"pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {{date}}-án átvált erre: {{plan}}.",
"pending_plan_change_title": "Ütemezett csomagváltás",
"pending_plan_cta": "Ütemezett",
"per_month": "havonta",
"per_year": "évente",
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltás sikeresen ütemezve.",
"manage_subscription": "Előfizetés kezelése",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Minden, ami a Hobby csomagban",
"plan_feature_everything_in_pro": "Minden, ami a Pro csomagban",
"plan_hobby": "Hobby",
"plan_hobby_description": "Magánszemélyek és kisebb csapatok számára, akik most kezdik a Formbricks Cloud használatát.",
"plan_hobby_feature_responses": "250 válasz / hó",
"plan_hobby_feature_workspaces": "1 munkaterület",
"plan_pro": "Pro",
"plan_pro_description": "Növekvő csapatok számára, amelyeknek magasabb korlátokra, automatizálásokra és dinamikus túlhasználatra van szükségük.",
"plan_pro_feature_responses": "2 000 válasz / hó (dinamikus túlhasználat)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Scale",
"plan_scale_description": "Nagyobb csapatok számára, amelyeknek nagyobb kapacitásra, erősebb irányításra és magasabb 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": "Hasonlítsa össze a Hobby, Pro és Scale csomagokat, majd váltson csomagot közvetlenül a Formbricks alkalmazásból.",
"plan_selection_title": "Válassza ki az Ön csomagját",
"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": "Zökkenőmentesen integrált felmérések, 100%-ban az Ön márkája.",
"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.",
"subscription": "Előfizetés",
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot",
"switch_at_period_end": "Váltás az időszak végén",
"switch_plan_now": "Csomag váltása most",
"this_includes": "Ez tartalmazza",
"trial_alert_description": "Adjon hozzá fizetési módot, hogy megtarthassa a hozzáférést az összes funkcióhoz.",
"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": "API-hozzáférés",
"trial_feature_attribute_segmentation": "Attribútumalapú szegmentálás",
"trial_feature_contact_segment_management": "Kapcsolat- és szegmenskezelés",
"trial_feature_email_followups": "E-mail követések",
"trial_feature_hide_branding": "Formbricks márkajelzés 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ú felhasználói hely",
"trial_feature_webhooks": "Egyéni webhookok",
"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_payment_method_added_description": "Minden rendben! A Pro csomag automatikusan folytatódik a próbaidőszak lejárta után.",
"trial_title": "Szerezze meg a Formbricks Pro-t ingyen!",
"trial_title": "Próbálja ki a Pro funkciókat ingyen!",
"unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés",
"upgrade_now": "Frissítés most",
"usage_cycle": "Usage cycle",
"used": "felhasználva",
"yearly": "Éves",
"yearly_checkout_unavailable": "Az éves fizetés még nem érhető el. Kérjük, adjon hozzá fizetési módot egy havi előfizetéshez, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
"your_plan": "Az Ön csomagja"
},
"domain": {
+16 -56
View File
@@ -434,9 +434,6 @@
"title": "タイトル",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "トライアル期間の残り{count}日",
"trial_expired": "トライアル期間が終了しました",
"trial_one_day_remaining": "トライアル期間の残り1日",
"try_again": "もう一度お試しください",
"type": "種類",
"unknown_survey": "不明なフォーム",
@@ -444,7 +441,6 @@
"update": "更新",
"updated": "更新済み",
"updated_at": "更新日時",
"upgrade_plan": "プランをアップグレード",
"upload": "アップロード",
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
@@ -972,80 +968,44 @@
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
},
"billing": {
"add_payment_method": "支払い方法を追加",
"add_payment_method_to_upgrade_tooltip": "有料プランにアップグレードするには、上記で支払い方法を追加してください",
"billing_interval_toggle": "請求間隔",
"current_plan_badge": "現在のプラン",
"current_plan_cta": "現在のプラン",
"custom_plan_description": "あなたの組織はカスタム請求設定を利用しています。以下の標準プランに切り替えることもできます。",
"custom_plan_title": "カスタムプラン",
"cancelling": "キャンセル中",
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
"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": "プラン変更を予約しました。",
"manage_subscription": "サブスクリプションを管理",
"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": "シームレスに統合されたアンケート、100%あなたのブランド。",
"select_plan_header_title": "今すぐプロフェッショナルなブランドフリーのアンケートを配信しよう!",
"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_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_feature_api_access": "フルAPIアクセスを利用",
"trial_feature_collaboration": "すべてのチーム・コラボレーション機能",
"trial_feature_email_followups": "メールフォローアップの設定",
"trial_feature_quotas": "クォータの管理",
"trial_feature_webhooks": "カスタムWebhookの設定",
"trial_feature_whitelabel": "完全ホワイトラベル対応のアンケート",
"trial_no_credit_card": "14日間トライアル、クレジットカード不要",
"trial_payment_method_added_description": "準備完了です!トライアル終了後、Proプランが自動的に継続されます。",
"trial_title": "Formbricks Proを無料で入手しよう!",
"trial_title": "Pro機能を無料でお試し!",
"unlimited_responses": "無制限の回答",
"unlimited_workspaces": "無制限ワークスペース",
"upgrade": "アップグレード",
"upgrade_now": "今すぐアップグレード",
"usage_cycle": "Usage cycle",
"used": "使用済み",
"yearly": "年間",
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
"your_plan": "ご利用プラン"
},
"domain": {
+17 -57
View File
@@ -399,7 +399,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": "Start gratis proefperiode",
"start_free_trial": "Gratis proefperiode starten",
"status": "Status",
"step_by_step_manual": "Stap voor stap handleiding",
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
},
"billing": {
"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",
"cancelling": "Bezig met annuleren",
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
"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.",
"manage_subscription": "Abonnement beheren",
"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": "Naadloos geïntegreerde enquêtes, 100% jouw merk.",
"select_plan_header_title": "Verstuur vandaag nog professionele, ongemerkte enquêtes!",
"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": "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_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_no_credit_card": "14 dagen proefperiode, geen creditcard vereist",
"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!",
"trial_title": "Probeer Pro-functies 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": {
+17 -57
View File
@@ -399,7 +399,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 gratuito",
"start_free_trial": "Iniciar Teste Grátis",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"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",
"cancelling": "Cancelando",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
"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.",
"manage_subscription": "Gerenciar assinatura",
"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": "Pesquisas perfeitamente integradas, 100% sua marca.",
"select_plan_header_title": "Envie pesquisas profissionais e sem marca hoje mesmo!",
"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": "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_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_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
"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!",
"trial_title": "Experimente os recursos 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": {
+17 -57
View File
@@ -399,7 +399,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 gratuito",
"start_free_trial": "Iniciar Teste Grátis",
"status": "Estado",
"step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"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",
"cancelling": "A cancelar",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
"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.",
"manage_subscription": "Gerir subscrição",
"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": "Inquéritos perfeitamente integrados, 100% da tua marca.",
"select_plan_header_title": "Envia inquéritos profissionais sem marca hoje!",
"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": "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_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_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
"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!",
"trial_title": "Experimenta as funcionalidades 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": {
+17 -57
View File
@@ -399,7 +399,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 probă gratuită",
"start_free_trial": "Începe perioada de testare gratuită",
"status": "Stare",
"step_by_step_manual": "Manual pas cu pas",
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
},
"billing": {
"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",
"cancelling": "Anulare în curs",
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.",
"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.",
"manage_subscription": "Gestionează abonamentul",
"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": "Sondaje integrate perfect, 100% brandul tău.",
"select_plan_header_title": "Lansează chestionare profesionale, fără branding, astăzi!",
"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": "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_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_no_credit_card": "14 zile de probă, fără card necesar",
"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!",
"trial_title": "Încearcă funcțiile 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": {
+16 -56
View File
@@ -434,9 +434,6 @@
"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": "Неизвестный опрос",
@@ -444,7 +441,6 @@
"update": "Обновить",
"updated": "Обновлено",
"updated_at": "Обновлено",
"upgrade_plan": "Перейти на другой тариф",
"upload": "Загрузить",
"upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.",
"upload_input_description": "Кликните или перетащите файлы для загрузки.",
@@ -972,80 +968,44 @@
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
},
"billing": {
"add_payment_method": "Добавить способ оплаты",
"add_payment_method_to_upgrade_tooltip": "Пожалуйста, добавьте способ оплаты выше, чтобы перейти на платный тариф",
"billing_interval_toggle": "Интервал выставления счетов",
"current_plan_badge": "Текущий",
"current_plan_cta": "Текущий тариф",
"custom_plan_description": "Ваша организация использует индивидуальные настройки оплаты. Вы все равно можете переключиться на один из стандартных тарифов ниже.",
"custom_plan_title": "Индивидуальный тариф",
"cancelling": "Отмена",
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
"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": "Изменение тарифа успешно запланировано.",
"manage_subscription": "Управление подпиской",
"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": "Бесшовно интегрированные опросы, 100% ваш бренд.",
"select_plan_header_title": "Создавайте профессиональные опросы без брендинга уже сегодня!",
"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_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_feature_api_access": "Получите полный доступ к API",
"trial_feature_collaboration": "Все функции для работы в команде и совместной работы",
"trial_feature_email_followups": "Настройте последующие письма",
"trial_feature_quotas": "Управляйте квотами",
"trial_feature_webhooks": "Настройте собственные вебхуки",
"trial_feature_whitelabel": "Полностью персонализированные опросы без брендинга",
"trial_no_credit_card": "14 дней пробного периода, кредитная карта не требуется",
"trial_payment_method_added_description": "Всё готово! Твой тарифный план Pro продолжится автоматически после окончания пробного периода.",
"trial_title": "Получите Formbricks Pro бесплатно!",
"trial_title": "Попробуйте Pro функции бесплатно!",
"unlimited_responses": "Неограниченное количество ответов",
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
"upgrade": "Обновить",
"upgrade_now": "Обновить сейчас",
"usage_cycle": "Usage cycle",
"used": "использовано",
"yearly": "Годовой",
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
"your_plan": "Ваш тариф"
},
"domain": {
+16 -56
View File
@@ -434,9 +434,6 @@
"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",
@@ -444,7 +441,6 @@
"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.",
@@ -972,80 +968,44 @@
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
},
"billing": {
"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",
"cancelling": "Avbryter",
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
"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.",
"manage_subscription": "Hantera prenumeration",
"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": "Sömlöst integrerade undersökningar, 100% ditt varumärke.",
"select_plan_header_title": "Skicka professionella undersökningar utan varumärke idag!",
"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": "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_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_no_credit_card": "14 dagars provperiod, inget kreditkort krävs",
"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!",
"trial_title": "Testa Pro-funktioner 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": {
+17 -57
View File
@@ -399,7 +399,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": "文件存储 未设置,上传 可能 失败",
@@ -434,9 +434,6 @@
"title": "标题",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "试用期还剩 {count} 天",
"trial_expired": "您的试用期已过期",
"trial_one_day_remaining": "试用期还剩 1 天",
"try_again": "再试一次",
"type": "类型",
"unknown_survey": "未知调查",
@@ -444,7 +441,6 @@
"update": "更新",
"updated": "已更新",
"updated_at": "更新 于",
"upgrade_plan": "升级套餐",
"upload": "上传",
"upload_failed": "上传失败,请重试。",
"upload_input_description": "点击 或 拖动 上传 文件",
@@ -972,80 +968,44 @@
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
},
"billing": {
"add_payment_method": "添加支付方式",
"add_payment_method_to_upgrade_tooltip": "请先在上方添加付款方式以升级到付费套餐",
"billing_interval_toggle": "账单周期",
"current_plan_badge": "当前",
"current_plan_cta": "当前方案",
"custom_plan_description": "您的组织使用的是自定义计费设置。您仍然可以切换到下面的标准方案。",
"custom_plan_title": "自定义方案",
"cancelling": "正在取消",
"failed_to_start_trial": "试用启动失败,请重试。",
"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": "方案变更预定成功。",
"manage_subscription": "管理订阅",
"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": "无缝集成的调查问卷,100% 展现您的品牌。",
"select_plan_header_title": "立即发布专业的无品牌调查!",
"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_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_feature_api_access": "获取完整 API 访问权限",
"trial_feature_collaboration": "所有团队和协作功能",
"trial_feature_email_followups": "设置邮件跟进",
"trial_feature_quotas": "管理配额",
"trial_feature_webhooks": "设置自定义 Webhook",
"trial_feature_whitelabel": "完全白标化的问卷调查",
"trial_no_credit_card": "14 天试用,无需信用卡",
"trial_payment_method_added_description": "一切就绪!试用期结束后,您的专业版计划将自动继续。",
"trial_title": "免费获取 Formbricks Pro!",
"trial_title": "免费试用专业版功能!",
"unlimited_responses": "无限反馈",
"unlimited_workspaces": "无限工作区",
"upgrade": "升级",
"upgrade_now": "立即升级",
"usage_cycle": "Usage cycle",
"used": "已用",
"yearly": "按年付费",
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
"your_plan": "你的套餐"
},
"domain": {
+16 -56
View File
@@ -434,9 +434,6 @@
"title": "標題",
"top_left": "左上",
"top_right": "右上",
"trial_days_remaining": "試用期剩餘 {count} 天",
"trial_expired": "您的試用期已結束",
"trial_one_day_remaining": "試用期剩餘 1 天",
"try_again": "再試一次",
"type": "類型",
"unknown_survey": "未知問卷",
@@ -444,7 +441,6 @@
"update": "更新",
"updated": "已更新",
"updated_at": "更新時間",
"upgrade_plan": "升級方案",
"upload": "上傳",
"upload_failed": "上傳失敗。請再試一次。",
"upload_input_description": "點擊或拖曳以上傳檔案。",
@@ -972,80 +968,44 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"add_payment_method": "新增付款方式",
"add_payment_method_to_upgrade_tooltip": "請先在上方新增付款方式以升級至付費方案",
"billing_interval_toggle": "帳單週期",
"current_plan_badge": "目前",
"current_plan_cta": "目前方案",
"custom_plan_description": "您的組織使用自訂計費設定。您仍可切換至下方的標準方案。",
"custom_plan_title": "自訂方案",
"cancelling": "正在取消",
"failed_to_start_trial": "無法開始試用。請再試一次。",
"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": "方案變更已成功排程。",
"manage_subscription": "管理訂閱",
"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": "完美整合的問卷調查,100% 展現你的品牌。",
"select_plan_header_title": "立即發送專業、無品牌標記的問卷調查!",
"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_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_feature_api_access": "獲得完整 API 存取權限",
"trial_feature_collaboration": "所有團隊與協作功能",
"trial_feature_email_followups": "設定電子郵件追蹤",
"trial_feature_quotas": "管理配額",
"trial_feature_webhooks": "設定自訂 Webhook",
"trial_feature_whitelabel": "完全白標問卷調查",
"trial_no_credit_card": "14 天試用,無需信用卡",
"trial_payment_method_added_description": "一切就緒!試用期結束後,您的 Pro 方案將自動繼續。",
"trial_title": "免費獲得 Formbricks Pro",
"trial_title": "免費試用 Pro 功能!",
"unlimited_responses": "無限回應",
"unlimited_workspaces": "無限工作區",
"upgrade": "升級",
"upgrade_now": "立即升級",
"usage_cycle": "Usage cycle",
"used": "已使用",
"yearly": "年繳",
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
"your_plan": "您的方案"
},
"domain": {
@@ -217,7 +217,7 @@ describe("utils", () => {
});
describe("logApiError", () => {
test("logs API error details with method and path", () => {
test("logs API error details", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -228,7 +228,7 @@ describe("utils", () => {
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -238,11 +238,9 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
});
@@ -277,8 +275,6 @@ describe("utils", () => {
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "",
method: "GET",
path: "/api/test",
error,
});
@@ -289,7 +285,7 @@ describe("utils", () => {
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
test("log API error details with SENTRY_DSN set", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -299,23 +295,11 @@ describe("utils", () => {
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -325,60 +309,20 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
mockRequest.headers.set("x-request-id", "456");
const error: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
});
+1 -8
View File
@@ -6,18 +6,13 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
// Use Sentry scope to add correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
@@ -29,8 +24,6 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");
@@ -1,4 +1,3 @@
import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
@@ -14,7 +13,6 @@ import {
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = async (request: Request, props: { params: Promise<TContactLinkParams> }) =>
@@ -49,17 +47,6 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
});
}
const organizationId = await getOrganizationIdFromSurveyId(params.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
],
});
}
const surveyResult = await getSurvey(params.surveyId);
if (!surveyResult.ok) {
+1 -1
View File
@@ -135,7 +135,7 @@ describe("Auth Utils", () => {
expect(hashedComplex.length).toBe(60);
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
}, 15000);
});
test("should handle bcrypt errors gracefully and log warning", async () => {
// Save the original bcryptjs implementation
-172
View File
@@ -1,172 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { startHobbyAction, startProTrialAction } from "./actions";
const mocks = vi.hoisted(() => ({
checkAuthorizationUpdated: vi.fn(),
getOrganization: vi.fn(),
createProTrialSubscription: vi.fn(),
ensureCloudStripeSetupForOrganization: vi.fn(),
ensureStripeCustomerForOrganization: vi.fn(),
reconcileCloudStripeSubscriptionsForOrganization: vi.fn(),
syncOrganizationBillingFromStripe: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
createCustomerPortalSession: vi.fn(),
createSetupCheckoutSession: vi.fn(),
isSubscriptionCancelled: vi.fn(),
stripeCustomerSessionsCreate: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://app.formbricks.com",
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: mocks.getOrganization,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("@/modules/ee/billing/api/lib/create-customer-portal-session", () => ({
createCustomerPortalSession: mocks.createCustomerPortalSession,
}));
vi.mock("@/modules/ee/billing/api/lib/create-setup-checkout-session", () => ({
createSetupCheckoutSession: mocks.createSetupCheckoutSession,
}));
vi.mock("@/modules/ee/billing/api/lib/is-subscription-cancelled", () => ({
isSubscriptionCancelled: mocks.isSubscriptionCancelled,
}));
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
createProTrialSubscription: mocks.createProTrialSubscription,
ensureCloudStripeSetupForOrganization: mocks.ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization: mocks.ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization: mocks.reconcileCloudStripeSubscriptionsForOrganization,
syncOrganizationBillingFromStripe: mocks.syncOrganizationBillingFromStripe,
}));
vi.mock("@/modules/ee/billing/lib/stripe-client", () => ({
stripeClient: {
customerSessions: {
create: mocks.stripeCustomerSessionsCreate,
},
},
}));
describe("billing actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: null,
},
});
mocks.ensureStripeCustomerForOrganization.mockResolvedValue({ customerId: "cus_1" });
mocks.createProTrialSubscription.mockResolvedValue(undefined);
mocks.reconcileCloudStripeSubscriptionsForOrganization.mockResolvedValue(undefined);
mocks.syncOrganizationBillingFromStripe.mockResolvedValue(undefined);
});
test("startHobbyAction ensures a customer, reconciles hobby, and syncs billing", async () => {
const result = await startHobbyAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startHobbyAction reuses an existing stripe customer id", async () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: "cus_existing",
},
});
const result = await startHobbyAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startProTrialAction uses ensured customer when org snapshot has no stripe customer id", async () => {
const result = await startProTrialAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
test("startProTrialAction reuses an existing stripe customer id", async () => {
mocks.getOrganization.mockResolvedValue({
id: "org_1",
billing: {
stripeCustomerId: "cus_existing",
},
});
const result = await startProTrialAction({
ctx: { user: { id: "user_1" } },
parsedInput: { organizationId: "org_1" },
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
});
+66 -238
View File
@@ -2,8 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZCloudBillingInterval } from "@formbricks/types/organizations";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { WEBAPP_URL } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -11,17 +10,14 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
import { createSetupCheckoutSession } from "@/modules/ee/billing/api/lib/create-setup-checkout-session";
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
import {
createPaidPlanCheckoutSession,
createProTrialSubscription,
createScaleTrialSubscription,
ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization,
switchOrganizationToCloudPlan,
syncOrganizationBillingFromStripe,
undoPendingOrganizationPlanChange,
} from "@/modules/ee/billing/lib/organization-billing";
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
const ZManageSubscriptionAction = z.object({
environmentId: ZId,
@@ -49,7 +45,7 @@ export const manageSubscriptionAction = authenticatedActionClient
}
if (!organization.billing.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
}
ctx.auditLoggingCtx.organizationId = organizationId;
@@ -57,64 +53,75 @@ export const manageSubscriptionAction = authenticatedActionClient
organization.billing.stripeCustomerId,
`${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`
);
ctx.auditLoggingCtx.newObject = { portalSessionCreated: true };
ctx.auditLoggingCtx.newObject = { portalSession: result };
return result;
})
);
const ZCreatePlanCheckoutAction = z.object({
environmentId: ZId,
targetPlan: z.enum(["pro", "scale"]),
targetInterval: ZCloudBillingInterval,
const ZIsSubscriptionCancelledAction = z.object({
organizationId: ZId,
});
export const createPlanCheckoutAction = authenticatedActionClient
.inputSchema(ZCreatePlanCheckoutAction)
.action(
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
export const isSubscriptionCancelledAction = authenticatedActionClient
.inputSchema(ZIsSubscriptionCancelledAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
return await isSubscriptionCancelled(parsedInput.organizationId);
});
if (!organization.billing?.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
const ZCreatePricingTableCustomerSessionAction = z.object({
environmentId: ZId,
});
if (organization.billing.stripe?.subscriptionId) {
throw new OperationNotAllowedError("paid_checkout_requires_no_existing_subscription");
}
export const createPricingTableCustomerSessionAction = authenticatedActionClient
.inputSchema(ZCreatePricingTableCustomerSessionAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
const checkoutUrl = await createPaidPlanCheckoutSession({
organizationId,
customerId: organization.billing.stripeCustomerId,
environmentId: parsedInput.environmentId,
plan: parsedInput.targetPlan,
interval: parsedInput.targetInterval,
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = {
checkoutCreated: true,
targetPlan: parsedInput.targetPlan,
targetInterval: parsedInput.targetInterval,
};
if (!organization.billing?.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
return checkoutUrl;
})
);
if (!stripeClient) {
return { clientSecret: null };
}
const customerSession = await stripeClient.customerSessions.create({
customer: organization.billing.stripeCustomerId,
components: {
pricing_table: {
enabled: true,
},
},
});
return { clientSecret: customerSession.client_secret ?? null };
});
const ZRetryStripeSetupAction = z.object({
organizationId: ZId,
@@ -138,59 +145,11 @@ export const retryStripeSetupAction = authenticatedActionClient
return { success: true };
});
const ZCreateTrialPaymentCheckoutAction = z.object({
environmentId: ZId,
});
export const createTrialPaymentCheckoutAction = authenticatedActionClient
.inputSchema(ZCreateTrialPaymentCheckoutAction)
.action(
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
const subscriptionId = organization.billing.stripe?.subscriptionId;
if (!subscriptionId) {
throw new ResourceNotFoundError("subscription", organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
const returnUrl = `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`;
const checkoutUrl = await createSetupCheckoutSession(
organization.billing.stripeCustomerId,
subscriptionId,
returnUrl,
organizationId
);
ctx.auditLoggingCtx.newObject = { setupCheckoutCreated: true };
return checkoutUrl;
})
);
const ZStartScaleTrialAction = z.object({
organizationId: ZId,
});
export const startHobbyAction = authenticatedActionClient
export const startScaleTrialAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
@@ -209,143 +168,12 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
const customerId =
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
if (!organization.billing?.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await createScaleTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "scale-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
export const startProTrialAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
}
const customerId =
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
const ZChangeBillingPlanAction = z.discriminatedUnion("targetPlan", [
z.object({
environmentId: ZId,
targetPlan: z.literal("hobby"),
targetInterval: z.literal("monthly"),
}),
z.object({
environmentId: ZId,
targetPlan: z.enum(["pro", "scale"]),
targetInterval: ZCloudBillingInterval,
}),
]);
export const changeBillingPlanAction = authenticatedActionClient.inputSchema(ZChangeBillingPlanAction).action(
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
const result = await switchOrganizationToCloudPlan({
organizationId,
customerId: organization.billing.stripeCustomerId,
targetPlan: parsedInput.targetPlan,
targetInterval: parsedInput.targetInterval,
});
if (result.mode === "immediate") {
await syncOrganizationBillingFromStripe(organizationId);
}
// Scheduled downgrades already persist the pending snapshot locally and
// the ensuing subscription_schedule webhook performs the full Stripe resync.
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = {
targetPlan: parsedInput.targetPlan,
targetInterval: parsedInput.targetInterval,
mode: result.mode,
};
return result;
})
);
const ZUndoPendingPlanChangeAction = z.object({
environmentId: ZId,
});
export const undoPendingPlanChangeAction = authenticatedActionClient
.inputSchema(ZUndoPendingPlanChangeAction)
.action(
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
await undoPendingOrganizationPlanChange(organizationId, organization.billing.stripeCustomerId);
await syncOrganizationBillingFromStripe(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
return { success: true };
})
);
@@ -1,52 +0,0 @@
import Stripe from "stripe";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
/**
* Creates a Stripe Checkout Session in `setup` mode so the customer can enter
* a payment method, billing address, and tax ID without creating a new subscription.
* After completion the webhook handler attaches the payment method to the existing
* trial subscription.
*/
export const createSetupCheckoutSession = async (
stripeCustomerId: string,
subscriptionId: string,
returnUrl: string,
organizationId: string
): Promise<string> => {
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
});
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currency = subscription.currency ?? "usd";
const session = await stripe.checkout.sessions.create({
mode: "setup",
customer: stripeCustomerId,
currency,
billing_address_collection: "required",
tax_id_collection: {
enabled: true,
required: "if_supported",
},
customer_update: {
address: "auto",
name: "auto",
},
success_url: `${returnUrl}?checkout_success=1`,
cancel_url: returnUrl,
metadata: {
organizationId,
subscriptionId,
},
});
if (!session.url) {
throw new Error("Stripe did not return a Checkout Session URL");
}
return session.url;
};
@@ -0,0 +1,50 @@
import { logger } from "@formbricks/logger";
import { getOrganization } from "@/lib/organization/service";
import { getStripeClient } from "./stripe-client";
export const isSubscriptionCancelled = async (
organizationId: string
): Promise<{
cancelled: boolean;
date: Date | null;
}> => {
try {
const stripe = getStripeClient();
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Team not found.");
let isNewTeam =
!organization.billing.stripeCustomerId ||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
if (!organization.billing.stripeCustomerId || isNewTeam) {
return {
cancelled: false,
date: null,
};
}
const subscriptions = await stripe.subscriptions.list({
customer: organization.billing.stripeCustomerId,
});
for (const subscription of subscriptions.data) {
if (subscription.cancel_at_period_end) {
const periodEndTimestamp = subscription.cancel_at ?? subscription.items.data[0]?.current_period_end;
return {
cancelled: true,
date: periodEndTimestamp ? new Date(periodEndTimestamp * 1000) : null,
};
}
}
return {
cancelled: false,
date: null,
};
} catch (err) {
logger.error(err, "Error checking if subscription is cancelled");
return {
cancelled: false,
date: null,
};
}
};
@@ -12,55 +12,10 @@ const relevantEvents = new Set([
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.finalized",
"entitlements.active_entitlement_summary.updated",
"subscription_schedule.created",
"subscription_schedule.updated",
"subscription_schedule.released",
"subscription_schedule.canceled",
"subscription_schedule.completed",
]);
/**
* When a setup-mode Checkout Session completes, the customer has just provided a
* payment method + billing address. We attach that payment method as the default
* on the customer (for future invoices) and on the trial subscription so Stripe
* can charge it when the trial ends.
*/
const handleSetupCheckoutCompleted = async (
session: Stripe.Checkout.Session,
stripe: Stripe
): Promise<void> => {
if (session.mode !== "setup" || !session.setup_intent) return;
const setupIntentId =
typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent.id;
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
const paymentMethodId =
typeof setupIntent.payment_method === "string"
? setupIntent.payment_method
: setupIntent.payment_method?.id;
if (!paymentMethodId) {
logger.warn({ sessionId: session.id }, "Setup checkout completed but no payment method found");
return;
}
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
if (customerId) {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
}
const subscriptionId = session.metadata?.subscriptionId;
if (subscriptionId) {
await stripe.subscriptions.update(subscriptionId, {
default_payment_method: paymentMethodId,
});
}
};
const getMetadataOrganizationId = (eventObject: Stripe.Event.Data.Object): string | null => {
if (!("metadata" in eventObject) || !eventObject.metadata) {
return null;
@@ -146,10 +101,6 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
}
try {
if (event.type === "checkout.session.completed") {
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
@@ -1,38 +1,83 @@
"use client";
import { CheckIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Script from "next/script";
import { createElement, useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
type TCloudBillingInterval,
type TOrganization,
type TOrganizationStripePendingChange,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { TOrganization, TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import {
changeBillingPlanAction,
createPlanCheckoutAction,
createTrialPaymentCheckoutAction,
createPricingTableCustomerSessionAction,
isSubscriptionCancelledAction,
manageSubscriptionAction,
retryStripeSetupAction,
undoPendingPlanChangeAction,
} from "../actions";
import type { TStripeBillingCatalogDisplay } from "../lib/stripe-billing-catalog";
import { TrialAlert } from "./trial-alert";
import { UsageCard } from "./usage-card";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
const STRIPE_SUPPORTED_LOCALES = new Set([
"bg",
"cs",
"da",
"de",
"el",
"en",
"en-GB",
"es",
"es-419",
"et",
"fi",
"fil",
"fr",
"fr-CA",
"hr",
"hu",
"id",
"it",
"ja",
"ko",
"lt",
"lv",
"ms",
"mt",
"nb",
"nl",
"pl",
"pt",
"pt-BR",
"ro",
"ru",
"sk",
"sl",
"sv",
"th",
"tr",
"vi",
"zh",
"zh-HK",
"zh-TW",
]);
type TDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "unknown";
type TStandardPlan = "hobby" | "pro" | "scale";
const getStripeLocaleOverride = (locale?: string): string | undefined => {
if (!locale) return undefined;
const normalizedLocale = locale.trim();
if (STRIPE_SUPPORTED_LOCALES.has(normalizedLocale)) {
return normalizedLocale;
}
const baseLocale = normalizedLocale.split("-")[0];
if (STRIPE_SUPPORTED_LOCALES.has(baseLocale)) {
return baseLocale;
}
return undefined;
};
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
interface PricingTableProps {
organization: TOrganization;
@@ -42,22 +87,17 @@ interface PricingTableProps {
usageCycleStart: Date;
usageCycleEnd: Date;
hasBillingRights: boolean;
currentCloudPlan: TDisplayPlan;
currentBillingInterval: TCloudBillingInterval | null;
currentCloudPlan: "hobby" | "pro" | "scale" | "custom" | "unknown";
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
pendingChange: TOrganizationStripePendingChange | null;
stripePublishableKey: string | null;
stripePricingTableId: string | null;
isStripeSetupIncomplete: boolean;
trialDaysRemaining: number | null;
billingCatalog: TStripeBillingCatalogDisplay;
}
const STANDARD_PLAN_LEVEL: Record<TStandardPlan, number> = {
hobby: 0,
pro: 1,
scale: 2,
};
const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string) => {
const getCurrentCloudPlanLabel = (
plan: "hobby" | "pro" | "scale" | "custom" | "unknown",
t: (key: string) => string
) => {
if (plan === "hobby") return t("environments.settings.billing.plan_hobby");
if (plan === "pro") return t("environments.settings.billing.plan_pro");
if (plan === "scale") return t("environments.settings.billing.plan_scale");
@@ -65,78 +105,6 @@ const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string
return t("environments.settings.billing.plan_unknown");
};
const formatMoney = (currency: string, unitAmount: number | null, locale: string) => {
if (unitAmount == null) {
return "—";
}
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currency.toUpperCase(),
minimumFractionDigits: unitAmount % 100 === 0 ? 0 : 2,
}).format(unitAmount / 100);
};
const formatDate = (date: Date, locale: string) =>
date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
});
type TPlanCardData = {
plan: TStandardPlan;
interval: TCloudBillingInterval;
amount: string;
description: string;
features: string[];
};
const getPlanPeriodLabel = (
plan: TStandardPlan,
interval: TCloudBillingInterval,
t: (key: string) => string
) => {
if (plan === "hobby" || interval === "monthly") {
return t("environments.settings.billing.per_month");
}
return t("environments.settings.billing.per_year");
};
const getPlanChangePayload = (environmentId: string, plan: TStandardPlan, interval: TCloudBillingInterval) =>
plan === "hobby"
? {
environmentId,
targetPlan: "hobby" as const,
targetInterval: "monthly" as const,
}
: {
environmentId,
targetPlan: plan,
targetInterval: interval,
};
const getPlanChangeSuccessMessage = (
mode: "immediate" | "scheduled" | undefined,
t: (key: string) => string
) => {
if (mode === "scheduled") {
return t("environments.settings.billing.plan_change_scheduled");
}
return t("environments.settings.billing.plan_change_applied");
};
const getActionErrorMessage = (serverError: string, t: (key: string) => string) => {
if (serverError === "mixed_interval_checkout_unsupported") {
return t("environments.settings.billing.yearly_checkout_unavailable");
}
return t("common.something_went_wrong_please_try_again");
};
export const PricingTable = ({
environmentId,
organization,
@@ -146,134 +114,102 @@ export const PricingTable = ({
usageCycleEnd,
hasBillingRights,
currentCloudPlan,
currentBillingInterval,
currentSubscriptionStatus,
pendingChange,
stripePublishableKey,
stripePricingTableId,
isStripeSetupIncomplete,
trialDaysRemaining,
billingCatalog,
}: PricingTableProps) => {
const { t, i18n } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false);
const [isPlanActionPending, setIsPlanActionPending] = useState<string | null>(null);
const [selectedInterval, setSelectedInterval] = useState<TCloudBillingInterval>(
currentBillingInterval ?? "monthly"
);
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
string | null
>(null);
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const isTrialing = currentSubscriptionStatus === "trialing";
const hasPaymentMethod = organization.billing.stripe?.hasPaymentMethod === true;
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const currentPlanLevel =
currentCloudPlan === "hobby" || currentCloudPlan === "pro" || currentCloudPlan === "scale"
? STANDARD_PLAN_LEVEL[currentCloudPlan]
: null;
const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
const showPricingTable =
hasBillingRights && isUpgradeablePlan && !!stripePublishableKey && !!stripePricingTableId;
const canManageSubscription =
hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
const stripeLocaleOverride = useMemo(
() => getStripeLocaleOverride(i18n.resolvedLanguage ?? i18n.language),
[i18n.language, i18n.resolvedLanguage]
);
const stripePricingTableProps = useMemo(() => {
const props: Record<string, string> = {
"pricing-table-id": stripePricingTableId ?? "",
"publishable-key": stripePublishableKey ?? "",
};
if (stripeLocaleOverride) {
props["__locale-override"] = stripeLocaleOverride;
}
if (pricingTableCustomerSessionClientSecret) {
props["customer-session-client-secret"] = pricingTableCustomerSessionClientSecret;
} else {
props["client-reference-id"] = organization.id;
}
return props;
}, [
organization.id,
pricingTableCustomerSessionClientSecret,
stripeLocaleOverride,
stripePricingTableId,
stripePublishableKey,
]);
useEffect(() => {
if (searchParams.get("checkout_success")) {
const timer = setTimeout(() => router.refresh(), 2500);
return () => clearTimeout(timer);
const checkSubscriptionStatus = async () => {
if (!hasBillingRights || !canManageSubscription) {
setCancellingOn(null);
return;
}
try {
const isSubscriptionCancelledResponse = await isSubscriptionCancelledAction({
organizationId: organization.id,
});
if (isSubscriptionCancelledResponse?.data) {
setCancellingOn(isSubscriptionCancelledResponse.data.date);
}
} catch {
// Ignore permission/network failures here and keep rendering billing UI.
}
};
checkSubscriptionStatus();
}, [canManageSubscription, hasBillingRights, organization.id]);
useEffect(() => {
if (!showPricingTable) {
setPricingTableCustomerSessionClientSecret(null);
return;
}
}, [searchParams, router]);
const planCards = useMemo<TPlanCardData[]>(() => {
return [
{
plan: "hobby",
interval: "monthly",
amount: formatMoney(
billingCatalog.hobby.monthly.currency,
billingCatalog.hobby.monthly.unitAmount,
locale
),
description: t("environments.settings.billing.plan_hobby_description"),
features: [
t("environments.settings.billing.plan_hobby_feature_workspaces"),
t("environments.settings.billing.plan_hobby_feature_responses"),
],
},
{
plan: "pro",
interval: selectedInterval,
amount: formatMoney(
billingCatalog.pro[selectedInterval].currency,
billingCatalog.pro[selectedInterval].unitAmount,
locale
),
description: t("environments.settings.billing.plan_pro_description"),
features: [
t("environments.settings.billing.plan_feature_everything_in_hobby"),
t("environments.settings.billing.plan_pro_feature_workspaces"),
t("environments.settings.billing.plan_pro_feature_responses"),
],
},
{
plan: "scale",
interval: selectedInterval,
amount: formatMoney(
billingCatalog.scale[selectedInterval].currency,
billingCatalog.scale[selectedInterval].unitAmount,
locale
),
description: t("environments.settings.billing.plan_scale_description"),
features: [
t("environments.settings.billing.plan_feature_everything_in_pro"),
t("environments.settings.billing.plan_scale_feature_workspaces"),
t("environments.settings.billing.plan_scale_feature_responses"),
],
},
];
}, [billingCatalog, locale, selectedInterval, t]);
const persistEnvironmentId = () => {
if (globalThis.window !== undefined) {
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
}
};
const navigateToExternalUrl = (url: string) => {
if (globalThis.window !== undefined) {
globalThis.window.location.href = url;
}
};
const openBillingPortal = async () => {
const response = await manageSubscriptionAction({ environmentId });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data && typeof response.data === "string") {
router.push(response.data);
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
};
const openTrialPaymentCheckout = async () => {
try {
persistEnvironmentId();
const response = await createTrialPaymentCheckoutAction({ environmentId });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
const loadPricingTableCustomerSession = async () => {
try {
const response = await createPricingTableCustomerSessionAction({ environmentId });
setPricingTableCustomerSessionClientSecret(response?.data?.clientSecret ?? null);
} catch {
setPricingTableCustomerSessionClientSecret(null);
}
if (response?.data && typeof response.data === "string") {
navigateToExternalUrl(response.data);
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
} catch (error) {
console.error("Failed to create setup checkout session:", error);
toast.error(t("common.something_went_wrong_please_try_again"));
};
void loadPricingTableCustomerSession();
}, [environmentId, showPricingTable]);
const openCustomerPortal = async () => {
const manageSubscriptionResponse = await manageSubscriptionAction({
environmentId,
});
if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") {
router.push(manageSubscriptionResponse.data);
}
};
@@ -281,15 +217,11 @@ export const PricingTable = ({
setIsRetryingStripeSetup(true);
try {
const response = await retryStripeSetupAction({ organizationId: organization.id });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data) {
router.refresh();
return;
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
}
toast.error(t("common.something_went_wrong_please_try_again"));
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
@@ -297,152 +229,23 @@ export const PricingTable = ({
}
};
const redirectToPlanCheckout = async (
plan: Exclude<TStandardPlan, "hobby">,
interval: TCloudBillingInterval
): Promise<void> => {
if (existingSubscriptionId) {
await openTrialPaymentCheckout();
return;
}
if (interval === "yearly") {
toast.error(t("environments.settings.billing.yearly_checkout_unavailable"));
return;
}
persistEnvironmentId();
const response = await createPlanCheckoutAction({
environmentId,
targetPlan: plan,
targetInterval: interval,
});
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data && typeof response.data === "string") {
navigateToExternalUrl(response.data);
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
};
const handlePlanAction = async (plan: TStandardPlan, interval: TCloudBillingInterval) => {
const actionKey = `${plan}-${interval}`;
setIsPlanActionPending(actionKey);
try {
if (!hasPaymentMethod && plan !== "hobby") {
await redirectToPlanCheckout(plan, interval);
return;
}
const response = await changeBillingPlanAction(getPlanChangePayload(environmentId, plan, interval));
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
toast.success(getPlanChangeSuccessMessage(response?.data?.mode, t));
router.refresh();
} catch (error) {
console.error("Failed to change billing plan:", error);
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsPlanActionPending(null);
}
};
const undoPendingChange = async () => {
setIsPlanActionPending("undo");
try {
const response = await undoPendingPlanChangeAction({ environmentId });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data) {
toast.success(t("environments.settings.billing.pending_change_removed"));
router.refresh();
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
} catch (error) {
console.error("Failed to undo pending plan change:", error);
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsPlanActionPending(null);
}
};
const getCtaLabel = (plan: TStandardPlan, interval: TCloudBillingInterval) => {
const isCurrentSelection =
currentCloudPlan === plan && (plan === "hobby" || currentBillingInterval === interval);
if (isCurrentSelection) {
return t("environments.settings.billing.current_plan_cta");
}
const isPendingSelection =
pendingChange?.targetPlan === plan && (plan === "hobby" || pendingChange.targetInterval === interval);
if (isPendingSelection) {
return t("environments.settings.billing.pending_plan_cta");
}
if (!hasPaymentMethod && plan !== "hobby") {
return t("environments.settings.billing.upgrade_now");
}
if (currentPlanLevel === null) {
return t("environments.settings.billing.switch_plan_now");
}
return STANDARD_PLAN_LEVEL[plan] > currentPlanLevel
? t("environments.settings.billing.upgrade_now")
: t("environments.settings.billing.switch_at_period_end");
};
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const usageCycleLabel = `${usageCycleStart.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})} - ${usageCycleEnd.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})}`;
return (
<main>
<div className="flex max-w-6xl flex-col gap-4">
{trialDaysRemaining !== null &&
(hasPaymentMethod ? (
<TrialAlert trialDaysRemaining={trialDaysRemaining} hasPaymentMethod>
<AlertDescription>
{t("environments.settings.billing.trial_payment_method_added_description")}
</AlertDescription>
</TrialAlert>
) : (
<TrialAlert trialDaysRemaining={trialDaysRemaining}>
<AlertDescription>
{t("environments.settings.billing.trial_alert_description")}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void openTrialPaymentCheckout()}>
{t("environments.settings.billing.add_payment_method")}
</AlertButton>
)}
</TrialAlert>
))}
{pendingChange && (
<Alert variant="info" className="max-w-4xl">
<AlertTitle>{t("environments.settings.billing.pending_plan_change_title")}</AlertTitle>
<AlertDescription>
{t("environments.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
{t("environments.settings.billing.keep_current_plan")}
</AlertButton>
)}
</Alert>
)}
<div className="flex max-w-4xl flex-col gap-4">
{isStripeSetupIncomplete && hasBillingRights && (
<Alert variant="warning">
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
@@ -454,24 +257,14 @@ export const PricingTable = ({
</AlertButton>
</Alert>
)}
{currentCloudPlan === "custom" && (
<Alert>
<AlertTitle>{t("environments.settings.billing.custom_plan_title")}</AlertTitle>
<AlertDescription>{t("environments.settings.billing.custom_plan_description")}</AlertDescription>
</Alert>
)}
<SettingsCard
title={t("environments.settings.billing.subscription")}
description={t("environments.settings.billing.subscription_description")}
buttonInfo={
canShowSubscriptionButton
canManageSubscription
? {
text: hasPaymentMethod
? t("environments.settings.billing.manage_billing_details")
: t("environments.settings.billing.add_payment_method"),
onClick: () => void (hasPaymentMethod ? openBillingPortal() : openTrialPaymentCheckout()),
text: t("environments.settings.billing.manage_subscription"),
onClick: () => void openCustomerPortal(),
variant: "default",
}
: undefined
@@ -481,19 +274,8 @@ export const PricingTable = ({
<p className="text-sm font-semibold text-slate-700">
{t("environments.settings.billing.your_plan")}
</p>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<Badge type="success" size="normal" text={getCurrentCloudPlanLabel(currentCloudPlan, t)} />
{currentCloudPlan !== "hobby" && currentBillingInterval && (
<Badge
type="gray"
size="normal"
text={
currentBillingInterval === "monthly"
? t("environments.settings.billing.monthly")
: t("environments.settings.billing.yearly")
}
/>
)}
{currentSubscriptionStatus === "trialing" && (
<Badge
type="warning"
@@ -501,9 +283,24 @@ export const PricingTable = ({
text={t("environments.settings.billing.status_trialing")}
/>
)}
{cancellingOn && (
<Badge
type="warning"
size="normal"
text={`${t("environments.settings.billing.cancelling")}: ${cancellingOn.toLocaleDateString(
"en-US",
{
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
}
)}`}
/>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<UsageCard
metric={t("common.responses")}
@@ -512,11 +309,11 @@ export const PricingTable = ({
isUnlimited={responsesUnlimitedCheck}
unlimitedLabel={t("environments.settings.billing.unlimited_responses")}
/>
<p className="text-sm text-slate-500">
{t("environments.settings.billing.usage_cycle")}: {usageCycleLabel}
</p>
</div>
<UsageCard
metric={t("common.workspaces")}
currentCount={projectCount}
@@ -527,136 +324,35 @@ export const PricingTable = ({
</div>
</SettingsCard>
{showPlanSelector && (
<SettingsCard
title={t("environments.settings.billing.plan_selection_title")}
description={t("environments.settings.billing.plan_selection_description")}>
<div className="flex flex-col gap-6">
<div
className="flex w-fit rounded-xl border border-slate-200 bg-slate-100 p-1"
role="tablist"
aria-label={t("environments.settings.billing.billing_interval_toggle")}>
{(["monthly", "yearly"] as const).map((interval) => (
<button
key={interval}
type="button"
role="tab"
aria-selected={selectedInterval === interval}
tabIndex={selectedInterval === interval ? 0 : -1}
onClick={() => setSelectedInterval(interval)}
className={cn(
"rounded-lg px-5 py-2 text-sm font-medium transition-colors",
selectedInterval === interval
? "bg-slate-900 text-white"
: "text-slate-600 hover:text-slate-900"
)}>
{interval === "monthly"
? t("environments.settings.billing.monthly")
: t("environments.settings.billing.yearly")}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-3">
{planCards.map((planCard) => {
const isCurrentSelection =
currentCloudPlan === planCard.plan &&
(planCard.plan === "hobby" || currentBillingInterval === planCard.interval);
const isPendingSelection =
pendingChange?.targetPlan === planCard.plan &&
(planCard.plan === "hobby" || pendingChange.targetInterval === planCard.interval);
const isMissingPaymentMethodUpgrade =
hasBillingRights &&
!isStripeSetupIncomplete &&
!isTrialing &&
!isCurrentSelection &&
!isPendingSelection &&
!hasPaymentMethod &&
planCard.plan !== "hobby";
const isDisabled =
!hasBillingRights ||
isCurrentSelection ||
isPendingSelection ||
isStripeSetupIncomplete ||
isMissingPaymentMethodUpgrade ||
(isTrialing && !hasPaymentMethod);
return (
<div
key={`${planCard.plan}-${planCard.interval}`}
className={cn(
"grid h-full grid-rows-[minmax(1.75rem,auto)_minmax(8rem,auto)_minmax(4.5rem,auto)_auto_1fr] rounded-2xl border bg-white p-6 shadow-sm",
planCard.plan === "pro" ? "border-slate-900/20" : "border-slate-200"
)}>
<div className="mb-4 flex min-h-7 items-start gap-2">
{planCard.plan === "pro" && (
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">
{t("environments.settings.billing.most_popular")}
</span>
)}
{isCurrentSelection && (
<span className="rounded-md bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">
{t("environments.settings.billing.current_plan_badge")}
</span>
)}
{isPendingSelection && (
<span className="rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
{t("environments.settings.billing.pending_plan_badge")}
</span>
)}
</div>
<div className="min-h-32">
<h3 className="text-3xl font-semibold text-slate-900">
{getCurrentCloudPlanLabel(planCard.plan, t)}
</h3>
<p className="mt-3 text-sm leading-6 text-slate-500">{planCard.description}</p>
</div>
<div className="mt-4 flex min-h-[3rem] items-end gap-2">
<span className="text-3xl font-normal tracking-tight text-slate-900">
{planCard.amount}
</span>
<span className="pb-1 text-sm text-slate-500">
{getPlanPeriodLabel(planCard.plan, planCard.interval, t)}
</span>
</div>
<TooltipRenderer
shouldRender={isMissingPaymentMethodUpgrade}
triggerClass="block w-full"
tooltipContent={t(
"environments.settings.billing.add_payment_method_to_upgrade_tooltip"
)}>
<Button
variant="secondary"
className="mt-4 w-full"
disabled={isDisabled}
loading={isPlanActionPending === `${planCard.plan}-${planCard.interval}`}
onClick={() => void handlePlanAction(planCard.plan, planCard.interval)}>
{getCtaLabel(planCard.plan, planCard.interval)}
</Button>
</TooltipRenderer>
<div className="mt-8 border-t border-slate-100 pt-6">
<p className="mb-4 text-sm font-semibold text-slate-900">
{t("environments.settings.billing.this_includes")}
</p>
<ul className="space-y-3">
{planCard.features.map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-slate-700">
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</div>
);
})}
{currentCloudPlan === "pro" && (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-slate-800 p-6 shadow-sm">
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1.5">
<h3 className="text-lg font-semibold text-white">
{t("environments.settings.billing.scale_banner_title")}
</h3>
<p className="text-sm text-slate-300">
{t("environments.settings.billing.scale_banner_description")}
</p>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-400">
<span>&#10003; {t("environments.settings.billing.scale_feature_teams")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_api")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_quota")}</span>
<span>&#10003; {t("environments.settings.billing.scale_feature_spam")}</span>
</div>
</div>
<Button variant="secondary" size="sm" onClick={openCustomerPortal} className="shrink-0">
{t("environments.settings.billing.upgrade")}
</Button>
</div>
</SettingsCard>
</div>
)}
{showPricingTable && (
<div className="mb-12 w-full max-w-4xl">
<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="afterInteractive" />
{createElement("stripe-pricing-table", stripePricingTableProps)}
</div>
)}
</div>
</main>
@@ -11,8 +11,7 @@ import ethereumLogo from "@/images/customer-logos/ethereum-logo.png";
import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
import githubLogo from "@/images/customer-logos/github-logo.png";
import siemensLogo from "@/images/customer-logos/siemens.png";
import { startProTrialAction } from "@/modules/ee/billing/actions";
import { startHobbyAction } from "@/modules/ee/billing/actions";
import { startScaleTrialAction } from "@/modules/ee/billing/actions";
import { Button } from "@/modules/ui/components/button";
interface SelectPlanCardProps {
@@ -32,25 +31,21 @@ const CUSTOMER_LOGOS = [
export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => {
const router = useRouter();
const [isStartingTrial, setIsStartingTrial] = useState(false);
const [isStartingHobby, setIsStartingHobby] = useState(false);
const { t } = useTranslation();
const TRIAL_FEATURE_KEYS = [
t("environments.settings.billing.trial_feature_unlimited_seats"),
t("environments.settings.billing.trial_feature_hide_branding"),
t("environments.settings.billing.trial_feature_respondent_identification"),
t("environments.settings.billing.trial_feature_contact_segment_management"),
t("environments.settings.billing.trial_feature_attribute_segmentation"),
t("environments.settings.billing.trial_feature_mobile_sdks"),
t("environments.settings.billing.trial_feature_email_followups"),
t("environments.settings.billing.trial_feature_whitelabel"),
t("environments.settings.billing.trial_feature_collaboration"),
t("environments.settings.billing.trial_feature_webhooks"),
t("environments.settings.billing.trial_feature_api_access"),
t("environments.settings.billing.trial_feature_email_followups"),
t("environments.settings.billing.trial_feature_quotas"),
] as const;
const handleStartTrial = async () => {
setIsStartingTrial(true);
try {
const result = await startProTrialAction({ organizationId });
const result = await startScaleTrialAction({ organizationId });
if (result?.data) {
router.push(nextUrl);
} else if (result?.serverError === "trial_already_used") {
@@ -66,20 +61,8 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
}
};
const handleContinueHobby = async () => {
setIsStartingHobby(true);
try {
const result = await startHobbyAction({ organizationId });
if (result?.data) {
router.push(nextUrl);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStartingHobby(false);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
setIsStartingHobby(false);
}
const handleContinueFree = () => {
router.push(nextUrl);
};
return (
@@ -112,7 +95,7 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
onClick={handleStartTrial}
className="mt-4 w-full"
loading={isStartingTrial}
disabled={isStartingTrial || isStartingHobby}>
disabled={isStartingTrial}>
{t("common.start_free_trial")}
</Button>
</div>
@@ -138,10 +121,9 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
</div>
<button
onClick={handleContinueHobby}
disabled={isStartingTrial || isStartingHobby}
onClick={handleContinueFree}
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
{isStartingHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
{t("environments.settings.billing.stay_on_hobby_plan")}
</button>
</div>
);
@@ -1,44 +0,0 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
type TrialAlertVariant = "error" | "warning" | "info" | "success";
const getTrialVariant = (daysRemaining: number): TrialAlertVariant => {
if (daysRemaining <= 3) return "error";
if (daysRemaining <= 7) return "warning";
return "info";
};
interface TrialAlertProps {
trialDaysRemaining: number;
size?: "small";
hasPaymentMethod?: boolean;
children?: React.ReactNode;
}
export const TrialAlert = ({
trialDaysRemaining,
size,
hasPaymentMethod = false,
children,
}: TrialAlertProps) => {
const { t } = useTranslation();
const title = useMemo(() => {
if (trialDaysRemaining <= 0) return t("common.trial_expired");
if (trialDaysRemaining === 1) return t("common.trial_one_day_remaining");
return t("common.trial_days_remaining", { count: trialDaysRemaining });
}, [trialDaysRemaining, t]);
const variant = hasPaymentMethod ? "success" : getTrialVariant(trialDaysRemaining);
return (
<Alert variant={variant} size={size} className="max-w-4xl">
<AlertTitle>{title}</AlertTitle>
{children}
</Alert>
);
};
@@ -29,10 +29,7 @@ describe("cloud-billing-display", () => {
expect(result).toEqual({
organizationId: "org_1",
currentCloudPlan: "pro",
currentBillingInterval: null,
currentSubscriptionStatus: null,
pendingChange: null,
trialDaysRemaining: null,
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
billing,
@@ -1,10 +1,6 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
type TCloudBillingInterval,
type TOrganizationStripePendingChange,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { type TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getOrganizationBillingWithReadThroughSync } from "./organization-billing";
@@ -13,10 +9,7 @@ export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "u
export type TCloudBillingDisplayContext = {
organizationId: string;
currentCloudPlan: TCloudBillingDisplayPlan;
currentBillingInterval: TCloudBillingInterval | null;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
pendingChange: TOrganizationStripePendingChange | null;
trialDaysRemaining: number | null;
usageCycleStart: Date;
usageCycleEnd: Date;
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>;
@@ -34,34 +27,6 @@ const resolveCurrentSubscriptionStatus = (
return billing.stripe?.subscriptionStatus ?? null;
};
const resolveCurrentBillingInterval = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): TCloudBillingInterval | null => {
return billing.stripe?.interval ?? null;
};
const resolvePendingChange = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): TOrganizationStripePendingChange | null => {
return billing.stripe?.pendingChange ?? null;
};
const MS_PER_DAY = 86_400_000;
const resolveTrialDaysRemaining = (
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
): number | null => {
if (billing.stripe?.subscriptionStatus !== "trialing" || !billing.stripe.trialEnd) {
return null;
}
const trialEndDate = new Date(billing.stripe.trialEnd);
if (!Number.isFinite(trialEndDate.getTime())) {
return null;
}
return Math.ceil((trialEndDate.getTime() - Date.now()) / MS_PER_DAY);
};
export const getCloudBillingDisplayContext = async (
organizationId: string
): Promise<TCloudBillingDisplayContext> => {
@@ -76,10 +41,7 @@ export const getCloudBillingDisplayContext = async (
return {
organizationId,
currentCloudPlan: resolveCurrentCloudPlan(billing),
currentBillingInterval: resolveCurrentBillingInterval(billing),
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
pendingChange: resolvePendingChange(billing),
trialDaysRemaining: resolveTrialDaysRemaining(billing),
usageCycleStart: usageCycleWindow.start,
usageCycleEnd: usageCycleWindow.end,
billing,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,205 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mocks = vi.hoisted(() => ({
pricesList: vi.fn(),
cacheWithCache: vi.fn(),
}));
vi.mock("./stripe-client", () => ({
stripeClient: {
prices: {
list: mocks.pricesList,
},
},
}));
const cacheStore = vi.hoisted(() => new Map<string, unknown>());
vi.mock("@/lib/cache", () => ({
cache: {
withCache: mocks.cacheWithCache,
},
}));
const createPrice = ({
id,
plan,
kind,
interval,
}: {
id: string;
plan: "hobby" | "pro" | "scale";
kind: "base" | "responses";
interval: "monthly" | "yearly";
}) => ({
id,
active: true,
currency: "usd",
unit_amount: kind === "responses" ? 0 : interval === "monthly" ? 1000 : 10000,
metadata: {
formbricks_plan: plan,
formbricks_price_kind: kind,
formbricks_interval: interval,
},
recurring: {
usage_type: kind === "base" ? "licensed" : "metered",
interval: interval === "monthly" ? "month" : "year",
},
product: {
id: `prod_${plan}`,
active: true,
metadata: {
formbricks_plan: plan,
},
},
});
describe("stripe-billing-catalog", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
cacheStore.clear();
mocks.cacheWithCache.mockImplementation(async (fn: () => Promise<unknown>, key: string) => {
if (cacheStore.has(key)) {
return cacheStore.get(key);
}
const value = await fn();
cacheStore.set(key, value);
return value;
});
});
test("resolves the metadata-backed billing catalog", async () => {
mocks.pricesList.mockResolvedValue({
data: [
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
],
has_more: false,
});
const { getCatalogItemsForPlan, getStripeBillingCatalogDisplay } =
await import("./stripe-billing-catalog");
await expect(getCatalogItemsForPlan("hobby", "monthly")).resolves.toEqual([
{ price: "price_hobby_monthly", quantity: 1 },
]);
await expect(getCatalogItemsForPlan("pro", "yearly")).resolves.toEqual([
{ price: "price_pro_yearly", quantity: 1 },
{ price: "price_pro_responses" },
]);
await expect(getStripeBillingCatalogDisplay()).resolves.toEqual({
hobby: {
monthly: {
plan: "hobby",
interval: "monthly",
currency: "usd",
unitAmount: 1000,
},
},
pro: {
monthly: {
plan: "pro",
interval: "monthly",
currency: "usd",
unitAmount: 1000,
},
yearly: {
plan: "pro",
interval: "yearly",
currency: "usd",
unitAmount: 10000,
},
},
scale: {
monthly: {
plan: "scale",
interval: "monthly",
currency: "usd",
unitAmount: 1000,
},
yearly: {
plan: "scale",
interval: "yearly",
currency: "usd",
unitAmount: 10000,
},
},
});
});
test("fails fast when the catalog is incomplete", async () => {
mocks.pricesList.mockResolvedValue({
data: [createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" })],
has_more: false,
});
const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog");
await expect(getCatalogItemsForPlan("pro", "monthly")).rejects.toThrow(
"Expected exactly one Stripe price for pro/base/monthly, but found 0"
);
});
test("reuses the shared cached catalog across module reloads", async () => {
mocks.pricesList.mockResolvedValue({
data: [
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
],
has_more: false,
});
const firstModule = await import("./stripe-billing-catalog");
await firstModule.getStripeBillingCatalogDisplay();
vi.resetModules();
const secondModule = await import("./stripe-billing-catalog");
await secondModule.getStripeBillingCatalogDisplay();
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
expect(mocks.cacheWithCache).toHaveBeenCalledTimes(2);
});
test("falls back to direct Stripe fetch when shared cache is unavailable", async () => {
mocks.pricesList.mockResolvedValue({
data: [
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
],
has_more: false,
});
mocks.cacheWithCache.mockImplementationOnce(async (fn: () => Promise<unknown>) => await fn());
const { getStripeBillingCatalogDisplay } = await import("./stripe-billing-catalog");
await expect(getStripeBillingCatalogDisplay()).resolves.toMatchObject({
hobby: {
monthly: {
plan: "hobby",
},
},
});
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
});
});
@@ -1,337 +0,0 @@
import "server-only";
import { cache as reactCache } from "react";
import Stripe from "stripe";
import { createCacheKey } from "@formbricks/cache";
import type { TCloudBillingInterval } from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { stripeClient } from "./stripe-client";
export type TStandardCloudPlan = "hobby" | "pro" | "scale";
type TStripePriceKind = "base" | "responses";
type TStripeCatalogPrice = Stripe.Price & {
product: Stripe.Product | Stripe.DeletedProduct;
};
export type TStripeBillingCatalogItem = {
plan: TStandardCloudPlan;
interval: TCloudBillingInterval;
basePrice: TStripeCatalogPrice;
responsePrice: TStripeCatalogPrice | null;
};
export type TStripeBillingCatalog = {
hobby: {
monthly: TStripeBillingCatalogItem;
};
pro: {
monthly: TStripeBillingCatalogItem;
yearly: TStripeBillingCatalogItem;
};
scale: {
monthly: TStripeBillingCatalogItem;
yearly: TStripeBillingCatalogItem;
};
};
export type TStripeBillingCatalogDisplayItem = {
plan: TStandardCloudPlan;
interval: TCloudBillingInterval;
currency: string;
unitAmount: number | null;
};
export type TStripeBillingCatalogDisplay = {
hobby: {
monthly: TStripeBillingCatalogDisplayItem;
};
pro: {
monthly: TStripeBillingCatalogDisplayItem;
yearly: TStripeBillingCatalogDisplayItem;
};
scale: {
monthly: TStripeBillingCatalogDisplayItem;
yearly: TStripeBillingCatalogDisplayItem;
};
};
const STANDARD_CLOUD_PLANS = new Set<TStandardCloudPlan>(["hobby", "pro", "scale"]);
const STRIPE_BILLING_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
const STRIPE_BILLING_CATALOG_CACHE_VERSION = "v1";
const getStripeBillingCatalogCacheKey = () =>
createCacheKey.custom(
"billing",
"stripe_catalog",
`${hashString(env.STRIPE_SECRET_KEY ?? "stripe-unconfigured")}-${STRIPE_BILLING_CATALOG_CACHE_VERSION}`
);
const getPriceProduct = (price: Stripe.Price): Stripe.Product | Stripe.DeletedProduct | null => {
if (typeof price.product === "string") {
return null;
}
return price.product;
};
const getPricePlan = (price: Stripe.Price): TStandardCloudPlan | null => {
const product = getPriceProduct(price);
const plan =
price.metadata?.formbricks_plan ??
(!product || product.deleted ? undefined : product.metadata?.formbricks_plan);
if (!plan || !STANDARD_CLOUD_PLANS.has(plan as TStandardCloudPlan)) {
return null;
}
return plan as TStandardCloudPlan;
};
const normalizeInterval = (interval: string | null | undefined): TCloudBillingInterval | null => {
if (interval === "month" || interval === "monthly") return "monthly";
if (interval === "year" || interval === "yearly") return "yearly";
return null;
};
const getPriceInterval = (price: Stripe.Price): TCloudBillingInterval | null => {
const metadataInterval = normalizeInterval(price.metadata?.formbricks_interval);
if (metadataInterval) {
return metadataInterval;
}
return normalizeInterval(price.recurring?.interval);
};
const getPriceKind = (price: Stripe.Price): TStripePriceKind | null => {
const metadataKind = price.metadata?.formbricks_price_kind;
if (metadataKind === "base" || metadataKind === "responses") {
return metadataKind;
}
if (price.recurring?.usage_type === "licensed") {
return "base";
}
if (price.recurring?.usage_type === "metered") {
return "responses";
}
return null;
};
const isCatalogCandidate = (price: Stripe.Price): price is TStripeCatalogPrice => {
if (!price.active || !price.recurring) {
return false;
}
const product = getPriceProduct(price);
if (!product || product.deleted || !product.active) {
return false;
}
return getPricePlan(price) !== null && getPriceKind(price) !== null && getPriceInterval(price) !== null;
};
const listAllActivePrices = async (): Promise<TStripeCatalogPrice[]> => {
if (!stripeClient) {
return [];
}
const prices: TStripeCatalogPrice[] = [];
let startingAfter: string | undefined;
do {
const result = await stripeClient.prices.list({
active: true,
limit: 100,
expand: ["data.product"],
...(startingAfter ? { starting_after: startingAfter } : {}),
});
for (const price of result.data) {
if (isCatalogCandidate(price)) {
prices.push(price);
}
}
const lastItem = result.data.at(-1);
startingAfter = result.has_more && lastItem ? lastItem.id : undefined;
} while (startingAfter);
return prices;
};
const getSinglePrice = (
prices: TStripeCatalogPrice[],
plan: TStandardCloudPlan,
kind: TStripePriceKind,
interval: TCloudBillingInterval
): TStripeCatalogPrice => {
const matches = prices.filter(
(price) =>
getPricePlan(price) === plan && getPriceKind(price) === kind && getPriceInterval(price) === interval
);
if (matches.length !== 1) {
throw new Error(
`Expected exactly one Stripe price for ${plan}/${kind}/${interval}, but found ${matches.length}`
);
}
return matches[0];
};
const fetchStripeBillingCatalog = async (): Promise<TStripeBillingCatalog> => {
if (!stripeClient) {
throw new Error("Stripe is not configured");
}
const prices = await listAllActivePrices();
if (prices.length === 0) {
throw new Error("No active Stripe billing catalog prices found");
}
return {
hobby: {
monthly: {
plan: "hobby",
interval: "monthly",
basePrice: getSinglePrice(prices, "hobby", "base", "monthly"),
responsePrice: null,
},
},
pro: {
monthly: {
plan: "pro",
interval: "monthly",
basePrice: getSinglePrice(prices, "pro", "base", "monthly"),
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
},
yearly: {
plan: "pro",
interval: "yearly",
basePrice: getSinglePrice(prices, "pro", "base", "yearly"),
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
},
},
scale: {
monthly: {
plan: "scale",
interval: "monthly",
basePrice: getSinglePrice(prices, "scale", "base", "monthly"),
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
},
yearly: {
plan: "scale",
interval: "yearly",
basePrice: getSinglePrice(prices, "scale", "base", "yearly"),
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
},
},
};
};
export const getStripeBillingCatalog = reactCache(async (): Promise<TStripeBillingCatalog> => {
return await cache.withCache(
fetchStripeBillingCatalog,
getStripeBillingCatalogCacheKey(),
STRIPE_BILLING_CATALOG_CACHE_TTL_MS
);
});
export const getStripeBillingCatalogDisplay = reactCache(async (): Promise<TStripeBillingCatalogDisplay> => {
const catalog = await getStripeBillingCatalog();
return {
hobby: {
monthly: {
plan: "hobby",
interval: "monthly",
currency: catalog.hobby.monthly.basePrice.currency,
unitAmount: catalog.hobby.monthly.basePrice.unit_amount,
},
},
pro: {
monthly: {
plan: "pro",
interval: "monthly",
currency: catalog.pro.monthly.basePrice.currency,
unitAmount: catalog.pro.monthly.basePrice.unit_amount,
},
yearly: {
plan: "pro",
interval: "yearly",
currency: catalog.pro.yearly.basePrice.currency,
unitAmount: catalog.pro.yearly.basePrice.unit_amount,
},
},
scale: {
monthly: {
plan: "scale",
interval: "monthly",
currency: catalog.scale.monthly.basePrice.currency,
unitAmount: catalog.scale.monthly.basePrice.unit_amount,
},
yearly: {
plan: "scale",
interval: "yearly",
currency: catalog.scale.yearly.basePrice.currency,
unitAmount: catalog.scale.yearly.basePrice.unit_amount,
},
},
};
});
export const getCatalogItemForPlan = async (
plan: TStandardCloudPlan,
interval: TCloudBillingInterval
): Promise<TStripeBillingCatalogItem> => {
const catalog = await getStripeBillingCatalog();
if (plan === "hobby") {
return catalog.hobby.monthly;
}
return catalog[plan][interval];
};
export const getCatalogItemsForPlan = async (
plan: TStandardCloudPlan,
interval: TCloudBillingInterval
): Promise<Array<{ price: string; quantity?: number }>> => {
const item = await getCatalogItemForPlan(plan, interval);
return [
{ price: item.basePrice.id, quantity: 1 },
...(item.responsePrice ? [{ price: item.responsePrice.id }] : []),
];
};
export const getIntervalFromPrice = (
price: Stripe.Price | null | undefined
): TCloudBillingInterval | null => {
if (!price) {
return null;
}
return getPriceInterval(price);
};
export const getPlanFromPrice = (price: Stripe.Price | null | undefined): TStandardCloudPlan | null => {
if (!price) {
return null;
}
return getPricePlan(price);
};
export const getPriceKindFromPrice = (price: Stripe.Price | null | undefined): TStripePriceKind | null => {
if (!price) {
return null;
}
return getPriceKind(price);
};
+4 -9
View File
@@ -1,11 +1,11 @@
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { env } from "@/lib/env";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getStripeBillingCatalogDisplay } from "@/modules/ee/billing/lib/stripe-billing-catalog";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -21,10 +21,7 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
notFound();
}
const [cloudBillingDisplayContext, billingCatalog] = await Promise.all([
getCloudBillingDisplayContext(organization.id),
getStripeBillingCatalogDisplay(),
]);
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
const organizationWithSyncedBilling = {
...organization,
@@ -56,14 +53,12 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
projectCount={projectCount}
hasBillingRights={hasBillingRights}
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
currentBillingInterval={cloudBillingDisplayContext.currentBillingInterval}
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
pendingChange={cloudBillingDisplayContext.pendingChange}
usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
billingCatalog={billingCatalog}
/>
</PageContentWrapper>
);
@@ -46,7 +46,7 @@ export const ContactsPageLayout = async ({
description={upgradePromptDescription ?? t("environments.contacts.unlock_contacts_description")}
buttons={[
{
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
@@ -97,13 +97,14 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
);
const ZUpdateSegmentAction = z.object({
environmentId: ZId,
segmentId: ZId,
data: ZSegmentUpdateInput,
});
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -75,6 +75,7 @@ export function SegmentSettings({
try {
setIsUpdatingSegment(true);
const data = await updateSegmentAction({
environmentId,
segmentId: segment.id,
data: {
title: segment.title,
@@ -124,7 +124,7 @@ export function TargetingCard({
};
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateSegmentAction({ segmentId, data });
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
return updatedSegment?.data as TSegment;
};
@@ -136,7 +136,7 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
const result = await updateSegmentAction({ segmentId: segment.id, data });
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
+5 -4
View File
@@ -21,6 +21,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZDeleteQuotaAction = z.object({
quotaId: ZId,
surveyId: ZId,
});
const checkQuotasEnabled = async (organizationId: string) => {
@@ -36,7 +37,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -48,7 +49,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -71,7 +72,7 @@ const ZUpdateQuotaAction = z.object({
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -83,7 +84,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
minPermission: "readWrite",
},
],
@@ -85,6 +85,7 @@ export const QuotasCard = ({
setIsDeletingQuota(true);
const deleteQuotaActionResult = await deleteQuotaAction({
quotaId: quotaId,
surveyId: localSurvey.id,
});
if (deleteQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
@@ -173,7 +174,9 @@ export const QuotasCard = ({
description={t("common.quotas_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
text: isFormbricksCloud
? t("common.start_free_trial")
: t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
@@ -10,7 +10,6 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
@@ -32,6 +31,7 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
const ZUpdateInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
data: ZInviteUpdateInput,
});
@@ -39,16 +39,17 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
@@ -67,9 +68,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
throw new OperationNotAllowedError("Managers can only invite members");
}
await checkRoleManagementPermission(organizationId);
await checkRoleManagementPermission(parsedInput.organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
@@ -65,7 +65,7 @@ export function EditMembershipRole({
}
if (inviteId) {
await updateInviteAction({ inviteId: inviteId, data: { role } });
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
@@ -37,7 +37,7 @@ export const TeamsView = async ({
const buttons: [ModalButton, ModalButton] = [
{
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
@@ -181,7 +181,7 @@ export const EmailCustomizationSettings = ({
const buttons: [ModalButton, ModalButton] = [
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
@@ -149,7 +149,7 @@ export const FaviconCustomizationSettings = ({
const buttons: [ModalButton, ModalButton] = [
{
text: t("common.upgrade_plan"),
text: t("common.start_free_trial"),
href: `/environments/${environmentId}/settings/billing`,
},
{
@@ -23,7 +23,7 @@ export const BrandingSettingsCard = async ({
const buttons: [ModalButton, ModalButton] = [
{
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import type { TOrganizationBilling } from "@formbricks/types/organizations";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
@@ -6,17 +7,8 @@ import { getCloudOrganizationEntitlementsContext } from "./cloud-provider";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/logger", () => ({
logger: { warn: vi.fn() },
}));
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
getOrganizationBillingWithReadThroughSync: vi.fn(),
getDefaultOrganizationBilling: () => ({
limits: { projects: 1, monthly: { responses: 250 } },
stripeCustomerId: null,
usageCycleAnchor: null,
}),
}));
vi.mock("@/modules/ee/license-check/lib/license", () => ({
@@ -43,23 +35,11 @@ beforeEach(() => {
});
describe("getCloudOrganizationEntitlementsContext", () => {
test("returns default entitlements when billing is null", async () => {
test("throws ResourceNotFoundError when billing is null", async () => {
mockGetBilling.mockResolvedValue(null);
mockGetLicense.mockResolvedValue({ status: "no-license", features: null, active: false });
const result = await getCloudOrganizationEntitlementsContext("org1");
expect(result).toEqual({
organizationId: "org1",
source: "cloud_stripe",
features: [],
limits: { projects: 1, monthlyResponses: 250 },
licenseStatus: "no-license",
licenseFeatures: null,
stripeCustomerId: null,
subscriptionStatus: null,
usageCycleAnchor: null,
});
await expect(getCloudOrganizationEntitlementsContext("org1")).rejects.toThrow(ResourceNotFoundError);
});
test("returns context with billing data", async () => {
@@ -1,9 +1,6 @@
import "server-only";
import { logger } from "@formbricks/logger";
import {
getDefaultOrganizationBilling,
getOrganizationBillingWithReadThroughSync,
} from "@/modules/ee/billing/lib/organization-billing";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { type TOrganizationEntitlementsContext, isEntitlementFeature } from "./types";
@@ -22,23 +19,7 @@ export const getCloudOrganizationEntitlementsContext = async (
]);
if (!billing) {
logger.warn({ organizationId }, "Organization billing not found, using default entitlements");
const defaultBilling = getDefaultOrganizationBilling();
return {
organizationId,
source: "cloud_stripe",
features: [],
limits: {
projects: defaultBilling.limits?.projects ?? null,
monthlyResponses: defaultBilling.limits?.monthly?.responses ?? null,
},
licenseStatus: license.status,
licenseFeatures: license.features,
stripeCustomerId: null,
subscriptionStatus: null,
usageCycleAnchor: null,
};
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
}
return {
@@ -27,15 +27,14 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
@@ -43,7 +42,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
return await deleteInvite(parsedInput.inviteId);
@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (!member && invite) {
// This is an invite
const result = await deleteInviteAction({ inviteId: invite?.id });
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
@@ -193,7 +193,7 @@ export const IndividualInviteTab = ({
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license"
}>
{t("common.upgrade_plan")}
{t("common.start_free_trial")}
</Link>
</AlertDescription>
</Alert>
@@ -43,7 +43,7 @@ export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: Target
description={t("environments.surveys.edit.unlock_targeting_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
@@ -94,17 +94,6 @@ export const ValidationRulesEditor = ({
const handleDisable = () => {
onUpdateValidation({ rules: [], logic: validationLogic });
// Reset inputType to "text" when disabling validation for OpenText elements.
// Without this, the HTML input keeps its type (e.g. "url"), which still enforces
// browser-native format validation even though the user toggled validation off.
if (
elementType === TSurveyElementTypeEnum.OpenText &&
onUpdateInputType &&
inputType !== undefined &&
inputType !== "text"
) {
onUpdateInputType("text");
}
};
const handleAddRule = (insertAfterIndex: number) => {
@@ -41,41 +41,113 @@ export const CustomScriptsInjector = ({
if (!scriptsToInject.trim()) return;
try {
// Create a temporary container to parse the HTML
const container = document.createElement("div");
container.innerHTML = scriptsToInject;
// Process and inject script elements
const scripts = container.querySelectorAll("script");
scripts.forEach((script) => {
const newScript = document.createElement("script");
// Copy all attributes (src, async, defer, type, etc.)
Array.from(script.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content
if (script.textContent) {
newScript.textContent = script.textContent;
/**
* Ensures document.body exists before executing the injection.
* This prevents race conditions where custom scripts try to access document.body
* before React hydration completes, which would cause:
* - React error #454 (missing document.body)
* - TypeError: can't access property "removeChild" of null
*/
const ensureBodyExists = (): Promise<void> => {
return new Promise((resolve) => {
// If body already exists, resolve immediately
if (document.body) {
resolve();
return;
}
document.head.appendChild(newScript);
// Wait for DOMContentLoaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
} else {
// Document is already loaded but body doesn't exist yet (edge case)
// Use setTimeout to defer until next tick
setTimeout(() => resolve(), 0);
}
});
};
// Process and inject non-script elements (noscript, meta, link, style, etc.)
const nonScripts = container.querySelectorAll(":not(script)");
nonScripts.forEach((el) => {
const clonedEl = el.cloneNode(true) as Element;
document.head.appendChild(clonedEl);
});
/**
* Wraps inline script content to ensure safe execution after DOM is ready.
* This prevents scripts from executing before document.body is available.
*/
const wrapScriptContent = (content: string): string => {
// Don't wrap if the script already has DOM-ready checks
if (
content.includes("DOMContentLoaded") ||
content.includes("document.readyState") ||
content.includes("window.addEventListener('load'")
) {
return content;
}
injectedRef.current = true;
} catch (error) {
// Log error but don't break the survey - self-hosted admins can check console
console.warn("[Formbricks] Error injecting custom scripts:", error);
}
// Wrap the script to ensure it runs after DOM is ready
return `
(function() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
${content}
});
} else {
${content}
}
})();
`;
};
const injectScripts = async () => {
try {
// Wait for document.body to exist before injecting any scripts
await ensureBodyExists();
// Defensive check: ensure body still exists
if (!document.body) {
console.warn("[Formbricks] document.body is not available, skipping script injection");
return;
}
// Create a temporary container to parse the HTML
const container = document.createElement("div");
container.innerHTML = scriptsToInject;
// Process and inject script elements
const scripts = container.querySelectorAll("script");
scripts.forEach((script) => {
const newScript = document.createElement("script");
// Copy all attributes (src, async, defer, type, etc.)
Array.from(script.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content with safety wrapper
if (script.textContent) {
// Only wrap inline scripts (not external scripts with src attribute)
if (!script.hasAttribute("src")) {
newScript.textContent = wrapScriptContent(script.textContent);
} else {
newScript.textContent = script.textContent;
}
}
document.head.appendChild(newScript);
});
// Process and inject non-script elements (noscript, meta, link, style, etc.)
const nonScripts = container.querySelectorAll(":not(script)");
nonScripts.forEach((el) => {
const clonedEl = el.cloneNode(true) as Element;
document.head.appendChild(clonedEl);
});
injectedRef.current = true;
} catch (error) {
// Log error but don't break the survey - self-hosted admins can check console
console.warn("[Formbricks] Error injecting custom scripts:", error);
}
};
injectScripts();
}, [projectScripts, surveyScripts, scriptsMode]);
return null;
@@ -10,11 +10,7 @@ import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import {
getBasicSurveyMetadata,
getMetadataBrandColor,
getSurveyOpenGraphMetadata,
} from "@/modules/survey/link/lib/metadata-utils";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
interface ContactSurveyPageProps {
@@ -52,8 +48,9 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
const customFaviconUrl = environmentContext.organizationWhitelabel?.faviconUrl;
const brandColor = getMetadataBrandColor(environmentContext.project.styling, survey.styling);
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, brandColor);
// Get OpenGraph metadata
const surveyBrandColor = survey.styling?.brandColor?.light;
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
// Override with the custom image URL
if (baseMetadata.openGraph) {
@@ -7,7 +7,6 @@ import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import {
getBasicSurveyMetadata,
getBrandColorForURL,
getMetadataBrandColor,
getNameForURL,
getSurveyOpenGraphMetadata,
} from "./metadata-utils";
@@ -254,35 +253,6 @@ describe("Metadata Utils", () => {
});
});
describe("getMetadataBrandColor", () => {
test("returns survey brand color when project allows override and survey overrides theme", () => {
const projectStyling = { allowStyleOverwrite: true, brandColor: { light: "#ff0000" } };
const surveyStyling = { overwriteThemeStyling: true, brandColor: { light: "#0000ff" } };
expect(getMetadataBrandColor(projectStyling, surveyStyling as any)).toBe("#0000ff");
});
test("returns project brand color when survey does not override theme", () => {
const projectStyling = { allowStyleOverwrite: true, brandColor: { light: "#ff0000" } };
const surveyStyling = { overwriteThemeStyling: false, brandColor: { light: "#0000ff" } };
expect(getMetadataBrandColor(projectStyling, surveyStyling as any)).toBe("#ff0000");
});
test("returns project brand color when project disallows style overwrite", () => {
const projectStyling = { allowStyleOverwrite: false, brandColor: { light: "#ff0000" } };
const surveyStyling = { overwriteThemeStyling: true, brandColor: { light: "#0000ff" } };
expect(getMetadataBrandColor(projectStyling, surveyStyling as any)).toBe("#ff0000");
});
test("returns project brand color when survey styling is null", () => {
const projectStyling = { allowStyleOverwrite: true, brandColor: { light: "#ff0000" } };
expect(getMetadataBrandColor(projectStyling, null)).toBe("#ff0000");
});
});
describe("getSurveyOpenGraphMetadata", () => {
test("generates correct OpenGraph metadata", () => {
const surveyId = "survey-123";
@@ -1,6 +1,4 @@
import { Metadata } from "next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -86,23 +84,6 @@ export const getBasicSurveyMetadata = async (
};
};
/**
* Determines the brand color for OG metadata based on project and survey styling settings.
* Uses project brand color unless the project allows style overwrite AND the survey overrides the theme.
*/
export const getMetadataBrandColor = (
projectStyling: TProjectStyling,
surveyStyling?: TSurveyStyling | null
): string | undefined => {
if (!projectStyling.allowStyleOverwrite) {
return projectStyling.brandColor?.light;
}
return surveyStyling?.overwriteThemeStyling
? surveyStyling.brandColor?.light
: projectStyling.brandColor?.light;
};
/**
* Generate Open Graph metadata for survey
*/
@@ -17,14 +17,10 @@ vi.mock("next/navigation", () => ({
notFound: vi.fn(),
}));
vi.mock("./lib/metadata-utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/metadata-utils")>();
return {
...actual,
getSurveyOpenGraphMetadata: vi.fn(),
getBasicSurveyMetadata: vi.fn(),
};
});
vi.mock("./lib/metadata-utils", () => ({
getSurveyOpenGraphMetadata: vi.fn(),
getBasicSurveyMetadata: vi.fn(),
}));
describe("getMetadataForLinkSurvey", () => {
const mockSurveyId = "survey-123";
@@ -57,7 +53,7 @@ describe("getMetadataForLinkSurvey", () => {
project: {
id: "project-123",
name: "Test Project",
styling: { allowStyleOverwrite: true },
styling: null,
logo: null,
linkSurveyBranding: true,
customHeadScripts: null,
+3 -7
View File
@@ -2,11 +2,7 @@ import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import {
getBasicSurveyMetadata,
getMetadataBrandColor,
getSurveyOpenGraphMetadata,
} from "./lib/metadata-utils";
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
export const getMetadataForLinkSurvey = async (
surveyId: string,
@@ -19,14 +15,14 @@ export const getMetadataForLinkSurvey = async (
}
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode, survey);
const surveyBrandColor = survey.styling?.brandColor?.light;
// Fetch organization whitelabel data for custom favicon
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
const customFaviconUrl = environmentContext.organizationWhitelabel?.faviconUrl;
// Use the shared function for creating the base metadata but override with custom data
const brandColor = getMetadataBrandColor(environmentContext.project.styling, survey.styling);
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, brandColor);
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
// Override with the custom image URL
if (baseMetadata.openGraph) {
-2
View File
@@ -94,9 +94,7 @@ describe("@formbricks/cache types/keys", () => {
test("should include expected namespaces", () => {
// Type test - this will fail at compile time if types don't match
const analyticsNamespace: CustomCacheNamespace = "analytics";
const billingNamespace: CustomCacheNamespace = "billing";
expect(analyticsNamespace).toBe("analytics");
expect(billingNamespace).toBe("billing");
});
test("should be usable in cache key construction", () => {
+1 -1
View File
@@ -16,4 +16,4 @@ export type CacheKey = z.infer<typeof ZCacheKey>;
* Possible namespaces for custom cache keys
* Add new namespaces here as they are introduced
*/
export type CustomCacheNamespace = "analytics" | "billing";
export type CustomCacheNamespace = "analytics";
-12
View File
@@ -28,7 +28,6 @@ export const ZOrganizationBilling = z.object({
stripe: z
.object({
plan: z.enum(["hobby", "pro", "scale", "custom", "unknown"]).optional(),
interval: z.enum(["monthly", "yearly"]).nullable().optional(),
subscriptionStatus: z
.enum([
"trialing",
@@ -43,21 +42,10 @@ export const ZOrganizationBilling = z.object({
.nullable()
.optional(),
subscriptionId: z.string().nullable().optional(),
hasPaymentMethod: z.boolean().optional(),
features: z.array(z.string()).optional(),
lastStripeEventCreatedAt: z.string().nullable().optional(),
lastSyncedAt: z.string().nullable().optional(),
lastSyncedEventId: z.string().nullable().optional(),
trialEnd: z.string().nullable().optional(),
pendingChange: z
.object({
type: z.literal("plan_change"),
targetPlan: z.enum(["hobby", "pro", "scale"]),
targetInterval: z.enum(["monthly", "yearly"]).nullable(),
effectiveAt: z.string(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
@@ -175,16 +175,6 @@ export const addPageUrlEventListeners = (): void => {
window.dispatchEvent(event);
};
// eslint-disable-next-line @typescript-eslint/unbound-method -- We need to access the original method
const originalReplaceState = history.replaceState;
// eslint-disable-next-line func-names -- We need an anonymous function here
history.replaceState = function (...args) {
originalReplaceState.apply(this, args);
const event = new Event("replacestate");
window.dispatchEvent(event);
};
isHistoryPatched = true;
}
@@ -17,7 +17,6 @@ import {
removeExitIntentListener,
removePageUrlEventListeners,
removeScrollDepthListener,
setIsHistoryPatched,
} from "@/lib/survey/no-code-action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
import { type TActionClassNoCodeConfig } from "@/types/survey";
@@ -639,32 +638,6 @@ describe("addPageUrlEventListeners additional cases", () => {
(window.addEventListener as Mock).mockRestore();
dispatchEventSpy.mockRestore();
});
test("patched history.replaceState dispatches a 'replacestate' event", () => {
const addEventListenerMock = vi.fn();
const removeEventListenerMock = vi.fn();
const originalReplaceState = vi.fn();
const dispatchEventMock = vi.fn();
vi.stubGlobal("window", {
addEventListener: addEventListenerMock,
removeEventListener: removeEventListenerMock,
dispatchEvent: dispatchEventMock,
});
vi.stubGlobal("history", { pushState: vi.fn(), replaceState: originalReplaceState });
// Reset patching state so addPageUrlEventListeners patches history fresh
setIsHistoryPatched(false);
removePageUrlEventListeners();
addPageUrlEventListeners();
// Call the patched replaceState
history.replaceState({}, "", "/replaced-url");
expect(originalReplaceState).toHaveBeenCalledWith({}, "", "/replaced-url");
expect(dispatchEventMock).toHaveBeenCalledWith(expect.objectContaining({ type: "replacestate" }));
(window.addEventListener as Mock).mockRestore();
});
});
describe("removePageUrlEventListeners additional cases", () => {
@@ -43,17 +43,6 @@ vi.mock("@/lib/common/utils", () => ({
handleHiddenFields: vi.fn(),
}));
const mockUpdateQueue = {
hasPendingWork: vi.fn().mockReturnValue(false),
waitForPendingWork: vi.fn().mockResolvedValue(true),
};
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => mockUpdateQueue),
},
}));
describe("widget-file", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
@@ -260,265 +249,4 @@ describe("widget-file", () => {
widget.removeWidgetContainer();
expect(document.getElementById("formbricks-container")).toBeFalsy();
});
test("renderWidget waits for pending identification before rendering", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_abc",
})
);
vi.useRealTimers();
});
test("renderWidget does not wait when no identification is pending", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
test("renderWidget reads contactId after identification wait completes", async () => {
let callCount = 0;
const mockConfigValue = {
get: vi.fn().mockImplementation(() => {
callCount++;
return {
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
// Simulate contactId becoming available after identification
userId: "user_abc",
contactId: callCount > 2 ? "contact_after_identification" : undefined,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
};
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// The contactId passed to renderSurvey should be read after the wait
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_after_identification",
})
);
vi.useRealTimers();
});
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed. Skipping survey with segment filters."
);
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: null,
contactId: null,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: undefined,
} as unknown as TEnvironmentStateSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed but survey has no segment filters. Proceeding."
);
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});
-19
View File
@@ -11,7 +11,6 @@ import {
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
@@ -61,24 +60,6 @@ export const renderWidget = async (
setIsSurveyRunning(true);
// Wait for pending user identification to complete before rendering
const updateQueue = UpdateQueue.getInstance();
if (updateQueue.hasPendingWork()) {
logger.debug("Waiting for pending user identification before rendering survey");
const identificationSucceeded = await updateQueue.waitForPendingWork();
if (!identificationSucceeded) {
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
setIsSurveyRunning(false);
return;
}
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
}
}
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
}
@@ -169,104 +169,4 @@ describe("UpdateQueue", () => {
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function"
);
});
test("hasPendingWork returns false when no updates and no flush in flight", () => {
expect(updateQueue.hasPendingWork()).toBe(false);
});
test("hasPendingWork returns true when updates are queued", () => {
updateQueue.updateUserId(mockUserId1);
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("hasPendingWork returns true while processUpdates flush is in flight", () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// Start processing but don't await — the debounce means the flush is in-flight
void updateQueue.processUpdates();
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("waitForPendingWork returns true immediately when no pending work", async () => {
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
});
test("waitForPendingWork returns true when processUpdates succeeds", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
expect(updateQueue.hasPendingWork()).toBe(false);
expect(sendUpdates).toHaveBeenCalled();
});
test("waitForPendingWork returns false when processUpdates rejects", async () => {
loggerMock.mockReturnValue(mockLogger as unknown as Logger);
(sendUpdates as Mock).mockRejectedValue(new Error("network error"));
updateQueue.updateUserId(mockUserId1);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallowing rejection to avoid unhandled promise
const processPromise = updateQueue.processUpdates().catch(() => {});
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(false);
await processPromise;
});
test("waitForPendingWork returns false when flush hangs past timeout", async () => {
vi.useFakeTimers();
// sendUpdates returns a promise that never resolves, simulating a network hang
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
(sendUpdates as Mock).mockReturnValue(new Promise(() => {}));
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const resultPromise = updateQueue.waitForPendingWork();
// Advance past the debounce delay (500ms) so the handler fires and hangs on sendUpdates
await vi.advanceTimersByTimeAsync(500);
// Advance past the pending work timeout (5000ms)
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toBe(false);
vi.useRealTimers();
});
test("processUpdates reuses pending flush instead of creating orphaned promises", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// First call creates the flush promise
const firstPromise = updateQueue.processUpdates();
// Second call while first is still pending should not create a new flush
updateQueue.updateAttributes({ name: mockAttributes.name });
const secondPromise = updateQueue.processUpdates();
// Both promises should resolve (second is not orphaned)
await Promise.all([firstPromise, secondPromise]);
expect(updateQueue.hasPendingWork()).toBe(false);
});
});
+1 -36
View File
@@ -8,9 +8,7 @@ export class UpdateQueue {
private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null;
private pendingFlush: Promise<void> | null = null;
private readonly DEBOUNCE_DELAY = 500;
private readonly PENDING_WORK_TIMEOUT = 5000;
private constructor() {}
@@ -65,45 +63,17 @@ export class UpdateQueue {
return !this.updates;
}
public hasPendingWork(): boolean {
return this.updates !== null || this.pendingFlush !== null;
}
public async waitForPendingWork(): Promise<boolean> {
if (!this.hasPendingWork()) return true;
const flush = this.pendingFlush ?? this.processUpdates();
try {
const succeeded = await Promise.race([
flush.then(() => true as const),
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, this.PENDING_WORK_TIMEOUT);
}),
]);
return succeeded;
} catch {
return false;
}
}
public async processUpdates(): Promise<void> {
const logger = Logger.getInstance();
if (!this.updates) {
return;
}
// If a flush is already in flight, reuse it instead of creating a new promise
if (this.pendingFlush) {
return this.pendingFlush;
}
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
const flushPromise = new Promise<void>((resolve, reject) => {
return new Promise((resolve, reject) => {
const handler = async (): Promise<void> => {
try {
let currentUpdates = { ...this.updates };
@@ -177,10 +147,8 @@ export class UpdateQueue {
}
this.clearUpdates();
this.pendingFlush = null;
resolve();
} catch (error: unknown) {
this.pendingFlush = null;
logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
@@ -190,8 +158,5 @@ export class UpdateQueue {
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
});
this.pendingFlush = flushPromise;
return flushPromise;
}
}
@@ -55,8 +55,6 @@ interface FileUploadProps {
imageAltText?: string;
/** Placeholder text for the file upload */
placeholderText?: string;
/** Text to display while uploading */
uploadingText?: string;
}
interface UploadedFileItemProps {
@@ -232,7 +230,6 @@ function FileUpload({
videoUrl,
imageAltText,
placeholderText = "Click or drag to upload files",
uploadingText = "Uploading...",
}: Readonly<FileUploadProps>): React.JSX.Element {
const fileInputRef = React.useRef<HTMLInputElement>(null);
@@ -309,7 +306,7 @@ function FileUpload({
<p
className="text-muted-foreground font-medium"
style={{ fontSize: "var(--fb-input-font-size)" }}>
{uploadingText}
Uploading...
</p>
</div>
) : null}
@@ -103,7 +103,7 @@ function Matrix({
{/* Column headers */}
<thead>
<tr>
<th className="p-2 text-start" />
<th className="p-2 text-left" />
{columns.map((column) => (
<th key={column.id} className="p-2 text-center font-normal">
<Label className="justify-center">{column.label}</Label>
@@ -130,9 +130,9 @@ function Matrix({
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}>
<tr className={cn("relative", baseBgColor)} dir={dir}>
<tr className={cn("relative", baseBgColor)}>
{/* Row label */}
<th scope="row" className={cn("rounded-s-input p-2 align-middle")}>
<th scope="row" className={cn("rounded-l-input p-2 align-middle")}>
<div className="flex flex-col gap-0 leading-none">
<Label>{row.label}</Label>
</div>
@@ -145,7 +145,7 @@ function Matrix({
return (
<td
key={column.id}
className={cn("p-2 text-center align-middle", isLastColumn && "rounded-e-input")}>
className={cn("p-2 text-center align-middle", isLastColumn && "rounded-r-input")}>
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
<RadioGroupItem
value={column.id}
-4
View File
@@ -9,7 +9,6 @@
"source": "en",
"targets": [
"ar",
"cs",
"da",
"de",
"es",
@@ -19,12 +18,9 @@
"it",
"ja",
"nl",
"pl",
"pt",
"ro",
"ru",
"sk",
"sr",
"sv",
"uz",
"zh-Hans"
-2
View File
@@ -41,9 +41,7 @@ checksums:
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
errors/file_input/no_valid_file_types_selected: 795acdedcffbcf06e57ea93fc16771ce
errors/file_input/only_one_file_can_be_uploaded_at_a_time: 1eda42bd46887f9702049e23fa7cb127
errors/file_input/placeholder_text: 15b61e390b6c5501d3e3b9da9f6c7930
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
errors/file_input/uploading: baef62e2015a34d6747ed6e4192a27b1
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
errors/invalid_device_error/title: ea7dbb9970c717e4d466f8e1211bd461
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "يجب أن يكون حجم الملف أقل من {maxSizeInMB} ميجابايت",
"no_valid_file_types_selected": "لم يتم اختيار أنواع ملفات صالحة. يرجى اختيار نوع ملف صالح.",
"only_one_file_can_be_uploaded_at_a_time": "يمكن تحميل ملف واحد فقط في المرة الواحدة.",
"placeholder_text": "انقر أو اسحب لرفع الملفات",
"upload_failed": "فشل التحميل! يرجى المحاولة مرة أخرى.",
"uploading": "جارٍ الرفع...",
"you_can_only_upload_a_maximum_of_files": "يمكنك تحميل {FILE_LIMIT} ملفات كحد أقصى."
},
"invalid_device_error": {
-83
View File
@@ -1,83 +0,0 @@
{
"common": {
"and": "a",
"apply": "použít",
"auto_close_wrapper": "Automaticky uzavřít obal",
"back": "Zpět",
"close_survey": "Zavřít dotazník",
"company_logo": "Logo společnosti",
"finish": "Dokončit",
"language_switch": "Přepínač jazyka",
"next": "Další",
"open_in_new_tab": "Otevřít na nové kartě",
"people_responded": "{count, plural, one {Odpověděla 1 osoba} few {Odpověděly {count} osoby} many {Odpovědělo {count} osoby} other {Odpovědělo {count} osob}}",
"please_retry_now_or_try_again_later": "Zkus to prosím znovu teď nebo to zkus později.",
"powered_by": "Používá technologii",
"privacy_policy": "Zásady ochrany osobních údajů",
"protected_by_reCAPTCHA_and_the_Google": "Chráněno reCAPTCHA a Google",
"question": "Otázka",
"question_video": "Video otázky",
"required": "Povinné",
"respondents_will_not_see_this_card": "Respondenti tuto kartu neuvidí",
"retry": "Zkusit znovu",
"retrying": "Opakuji pokus…",
"select_option": "Vyber možnost",
"select_options": "Vyber možnosti",
"sending_responses": "Odesílám odpovědi…",
"takes_less_than_x_minutes": "{count, plural, one {Zabere méně než 1 minutu} few {Zabere méně než {count} minuty} many {Zabere méně než {count} minuty} other {Zabere méně než {count} minut}}",
"takes_x_minutes": "{count, plural, one {Zabere 1 minutu} few {Zabere {count} minuty} many {Zabere {count} minuty} other {Zabere {count} minut}}",
"takes_x_plus_minutes": "Zabere {count}+ minut",
"terms_of_service": "Smluvní podmínky",
"the_servers_cannot_be_reached_at_the_moment": "Servery momentálně nelze kontaktovat.",
"they_will_be_redirected_immediately": "Budou okamžitě přesměrováni",
"your_feedback_is_stuck": "Vaše zpětná vazba uvízla :("
},
"errors": {
"all_options_must_be_ranked": "Seřaďte prosím všechny možnosti",
"all_rows_must_be_answered": "Odpovězte prosím na všechny řádky",
"file_extension_must_be": "Přípona souboru musí být {extension}",
"file_extension_must_not_be": "Přípona souboru nesmí být {extension}",
"file_input": {
"duplicate_files": "Následující soubory jsou již nahrány: {duplicateNames}. Duplicitní soubory nejsou povoleny.",
"file_size_exceeded": "Následující soubory překračují maximální velikost {maxSizeInMB} MB a byly odstraněny: {fileNames}",
"file_size_exceeded_alert": "Soubor by měl být menší než {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nebyly vybrány žádné platné typy souborů. Vyberte prosím platný typ souboru.",
"only_one_file_can_be_uploaded_at_a_time": "Najednou lze nahrát pouze jeden soubor.",
"placeholder_text": "Klikněte nebo přetáhněte soubory k nahrání",
"upload_failed": "Nahrávání selhalo! Zkuste to prosím znovu.",
"uploading": "Nahrávám...",
"you_can_only_upload_a_maximum_of_files": "Můžete nahrát maximálně {FILE_LIMIT} souborů."
},
"invalid_device_error": {
"message": "Pokud chcete pokračovat v používání tohoto zařízení, zakažte prosím ochranu proti spamu v nastavení průzkumu.",
"title": "Toto zařízení nepodporuje ochranu proti spamu."
},
"invalid_format": "Zadejte prosím platný formát",
"is_between": "Vyberte prosím datum mezi {startDate} a {endDate}",
"is_earlier_than": "Vyberte prosím datum dřívější než {date}",
"is_greater_than": "Zadejte prosím hodnotu větší než {min}",
"is_later_than": "Prosím vyber datum pozdější než {date}",
"is_less_than": "Prosím zadej hodnotu menší než {max}",
"is_not_between": "Prosím vyber datum, které není mezi {startDate} a {endDate}",
"max_length": "Prosím zadej maximálně {max} znaků",
"max_selections": "Prosím vyber maximálně {max} možností",
"max_value": "Prosím zadej hodnotu nejvýše {max}",
"min_length": "Prosím zadej alespoň {min} znaků",
"min_selections": "Prosím vyber alespoň {min} možností",
"min_value": "Prosím zadej hodnotu alespoň {min}",
"minimum_options_ranked": "Prosím seřaď alespoň {min} možností",
"minimum_rows_answered": "Prosím odpověz alespoň na {min} řádků",
"please_enter_a_valid_email_address": "Prosím zadej platnou e-mailovou adresu",
"please_enter_a_valid_phone_number": "Prosím zadej platné telefonní číslo",
"please_enter_a_valid_url": "Prosím zadej platnou URL adresu",
"please_fill_out_this_field": "Prosím vyplň toto pole",
"recaptcha_error": {
"message": "Tvou odpověď se nepodařilo odeslat, protože byla označena jako automatizovaná aktivita. Pokud dýcháš, zkus to prosím znovu.",
"title": "Nepodařilo se nám ověřit, že jsi člověk."
},
"value_must_contain": "Hodnota musí obsahovat {value}",
"value_must_equal": "Hodnota musí být {value}",
"value_must_not_contain": "Hodnota nesmí obsahovat {value}",
"value_must_not_equal": "Hodnota nesmí být {value}"
}
}
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "Filen skal være mindre end {maxSizeInMB} MB",
"no_valid_file_types_selected": "Ingen gyldige filtyper valgt. Vælg venligst en gyldig filtype.",
"only_one_file_can_be_uploaded_at_a_time": "Du kan kun uploade én fil ad gangen.",
"placeholder_text": "Klik eller træk for at uploade filer",
"upload_failed": "Upload mislykkedes! Prøv venligst igen.",
"uploading": "Uploader...",
"you_can_only_upload_a_maximum_of_files": "Du kan maksimalt uploade {FILE_LIMIT} filer."
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "Die Datei sollte kleiner als {maxSizeInMB} MB sein",
"no_valid_file_types_selected": "Keine gültigen Dateitypen ausgewählt. Bitte wählen Sie einen gültigen Dateityp.",
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
"placeholder_text": "Klicke oder ziehe Dateien hierher zum Hochladen",
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
"uploading": "Wird hochgeladen...",
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "File should be less than {maxSizeInMB} MB",
"no_valid_file_types_selected": "No valid file types selected. Please select a valid file type.",
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
"placeholder_text": "Click or drag to upload files",
"upload_failed": "Upload failed! Please try again.",
"uploading": "Uploading...",
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "El archivo debe ser menor de {maxSizeInMB} MB",
"no_valid_file_types_selected": "No se han seleccionado tipos de archivo válidos. Por favor, selecciona un tipo de archivo válido.",
"only_one_file_can_be_uploaded_at_a_time": "Solo se puede subir un archivo a la vez.",
"placeholder_text": "Haz clic o arrastra para subir archivos",
"upload_failed": "¡Subida fallida! Por favor, inténtalo de nuevo.",
"uploading": "Subiendo...",
"you_can_only_upload_a_maximum_of_files": "Solo puedes subir un máximo de {FILE_LIMIT} archivos."
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "Le fichier doit être inférieur à {maxSizeInMB} Mo",
"no_valid_file_types_selected": "Aucun type de fichier valide sélectionné. Veuillez sélectionner un type de fichier valide.",
"only_one_file_can_be_uploaded_at_a_time": "Un seul fichier peut être téléchargé à la fois.",
"placeholder_text": "Cliquez ou glissez pour télécharger des fichiers",
"upload_failed": "Échec du téléchargement ! Veuillez réessayer.",
"uploading": "Téléchargement en cours...",
"you_can_only_upload_a_maximum_of_files": "Vous ne pouvez télécharger qu'un maximum de {FILE_LIMIT} fichiers."
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "फ़ाइल {maxSizeInMB} MB से कम होनी चाहिए",
"no_valid_file_types_selected": "कोई मान्य फ़ाइल प्रकार नहीं चुना गया है। कृपया एक मान्य फ़ाइल प्रकार चुनें।",
"only_one_file_can_be_uploaded_at_a_time": "एक समय में केवल एक फ़ाइल अपलोड की जा सकती है।",
"placeholder_text": "फ़ाइलें अपलोड करने के लिए क्लिक करें या ड्रैग करें",
"upload_failed": "अपलोड विफल! कृपया पुनः प्रयास करें।",
"uploading": "अपलोड हो रहा है...",
"you_can_only_upload_a_maximum_of_files": "आप अधिकतम {FILE_LIMIT} फ़ाइलें ही अपलोड कर सकते हैं।"
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "A fájlnak kisebbnek kell lennie mint {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nincs érvényes fájltípus kiválasztva. Válasszon egy érvényes fájltípust.",
"only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.",
"placeholder_text": "Kattints vagy húzd ide a fájlokat a feltöltéshez",
"upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.",
"uploading": "Feltöltés...",
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "Il file deve essere inferiore a {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nessun tipo di file valido selezionato. Seleziona un tipo di file valido.",
"only_one_file_can_be_uploaded_at_a_time": "È possibile caricare solo un file alla volta.",
"placeholder_text": "Clicca o trascina per caricare i file",
"upload_failed": "Caricamento fallito! Riprova.",
"uploading": "Caricamento in corso...",
"you_can_only_upload_a_maximum_of_files": "Puoi caricare un massimo di {FILE_LIMIT} file."
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "ファイルは{maxSizeInMB}MB未満である必要があります",
"no_valid_file_types_selected": "有効なファイルタイプが選択されていません。有効なファイルタイプを選択してください。",
"only_one_file_can_be_uploaded_at_a_time": "一度にアップロードできるファイルは1つだけです。",
"placeholder_text": "クリックまたはドラッグしてファイルをアップロード",
"upload_failed": "アップロードに失敗しました!もう一度お試しください。",
"uploading": "アップロード中...",
"you_can_only_upload_a_maximum_of_files": "アップロードできるファイルは最大{FILE_LIMIT}個までです。"
},
"invalid_device_error": {
-2
View File
@@ -43,9 +43,7 @@
"file_size_exceeded_alert": "Het bestand moet kleiner zijn dan {maxSizeInMB} MB",
"no_valid_file_types_selected": "Geen geldige bestandstypen geselecteerd. Selecteer een geldig bestandstype.",
"only_one_file_can_be_uploaded_at_a_time": "Er kan slechts één bestand tegelijk worden geüpload.",
"placeholder_text": "Klik of sleep bestanden om te uploaden",
"upload_failed": "Uploaden mislukt! Probeer het opnieuw.",
"uploading": "Uploaden...",
"you_can_only_upload_a_maximum_of_files": "Je kunt maximaal {FILE_LIMIT} bestanden uploaden."
},
"invalid_device_error": {
-83
View File
@@ -1,83 +0,0 @@
{
"common": {
"and": "i",
"apply": "zastosuj",
"auto_close_wrapper": "Automatyczne zamknięcie okna",
"back": "Wstecz",
"close_survey": "Zamknij ankietę",
"company_logo": "Logo firmy",
"finish": "Zakończ",
"language_switch": "Przełącznik języka",
"next": "Dalej",
"open_in_new_tab": "Otwórz w nowej karcie",
"people_responded": "{count, plural, one {1 osoba odpowiedziała} few {{count} osoby odpowiedziały} many {{count} osób odpowiedziało} other {{count} osób odpowiedziało}}",
"please_retry_now_or_try_again_later": "Spróbuj ponownie teraz lub spróbuj później.",
"powered_by": "Powered by",
"privacy_policy": "Polityka prywatności",
"protected_by_reCAPTCHA_and_the_Google": "Chronione przez reCAPTCHA i Google",
"question": "Pytanie",
"question_video": "Wideo z pytaniem",
"required": "Wymagane",
"respondents_will_not_see_this_card": "Respondenci nie zobaczą tej karty",
"retry": "Spróbuj ponownie",
"retrying": "Ponowna próba…",
"select_option": "Wybierz opcję",
"select_options": "Wybierz opcje",
"sending_responses": "Wysyłanie odpowiedzi…",
"takes_less_than_x_minutes": "{count, plural, one {Zajmie mniej niż 1 minutę} few {Zajmie mniej niż {count} minuty} many {Zajmie mniej niż {count} minut} other {Zajmie mniej niż {count} minut}}",
"takes_x_minutes": "{count, plural, one {Zajmuje 1 minutę} few {Zajmuje {count} minuty} many {Zajmuje {count} minut} other {Zajmuje {count} minuty}}",
"takes_x_plus_minutes": "Zajmuje {count}+ minut",
"terms_of_service": "Regulamin świadczenia usług",
"the_servers_cannot_be_reached_at_the_moment": "Serwery są obecnie niedostępne.",
"they_will_be_redirected_immediately": "Zostaną przekierowani natychmiast",
"your_feedback_is_stuck": "Twoja opinia utknęła :("
},
"errors": {
"all_options_must_be_ranked": "Proszę uszeregować wszystkie opcje",
"all_rows_must_be_answered": "Proszę odpowiedzieć na wszystkie wiersze",
"file_extension_must_be": "Rozszerzenie pliku musi być {extension}",
"file_extension_must_not_be": "Rozszerzenie pliku nie może być {extension}",
"file_input": {
"duplicate_files": "Następujące pliki są już przesłane: {duplicateNames}. Duplikaty plików nie są dozwolone.",
"file_size_exceeded": "Następujące pliki przekraczają maksymalny rozmiar {maxSizeInMB} MB i zostały usunięte: {fileNames}",
"file_size_exceeded_alert": "Plik powinien mieć mniej niż {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nie wybrano prawidłowych typów plików. Proszę wybrać prawidłowy typ pliku.",
"only_one_file_can_be_uploaded_at_a_time": "Można przesłać tylko jeden plik na raz.",
"placeholder_text": "Kliknij lub przeciągnij, aby przesłać pliki",
"upload_failed": "Przesyłanie nie powiodło się! Spróbuj ponownie.",
"uploading": "Przesyłanie...",
"you_can_only_upload_a_maximum_of_files": "Możesz przesłać maksymalnie {FILE_LIMIT} plików."
},
"invalid_device_error": {
"message": "Proszę wyłączyć ochronę przed spamem w ustawieniach ankiety, aby kontynuować korzystanie z tego urządzenia.",
"title": "To urządzenie nie obsługuje ochrony przed spamem."
},
"invalid_format": "Proszę wprowadzić prawidłowy format",
"is_between": "Proszę wybrać datę między {startDate} a {endDate}",
"is_earlier_than": "Proszę wybrać datę wcześniejszą niż {date}",
"is_greater_than": "Proszę wprowadzić wartość większą niż {min}",
"is_later_than": "Wybierz datę późniejszą niż {date}",
"is_less_than": "Wprowadź wartość mniejszą niż {max}",
"is_not_between": "Wybierz datę spoza przedziału od {startDate} do {endDate}",
"max_length": "Wprowadź maksymalnie {max} znaków",
"max_selections": "Wybierz maksymalnie {max} opcji",
"max_value": "Wprowadź wartość nie większą niż {max}",
"min_length": "Wprowadź co najmniej {min} znaków",
"min_selections": "Wybierz co najmniej {min} opcji",
"min_value": "Wprowadź wartość co najmniej {min}",
"minimum_options_ranked": "Uszereguj co najmniej {min} opcji",
"minimum_rows_answered": "Odpowiedz na co najmniej {min} wierszy",
"please_enter_a_valid_email_address": "Wprowadź poprawny adres e-mail",
"please_enter_a_valid_phone_number": "Wprowadź poprawny numer telefonu",
"please_enter_a_valid_url": "Wprowadź poprawny adres URL",
"please_fill_out_this_field": "Wypełnij to pole",
"recaptcha_error": {
"message": "Nie udało się przesłać odpowiedzi, ponieważ została oznaczona jako aktywność automatyczna. Jeśli oddychasz, spróbuj ponownie.",
"title": "Nie mogliśmy zweryfikować, że jesteś człowiekiem."
},
"value_must_contain": "Wartość musi zawierać {value}",
"value_must_equal": "Wartość musi być równa {value}",
"value_must_not_contain": "Wartość nie może zawierać {value}",
"value_must_not_equal": "Wartość nie może być równa {value}"
}
}

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