Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent 0992c182a3 fix: add Backbone.Collection merge updateFrom fix documentation
Adds comprehensive documentation and working example code to fix the
TypeError: Object [object Object] has no method 'updateFrom' error
that occurs when using Backbone.Collection.add() with merge: true.

The fix adds the updateFrom method to Backbone models to properly
handle attribute merging when collections use the merge option.

Includes:
- Complete MDX documentation with examples and best practices
- Working JavaScript implementation with tests
- Version checking and validation examples

Fixes FORMBRICKS-RN
2026-03-12 10:27:11 +00:00
209 changed files with 3387 additions and 8515 deletions
+2 -1
View File
@@ -150,6 +150,7 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET= NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables # Stripe Billing Variables
STRIPE_PRICING_TABLE_ID=
STRIPE_PUBLISHABLE_KEY= STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
@@ -231,4 +232,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here LINGODOTDEV_API_KEY=your_api_key_here
-8
View File
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`. - Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys. - Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance ## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment. - Multi-tenancy: All data must be scoped by Organization or Environment.
+9 -9
View File
@@ -12,18 +12,18 @@
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17", "@storybook/addon-a11y": "10.2.15",
"@storybook/addon-links": "10.2.17", "@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.17", "@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.17", "@storybook/react-vite": "10.2.15",
"@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/eslint-plugin": "8.56.1",
"@tailwindcss/vite": "4.2.1", "@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0", "@typescript-eslint/parser": "8.56.1",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17", "eslint-plugin-storybook": "10.2.14",
"storybook": "10.2.17", "storybook": "10.2.15",
"vite": "7.3.1", "vite": "7.3.1",
"@storybook/addon-docs": "10.2.17" "@storybook/addon-docs": "10.2.15"
} }
} }
+3 -6
View File
@@ -122,11 +122,8 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2 COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2 RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
# Runtime migrations import uuid v7 from the database package, so copy the COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
# database package's resolved install instead of the repo-root hoisted version. RUN chmod -R 755 ./node_modules/uuid
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid \
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -169,4 +166,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
VOLUME /home/nextjs/apps/web/uploads/ VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"] CMD ["/home/nextjs/start.sh"]
@@ -22,10 +22,12 @@ export const getTeamsByOrganizationId = reactCache(
}, },
}); });
return teams.map((team: TOrganizationTeam) => ({ const projectTeams = teams.map((team) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
})); }));
return projectTeams;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
@@ -1,12 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding"; import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
interface PlanPageProps { interface PlanPageProps {
params: Promise<{ params: Promise<{
organizationId: string; organizationId: string;
@@ -26,16 +22,6 @@ const Page = async (props: PlanPageProps) => {
return redirect(`/auth/login`); 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} />; return <SelectPlanOnboarding organizationId={params.organizationId} />;
}; };
@@ -28,7 +28,6 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; 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 { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -168,20 +167,6 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases(); if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]); }, [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/"}`; const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return ( return (
@@ -256,13 +241,6 @@ export const MainNavigation = ({
</Link> </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 */} {/* User Switch */}
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenu> <DropdownMenu>
@@ -60,7 +60,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[ buttons={[
{ {
text: IS_FORMBRICKS_CLOUD text: IS_FORMBRICKS_CLOUD
? t("common.upgrade_plan") ? t("common.start_free_trial")
: t("common.request_trial_license"), : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing` ? `/environments/${params.environmentId}/settings/billing`
@@ -1,146 +0,0 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};
@@ -6,23 +6,22 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions"; import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard"; import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps { interface EnterpriseLicenseStatusProps {
status: TLicenseStatus; status: LicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date; gracePeriodEnd?: Date;
environmentId: string; environmentId: string;
} }
const getBadgeConfig = ( const getBadgeConfig = (
status: TLicenseStatus, status: LicenseStatus,
t: TFunction t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => { ): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) { switch (status) {
@@ -30,11 +29,6 @@ const getBadgeConfig = (
return { type: "success", label: t("environments.settings.enterprise.license_status_active") }; return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired": case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") }; return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "instance_mismatch":
return {
type: "error",
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
};
case "unreachable": case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") }; return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license": case "invalid_license":
@@ -46,12 +40,10 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({ export const EnterpriseLicenseStatus = ({
status, status,
lastChecked,
gracePeriodEnd, gracePeriodEnd,
environmentId, environmentId,
}: EnterpriseLicenseStatusProps) => { }: EnterpriseLicenseStatusProps) => {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter(); const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false); const [isRechecking, setIsRechecking] = useState(false);
@@ -67,8 +59,6 @@ export const EnterpriseLicenseStatus = ({
if (result?.data) { if (result?.data) {
if (result.data.status === "unreachable") { if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable")); toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "instance_mismatch") {
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
} else if (result.data.status === "invalid_license") { } else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid")); toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else { } else {
@@ -96,12 +86,7 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-3"> <Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div> </div>
<Button <Button
type="button" type="button"
@@ -127,7 +112,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small"> <Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal"> <AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", { {t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, { gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -143,13 +128,6 @@ export const EnterpriseLicenseStatus = ({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{status === "instance_mismatch" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_instance_mismatch_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500"> <p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "} {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a <a
@@ -10,7 +10,6 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
@@ -94,19 +93,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/> />
</PageHeader> </PageHeader>
{hasLicense ? ( {hasLicense ? (
<> <EnterpriseLicenseStatus
<EnterpriseLicenseStatus status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
status={licenseState.status} gracePeriodEnd={
lastChecked={licenseState.lastChecked} licenseState.status === "unreachable"
gracePeriodEnd={ ? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
licenseState.status === "unreachable" : undefined
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS) }
: undefined environmentId={params.environmentId}
} />
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : ( ) : (
<div> <div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0; const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns // Generate columns
const columns = useMemo( const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn), () => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn] [survey, isExpanded, isReadOnly, t, showQuotasColumn]
); );
// Save settings to localStorage when they change // Save settings to localStorage when they change
@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime"; import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -35,7 +34,6 @@ const getElementColumnsData = (
element: TSurveyElement, element: TSurveyElement,
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
locale: TUserLocale,
t: TFunction t: TFunction
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t); const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -169,7 +167,6 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -221,7 +218,6 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
isReadOnly: boolean, isReadOnly: boolean,
locale: TUserLocale,
t: TFunction, t: TFunction,
showQuotasColumn: boolean showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks); const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = { const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt", accessorKey: "createdAt",
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
size: 200, size: 200,
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.createdAt); const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>; return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
}, },
}; };
@@ -1,17 +1,13 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
DEFAULT_LOCALE,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
RESPONSES_PER_PAGE,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -27,12 +23,13 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([ const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId), getSurvey(params.surveyId),
getUser(session.user.id), getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId), getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id), getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId), getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]); ]);
if (!survey) { if (!survey) {
@@ -89,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags} environmentTags={tags}
user={user} user={user}
responsesPerPage={RESPONSES_PER_PAGE} responsesPerPage={RESPONSES_PER_PAGE}
locale={user.locale ?? DEFAULT_LOCALE} locale={locale}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
quotas={quotas} quotas={quotas}
@@ -64,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({ const ZResetSurveyAction = z.object({
surveyId: ZId, surveyId: ZId,
organizationId: ZId,
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{ {
type: "projectTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
projectId, projectId: parsedInput.projectId,
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null; ctx.auditLoggingCtx.oldObject = null;
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display"; import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,9 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
}; };
const renderResponseValue = (value: string) => { const renderResponseValue = (value: string) => {
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale); const parsedDate = new Date(value);
return formattedDate ?? `${t("common.invalid_date")}(${value})`; const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
}; };
return ( return (
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const { project } = useEnvironment(); const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true); setIsResetting(true);
const result = await resetSurveyAction({ const result = await resetSurveyAction({
surveyId: survey.id, surveyId: survey.id,
organizationId: organizationId,
projectId: project.id, projectId: project.id,
}); });
if (result?.data) { if (result?.data) {
@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")} description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[ 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 href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing` ? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license", : "https://formbricks.com/upgrade-self-hosting-license",
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper"; import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service"; import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID; const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([ const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]); ]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find( const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (airtableIntegration?.config.key) { if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId); airtableArray = await getAirtableTables(params.environmentId);
} }
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
} }
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id} environmentId={environment.id}
surveys={surveys} surveys={surveys}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</div> </div>
</PageContentWrapper> </PageContentWrapper>
@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper"; import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET, GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL, GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL, WEBAPP_URL,
} from "@/lib/constants"; } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL); const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([ const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]); ]);
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find( const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets" (integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
); );
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
} }
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys} surveys={surveys}
googleSheetIntegration={googleSheetIntegration} googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</div> </div>
</PageContentWrapper> </PageContentWrapper>
@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper"; import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import { import {
DEFAULT_LOCALE,
NOTION_AUTH_URL, NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET, NOTION_OAUTH_CLIENT_SECRET,
@@ -12,7 +11,7 @@ import {
} from "@/lib/constants"; } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service"; import { getNotionDatabases } from "@/lib/notion/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI NOTION_REDIRECT_URI
); );
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration, locale] = await Promise.all([ const [surveys, notionIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"), getIntegrationByType(params.environmentId, "notion"),
getUserLocale(session.user.id),
]); ]);
let databasesArray: TIntegrationNotionDatabase[] = []; let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) { if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? []; databasesArray = (await getNotionDatabases(environment.id)) ?? [];
} }
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion} notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
databasesArray={databasesArray} databasesArray={databasesArray}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</PageContentWrapper> </PageContentWrapper>
); );
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper"; import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants"; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate(); const t = await getTranslate();
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration, locale] = await Promise.all([ const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"), getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]); ]);
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
} }
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys} surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack} slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</div> </div>
</PageContentWrapper> </PageContentWrapper>
@@ -50,7 +50,6 @@ vi.mock("@/lib/env", () => ({
RECAPTCHA_SITE_KEY: "site-key", RECAPTCHA_SITE_KEY: "site-key",
RECAPTCHA_SECRET_KEY: "secret-key", RECAPTCHA_SECRET_KEY: "secret-key",
GITHUB_ID: "github-id", 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.userCount).toBe(5);
expect(payload.integrations.notion).toBe(true); expect(payload.integrations.notion).toBe(true);
expect(payload.sso.github).toBe(true); expect(payload.sso.github).toBe(true);
expect(payload.sso.saml).toBe(true);
// Check cache update (no TTL parameter) // Check cache update (no TTL parameter)
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String)); 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"), google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"), azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"), 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. // 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) { if (featureCheckResult) {
return { return {
response: featureCheckResult, 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 { TOrganization } from "@formbricks/types/organizations";
import { import {
TSurvey,
TSurveyCreateInputWithEnvironmentId, TSurveyCreateInputWithEnvironmentId,
TSurveyQuestionTypeEnum, TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types"; } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { checkFeaturePermissions } from "./utils"; import { checkFeaturePermissions } from "./utils";
// Mock dependencies // Mock dependencies
@@ -26,14 +24,6 @@ vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
getSurveyFollowUpsPermission: vi.fn(), 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 = { const mockOrganization: TOrganization = {
id: "test-org", id: "test-org",
name: "Test Organization", name: "Test Organization",
@@ -108,13 +98,6 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
}; };
describe("checkFeaturePermissions", () => { 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 () => { test("should return null if no restricted features are used", async () => {
const surveyData = { ...baseSurveyData }; const surveyData = { ...baseSurveyData };
const result = await checkFeaturePermissions(surveyData, mockOrganization); const result = await checkFeaturePermissions(surveyData, mockOrganization);
@@ -214,315 +197,4 @@ describe("checkFeaturePermissions", () => {
); );
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure 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 { 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 { responses } from "@/app/lib/api/response";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
export const checkFeaturePermissions = async ( export const checkFeaturePermissions = async (
surveyData: TSurveyCreateInputWithEnvironmentId, surveyData: TSurveyCreateInputWithEnvironmentId,
organization: TOrganization, organization: TOrganization
oldSurvey?: TSurvey
): Promise<Response | null> => { ): Promise<Response | null> => {
if (surveyData.recaptcha?.enabled) { if (surveyData.recaptcha?.enabled) {
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id); 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; return null;
}; };
+19 -49
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps { interface ChatwootWidgetProps {
chatwootBaseUrl: string; chatwootBaseUrl: string;
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script"; const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({ export const ChatwootWidget = ({
userEmail, userEmail,
userName, userName,
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
chatwootBaseUrl, chatwootBaseUrl,
}: ChatwootWidgetProps) => { }: ChatwootWidgetProps) => {
const userSetRef = useRef(false); const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => { const setUserInfo = useCallback(() => {
const $chatwoot = getChatwoot(); const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) { if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, { $chatwoot.setUser(userId, {
email: userEmail, email: userEmail,
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
}); });
userSetRef.current = true; userSetRef.current = true;
} }
}, [userId, userEmail, userName, getChatwoot]); }, [userId, userEmail, userName]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
useEffect(() => { useEffect(() => {
if (!chatwootWebsiteToken) return; if (!chatwootWebsiteToken) return;
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo(); const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady); globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready // Check if Chatwoot is already ready
if (getChatwoot()) { if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo(); setUserInfo();
} }
return () => { return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady); globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = getChatwoot(); const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) { if ($chatwoot) {
$chatwoot.reset(); $chatwoot.reset();
} }
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
scriptElement?.remove(); scriptElement?.remove();
userSetRef.current = false; userSetRef.current = false;
customerStatusSetRef.current = false;
}; };
}, [ }, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
return null; return null;
}; };
-18
View File
@@ -1,18 +0,0 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});
@@ -1,15 +1,12 @@
"use server"; "use server";
import { z } from "zod"; import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service"; import { gethasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service"; import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service"; import { createOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; 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"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
const ZCreateOrganizationAction = z.object({ const ZCreateOrganizationAction = z.object({
@@ -36,16 +33,6 @@ export const createOrganizationAction = authenticatedActionClient
accepted: true, 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.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization; ctx.auditLoggingCtx.newObject = newOrganization;
+19 -87
View File
@@ -267,7 +267,6 @@ checksums:
common/new: 126d036fae5fb6b629728ecb97e6195b common/new: 126d036fae5fb6b629728ecb97e6195b
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
common/next: 89ddbcf710eba274963494f312bdc8a9 common/next: 89ddbcf710eba274963494f312bdc8a9
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4 common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
@@ -313,7 +312,6 @@ checksums:
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50 common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2 common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08 common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -374,7 +372,7 @@ checksums:
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836 common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/sort_by: 8adf3dbc5668379558957662f0c43563 common/sort_by: 8adf3dbc5668379558957662f0c43563
common/start_free_trial: e346e4ed7d138dcc873db187922369da common/start_free_trial: 4fab76a3fc5d5c94e3248cd279cfdd14
common/status: 4e1fcce15854d824919b4a582c697c90 common/status: 4e1fcce15854d824919b4a582c697c90
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6 common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
@@ -409,9 +407,6 @@ checksums:
common/title: 344e64395eaff6822a57d18623853e1a common/title: 344e64395eaff6822a57d18623853e1a
common/top_left: aa61bb29b56df3e046b6d68d89ee8986 common/top_left: aa61bb29b56df3e046b6d68d89ee8986
common/top_right: 241f95c923846911aaf13af6109333e5 common/top_right: 241f95c923846911aaf13af6109333e5
common/trial_days_remaining: 914ff3132895e410bf0f862433ccb49e
common/trial_expired: ca9f0532ac40ca427ca1ba4c86454e07
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5 common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75 common/type: f04471a7ddac844b9ad145eb9911ef75
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498 common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
@@ -419,7 +414,6 @@ checksums:
common/update: 079fc039262fd31b10532929685c2d1b common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4 common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
common/updated_at: 8fdb85248e591254973403755dcc3724 common/updated_at: 8fdb85248e591254973403755dcc3724
common/upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7 common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
@@ -919,80 +913,44 @@ checksums:
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89 environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133 environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02 environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
environments/settings/billing/add_payment_method_to_upgrade_tooltip: 977005ad38bfe0800a78c21edcd16e4d
environments/settings/billing/billing_interval_toggle: 62c76eb73507108fc6aefdf1ab86cc38
environments/settings/billing/current_plan_badge: 27f172f76ac28e72cb062f80002b0ad5
environments/settings/billing/current_plan_cta: 53ac259fd40a361274861ee7c7498424
environments/settings/billing/custom_plan_description: 53faa38123cc74e5adc7e59630641d66
environments/settings/billing/custom_plan_title: f3b71be0d1cd4f81a177ada040119f30
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380 environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
environments/settings/billing/keep_current_plan: 57ac15ffa2c29ac364dd405669eeb7f6 environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
environments/settings/billing/manage_billing_details: 40448f0b5ed4b3bb1d864ba6e1bb6a3b
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
environments/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
environments/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
environments/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
environments/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
environments/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
environments/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
environments/settings/billing/plan_change_applied: d1e04599487247dd0e21a7d99785dc7a
environments/settings/billing/plan_change_scheduled: 16455d4aa9a152b156ee434d8c7e34d4
environments/settings/billing/plan_custom: b7b89901f46267f532600a23cfc54ae2 environments/settings/billing/plan_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: 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: 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: 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/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280 environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
environments/settings/billing/retry_setup: bef560e42fa8798271fea150476791e0 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_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/status_trialing: 4fd32760caf3bd7169935b0a6d2b5b67
environments/settings/billing/stay_on_hobby_plan: 966ab0c752a79f00ef10d6a5ed1d8cad environments/settings/billing/stay_on_hobby_plan: 966ab0c752a79f00ef10d6a5ed1d8cad
environments/settings/billing/stripe_setup_incomplete: fa6d6e295dd14b73c17ac8678205109b environments/settings/billing/stripe_setup_incomplete: fa6d6e295dd14b73c17ac8678205109b
environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284 environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284
environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343 environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343
environments/settings/billing/subscription_description: b03618508e576666198d4adf3c2cb9a9 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_already_used: 5433347ff7647fe0aba0fe91a44560ba
environments/settings/billing/trial_feature_api_access: 8c6d03728c3d9470616eb5cee5f9f65d environments/settings/billing/trial_feature_api_access: d7aabb2de18beb5bd30c274cd768a2a9
environments/settings/billing/trial_feature_attribute_segmentation: 90087da973ae48e32ec6d863516fc8c9 environments/settings/billing/trial_feature_collaboration: a43509fffe319e14d69a981ef2791517
environments/settings/billing/trial_feature_contact_segment_management: 27f17a039ebed6413811ab3a461db2f4 environments/settings/billing/trial_feature_email_followups: add368efdd84c5aef8886f369d54cbed
environments/settings/billing/trial_feature_email_followups: 0cc02dc14aa28ce94ca6153c306924e5 environments/settings/billing/trial_feature_quotas: 3a67818b3901bdaa72abc62db72ab170
environments/settings/billing/trial_feature_hide_branding: b8dbcb24e50e0eb4aeb0c97891cac61d environments/settings/billing/trial_feature_webhooks: 8d7f034e006b2fe0eb8fa9b8f1abef51
environments/settings/billing/trial_feature_mobile_sdks: 0963480a27df49657c1b7507adec9a06 environments/settings/billing/trial_feature_whitelabel: 624a7aeca6a0fa65935c63fd7a8e9638
environments/settings/billing/trial_feature_respondent_identification: a82e24ab4c27c5e485326678d9b7bd79
environments/settings/billing/trial_feature_unlimited_seats: a3257d5b6a23bfbc4b7fd1108087a823
environments/settings/billing/trial_feature_webhooks: 5ead39fba97fbd37835a476ee67fdd94
environments/settings/billing/trial_no_credit_card: 01c70aa6e1001815a9a11951394923ca environments/settings/billing/trial_no_credit_card: 01c70aa6e1001815a9a11951394923ca
environments/settings/billing/trial_payment_method_added_description: 872a5c557f56bafc9b7ec4895f9c33e8 environments/settings/billing/trial_title: 23d0d2cbe306ae0f784b8289bf66a2c7
environments/settings/billing/trial_title: f2c3791c1fb2970617ec0f2d243a931b
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1 environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173 environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2 environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e 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/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3 environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
@@ -1014,32 +972,11 @@ checksums:
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12 environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44 environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526 environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8 environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
environments/settings/enterprise/license_status_active: 3e1ec025c4a50830bbb9ad57a176630a environments/settings/enterprise/license_status_active: 3e1ec025c4a50830bbb9ad57a176630a
environments/settings/enterprise/license_status_description: 828e4527f606471cd8cf58b55ff824f6 environments/settings/enterprise/license_status_description: 828e4527f606471cd8cf58b55ff824f6
environments/settings/enterprise/license_status_expired: 63b27cccba4ab2143e0f5f3d46e4168a environments/settings/enterprise/license_status_expired: 63b27cccba4ab2143e0f5f3d46e4168a
environments/settings/enterprise/license_status_instance_mismatch: 2c85ca34eef67c5ca34477dc1eda68c0
environments/settings/enterprise/license_status_invalid: a4bfd3787fc0bf0a38db61745bd25cec environments/settings/enterprise/license_status_invalid: a4bfd3787fc0bf0a38db61745bd25cec
environments/settings/enterprise/license_status_unreachable: 202b110dab099f1167b13c326349e570 environments/settings/enterprise/license_status_unreachable: 202b110dab099f1167b13c326349e570
environments/settings/enterprise/license_unreachable_grace_period: c0587c9d79ac55ff2035fb8b8eec4433 environments/settings/enterprise/license_unreachable_grace_period: c0587c9d79ac55ff2035fb8b8eec4433
@@ -1050,7 +987,6 @@ checksums:
environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7 environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7
environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa
environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c
environments/settings/enterprise/recheck_license_instance_mismatch: 655cd1cce2f25b100439d8725c1e72f2
environments/settings/enterprise/recheck_license_invalid: 58f41bc208692b7d53b975dfcf9f4ad8 environments/settings/enterprise/recheck_license_invalid: 58f41bc208692b7d53b975dfcf9f4ad8
environments/settings/enterprise/recheck_license_success: 700ddd805be904a415f614de3df1da78 environments/settings/enterprise/recheck_license_success: 700ddd805be904a415f614de3df1da78
environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175 environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175
@@ -1380,7 +1316,6 @@ checksums:
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7 environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113 environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413 environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66 environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722 environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350 environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
@@ -1638,7 +1573,6 @@ checksums:
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05 environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60 environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7 environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781 environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
@@ -1648,12 +1582,10 @@ checksums:
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69 environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0 environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486 environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919 environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2 environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11 environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579 environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
@@ -2922,7 +2854,7 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72 templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0 templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940 templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830 templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00 templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
@@ -3175,7 +3107,7 @@ checksums:
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2 templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23 workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039 workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: ead918852c5840636a14baabfe94821e workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111 workflows/heading: a98a6b14d3e955f38cc16386df9a4111
+1 -1
View File
@@ -55,7 +55,7 @@ describe("Crypto Utils", () => {
// But both should verify correctly // But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true); expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true); expect(await verifySecret(secret, hash2)).toBe(true);
}, 15000); });
test("should use custom cost factor", async () => { test("should use custom cost factor", async () => {
const secret = "test-secret-123"; const secret = "test-secret-123";
+2
View File
@@ -85,6 +85,7 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(), STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_PRICING_TABLE_ID: z.string().optional(),
PUBLIC_URL: z PUBLIC_URL: z
.url() .url()
.refine( .refine(
@@ -202,6 +203,7 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY, STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID,
PUBLIC_URL: process.env.PUBLIC_URL, PUBLIC_URL: process.env.PUBLIC_URL,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY, TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_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; const stripeCustomerId = deletedOrganization.billing?.stripeCustomerId;
if (IS_FORMBRICKS_CLOUD && 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) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
-56
View File
@@ -18,18 +18,6 @@ describe("Time Utilities", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024"); expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
}); });
test("should format date string with the provided locale", () => {
const date = new Date("2024-03-20T12:30:00");
expect(convertDateString("2024-03-20T12:30:00", "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("should return empty string for empty input", () => { test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe(""); expect(convertDateString("")).toBe("");
}); });
@@ -58,20 +46,6 @@ describe("Time Utilities", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM"); expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
}); });
test("should format date and time string in the provided locale", () => {
const date = new Date("2024-03-20T15:30:00");
expect(convertDateTimeStringShort("2024-03-20T15:30:00", "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
});
test("should return empty string for empty input", () => { test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe(""); expect(convertDateTimeStringShort("")).toBe("");
}); });
@@ -101,18 +75,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan"); expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
}); });
test("should format time since in Brazilian Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
});
test("should format time since in European Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
});
}); });
describe("timeSinceDate", () => { describe("timeSinceDate", () => {
@@ -121,12 +83,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago"); expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
}); });
test("should format time since from Date object in the provided locale", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
});
}); });
describe("formatDate", () => { describe("formatDate", () => {
@@ -134,18 +90,6 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based) const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024"); expect(formatDate(date)).toBe("March 20, 2024");
}); });
test("should format date with the provided locale", () => {
const date = new Date(2024, 2, 20);
expect(formatDate(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
}); });
describe("getTodaysDateFormatted", () => { describe("getTodaysDateFormatted", () => {
+36 -26
View File
@@ -1,11 +1,8 @@
import { formatDistance, intlFormat } from "date-fns"; import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale"; import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay, formatDateTimeForDisplay } from "./utils/datetime";
const DEFAULT_LOCALE = "en-US"; export const convertDateString = (dateString: string | null) => {
export const convertDateString = (dateString: string | null, locale: string = DEFAULT_LOCALE) => {
if (dateString === null) return null; if (dateString === null) return null;
if (!dateString) { if (!dateString) {
return dateString; return dateString;
@@ -15,25 +12,41 @@ export const convertDateString = (dateString: string | null, locale: string = DE
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return "Invalid Date"; return "Invalid Date";
} }
return formatDateForDisplay(date, locale); return intlFormat(
date,
{
year: "numeric",
month: "short",
day: "numeric",
},
{
locale: "en",
}
);
}; };
export const convertDateTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => { export const convertDateTimeString = (dateString: string) => {
if (!dateString) { if (!dateString) {
return dateString; return dateString;
} }
const date = new Date(dateString); const date = new Date(dateString);
return formatDateTimeForDisplay(date, locale, { return intlFormat(
weekday: "long", date,
year: "numeric", {
month: "long", weekday: "long",
day: "numeric", year: "numeric",
hour: "numeric", month: "long",
minute: "2-digit", day: "numeric",
}); hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
}; };
export const convertDateTimeStringShort = (dateString: string, locale: string = DEFAULT_LOCALE) => { export const convertDateTimeStringShort = (dateString: string) => {
if (!dateString) { if (!dateString) {
return dateString; return dateString;
} }
@@ -48,12 +61,12 @@ export const convertDateTimeStringShort = (dateString: string, locale: string =
minute: "2-digit", minute: "2-digit",
}, },
{ {
locale, locale: "en",
} }
); );
}; };
export const convertTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => { export const convertTimeString = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return intlFormat( return intlFormat(
date, date,
@@ -63,12 +76,12 @@ export const convertTimeString = (dateString: string, locale: string = DEFAULT_L
second: "2-digit", second: "2-digit",
}, },
{ {
locale, locale: "en",
} }
); );
}; };
const getLocaleForTimeSince = (locale: TUserLocale | string) => { const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) { switch (locale) {
case "de-DE": case "de-DE":
return de; return de;
@@ -98,12 +111,10 @@ const getLocaleForTimeSince = (locale: TUserLocale | string) => {
return zhCN; return zhCN;
case "zh-Hant-TW": case "zh-Hant-TW":
return zhTW; return zhTW;
default:
return enUS;
} }
}; };
export const timeSince = (dateString: string, locale: TUserLocale | string = DEFAULT_LOCALE) => { export const timeSince = (dateString: string, locale: TUserLocale) => {
const date = new Date(dateString); const date = new Date(dateString);
return formatDistance(date, new Date(), { return formatDistance(date, new Date(), {
addSuffix: true, addSuffix: true,
@@ -111,15 +122,14 @@ export const timeSince = (dateString: string, locale: TUserLocale | string = DEF
}); });
}; };
export const timeSinceDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => { export const timeSinceDate = (date: Date) => {
return formatDistance(date, new Date(), { return formatDistance(date, new Date(), {
addSuffix: true, addSuffix: true,
locale: getLocaleForTimeSince(locale),
}); });
}; };
export const formatDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => { export const formatDate = (date: Date) => {
return formatDateForDisplay(date, locale, { return intlFormat(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
-67
View File
@@ -1,67 +0,0 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
describe("date display utils", () => {
test("parses ISO stored dates", () => {
const parsedDate = parseStoredDateValue("2025-05-06");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses legacy stored dates using the element format", () => {
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses day-first stored dates when no format is provided", () => {
const parsedDate = parseStoredDateValue("06-05-2025");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("formats stored dates using the selected locale", () => {
const date = new Date(2025, 4, 6);
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("returns null for invalid stored dates", () => {
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
});
test("builds a date format map for survey date elements", () => {
const elements = [
{
id: "dateQuestion",
type: "date",
format: "d-M-y",
},
{
id: "textQuestion",
type: "openText",
},
] as TSurveyElement[];
expect(getSurveyDateFormatMap(elements)).toEqual({
dateQuestion: "d-M-y",
});
});
});
-83
View File
@@ -1,83 +0,0 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
const buildDate = (year: number, month: number, day: number): Date | null => {
if ([year, month, day].some((value) => Number.isNaN(value))) {
return null;
}
const parsedDate = new Date(year, month - 1, day);
if (
parsedDate.getFullYear() !== year ||
parsedDate.getMonth() !== month - 1 ||
parsedDate.getDate() !== day
) {
return null;
}
return parsedDate;
};
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
const parts = value.split("-");
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
return null;
}
const [first, second, third] = parts.map(Number);
switch (format) {
case "M-d-y":
return buildDate(third, first, second);
case "d-M-y":
return buildDate(third, second, first);
case "y-M-d":
return buildDate(first, second, third);
}
};
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
const isoMatch = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (isoMatch) {
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
}
if (format) {
return parseLegacyStoredDateValue(value, format);
}
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
return parseLegacyStoredDateValue(value, "d-M-y");
}
return null;
};
export const formatStoredDateForDisplay = (
value: string,
format: TSurveyDateElement["format"] | undefined,
locale: string = "en-US"
): string | null => {
const parsedDate = parseStoredDateValue(value, format);
if (!parsedDate) {
return null;
}
return formatDateWithOrdinal(parsedDate, locale);
};
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
if (element.type === "date") {
dateFormats[element.id] = element.format;
}
return dateFormats;
}, {});
};
+4 -43
View File
@@ -1,12 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
diffInDays,
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
getFormattedDateTimeString,
isValidDateString,
} from "./datetime";
describe("datetime utils", () => { describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => { test("diffInDays calculates the difference in days between two dates", () => {
@@ -15,45 +8,13 @@ describe("datetime utils", () => {
expect(diffInDays(date1, date2)).toBe(5); expect(diffInDays(date1, date2)).toBe(5);
}); });
test("formatDateWithOrdinal formats a date using the provided locale", () => { test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
// Create a date that's fixed to May 6, 2025 at noon UTC // Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones // Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0)); const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateWithOrdinal(date)).toBe( // Test the function
new Intl.DateTimeFormat("en-US", { expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("formatDateForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateForDisplay(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("formatDateTimeForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
}); });
test("isValidDateString validates correct date strings", () => { test("isValidDateString validates correct date strings", () => {
+13 -44
View File
@@ -1,17 +1,7 @@
const DEFAULT_LOCALE = "en-US"; const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = { const relevantDigits = day < 30 ? day % 20 : day % 30;
year: "numeric", return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
month: "short",
day: "numeric",
};
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}; };
// Helper function to calculate difference in days between two dates // Helper function to calculate difference in days between two dates
@@ -20,44 +10,23 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24)); return Math.floor(diffTime / (1000 * 60 * 60 * 24));
}; };
export const formatDateForDisplay = ( export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
date: Date, const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
locale: string = DEFAULT_LOCALE, const day = date.getDate();
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
): string => { const year = date.getFullYear();
return new Intl.DateTimeFormat(locale, options).format(date); return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};
export const formatDateTimeForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
return formatDateForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}; };
export const isValidDateString = (value: string) => { export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/; const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
if (!regex.test(value)) { if (!regex.test(value)) {
return false; return false;
} }
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value) const date = new Date(value);
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1") return date;
: value;
const date = new Date(normalizedValue);
return !Number.isNaN(date.getTime());
}; };
export const getFormattedDateTimeString = (date: Date): string => { export const getFormattedDateTimeString = (date: Date): string => {
+10 -24
View File
@@ -32,17 +32,16 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))), structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
})); }));
vi.mock("@/lib/utils/date-display", () => ({ vi.mock("@/lib/utils/datetime", () => ({
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => { isValidDateString: vi.fn((value) => {
if (value === "2023-01-01") { try {
return `formatted-${locale}-${format ?? "iso"}`; return !isNaN(new Date(value as string).getTime());
} catch {
return false;
} }
}),
if (value === "01-02-2023" && format === "M-d-y") { formatDateWithOrdinal: vi.fn(() => {
return `legacy-${locale}-${format}`; return "January 1st, 2023";
}
return null;
}), }),
})); }));
@@ -478,20 +477,7 @@ describe("recall utility functions", () => {
}; };
const result = parseRecallInfo(text, responseData); const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on formatted-en-US-iso"); expect(result).toBe("You joined on January 1st, 2023");
});
test("formats legacy date values using the provided locale and stored format", () => {
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
const responseData: TResponseData = {
joinDate: "01-02-2023",
};
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
joinDate: "M-d-y",
});
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
}); });
test("formats array values as comma-separated list", () => { test("formats array values as comma-separated list", () => {
+7 -11
View File
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display"; import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks { export interface fallbacks {
[id: string]: string; [id: string]: string;
@@ -224,9 +224,7 @@ export const parseRecallInfo = (
text: string, text: string,
responseData?: TResponseData, responseData?: TResponseData,
variables?: TResponseVariables, variables?: TResponseVariables,
withSlash: boolean = false, withSlash: boolean = false
locale: string = "en-US",
dateFormats?: TSurveyDateFormatMap
) => { ) => {
let modifiedText = text; let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : []; const questionIds = responseData ? Object.keys(responseData) : [];
@@ -256,14 +254,12 @@ export const parseRecallInfo = (
value = responseData[recallItemId]; value = responseData[recallItemId];
// Apply formatting for special value types // Apply formatting for special value types
if (typeof value === "string") { if (value) {
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale); if (isValidDateString(value as string)) {
value = formatDateWithOrdinal(new Date(value as string));
if (formattedDate) { } else if (Array.isArray(value)) {
value = formattedDate; value = value.filter((item) => item).join(", ");
} }
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
} }
} }
+19 -87
View File
@@ -294,7 +294,6 @@
"new": "Neu", "new": "Neu",
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!", "new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
"next": "Weiter", "next": "Weiter",
"no_actions_found": "Keine Aktionen gefunden",
"no_background_image_found": "Kein Hintergrundbild gefunden.", "no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code", "no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen", "no_files_uploaded": "Keine Dateien hochgeladen",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus", "please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus", "please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan", "please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau", "preview": "Vorschau",
"preview_survey": "Umfragevorschau", "preview_survey": "Umfragevorschau",
"privacy": "Datenschutz", "privacy": "Datenschutz",
@@ -401,7 +399,7 @@
"something_went_wrong": "Etwas ist schiefgelaufen", "something_went_wrong": "Etwas ist schiefgelaufen",
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.", "something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.",
"sort_by": "Sortieren nach", "sort_by": "Sortieren nach",
"start_free_trial": "Kostenlose Testversion starten", "start_free_trial": "Kostenlos starten",
"status": "Status", "status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung", "step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen", "storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
@@ -436,9 +434,6 @@
"title": "Titel", "title": "Titel",
"top_left": "Oben links", "top_left": "Oben links",
"top_right": "Oben rechts", "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", "try_again": "Versuch's nochmal",
"type": "Typ", "type": "Typ",
"unknown_survey": "Unbekannte Umfrage", "unknown_survey": "Unbekannte Umfrage",
@@ -446,7 +441,6 @@
"update": "Aktualisierung", "update": "Aktualisierung",
"updated": "Aktualisiert", "updated": "Aktualisiert",
"updated_at": "Aktualisiert am", "updated_at": "Aktualisiert am",
"upgrade_plan": "Plan upgraden",
"upload": "Hochladen", "upload": "Hochladen",
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.", "upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.", "upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
@@ -974,80 +968,44 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen" "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
}, },
"billing": { "billing": {
"add_payment_method": "Zahlungsmethode hinzufügen", "cancelling": "Wird storniert",
"add_payment_method_to_upgrade_tooltip": "Bitte füge oben eine Zahlungsmethode hinzu, um auf einen kostenpflichtigen Plan zu upgraden",
"billing_interval_toggle": "Abrechnungsintervall",
"current_plan_badge": "Aktuell",
"current_plan_cta": "Aktueller Tarif",
"custom_plan_description": "Deine Organisation nutzt ein individuelles Abrechnungsmodell. Du kannst trotzdem zu einem der Standardtarife unten wechseln.",
"custom_plan_title": "Individueller Tarif",
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.", "failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
"keep_current_plan": "Aktuellen Tarif beibehalten", "manage_subscription": "Abonnement verwalten",
"manage_billing_details": "Kartendaten & Rechnungen verwalten",
"monthly": "Monatlich",
"most_popular": "Am beliebtesten",
"pending_change_removed": "Geplante Tarifänderung entfernt.",
"pending_plan_badge": "Geplant",
"pending_plan_change_description": "Dein Tarif wechselt am {{date}} zu {{plan}}.",
"pending_plan_change_title": "Geplante Tarifänderung",
"pending_plan_cta": "Geplant",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_change_applied": "Tarif erfolgreich aktualisiert.",
"plan_change_scheduled": "Tarifänderung erfolgreich geplant.",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Alles aus Hobby",
"plan_feature_everything_in_pro": "Alles aus Pro",
"plan_hobby": "Hobby", "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": "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": "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", "plan_unknown": "Unbekannt",
"remove_branding": "Branding entfernen", "remove_branding": "Branding entfernen",
"retry_setup": "Erneut einrichten", "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_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", "status_trialing": "Trial",
"stay_on_hobby_plan": "Ich möchte beim Hobby-Plan bleiben", "stay_on_hobby_plan": "Ich möchte beim Hobby-Plan bleiben",
"stripe_setup_incomplete": "Abrechnungseinrichtung unvollständig", "stripe_setup_incomplete": "Abrechnungseinrichtung unvollständig",
"stripe_setup_incomplete_description": "Die Abrechnungseinrichtung war nicht erfolgreich. Bitte versuche es erneut, um Dein Abo zu aktivieren.", "stripe_setup_incomplete_description": "Die Abrechnungseinrichtung war nicht erfolgreich. Bitte versuche es erneut, um Dein Abo zu aktivieren.",
"subscription": "Abonnement", "subscription": "Abonnement",
"subscription_description": "Verwalte Dein Abonnement und behalte Deine Nutzung im Blick", "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_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_api_access": "Vollen API-Zugriff erhalten",
"trial_feature_attribute_segmentation": "Attributbasierte Segmentierung", "trial_feature_collaboration": "Alle Team- und Kollaborationsfunktionen",
"trial_feature_contact_segment_management": "Kontakt- & Segmentverwaltung", "trial_feature_email_followups": "E-Mail-Nachfassaktionen einrichten",
"trial_feature_email_followups": "E-Mail-Nachfassaktionen", "trial_feature_quotas": "Kontingente verwalten",
"trial_feature_hide_branding": "Formbricks-Branding ausblenden", "trial_feature_webhooks": "Benutzerdefinierte Webhooks einrichten",
"trial_feature_mobile_sdks": "iOS & Android SDKs", "trial_feature_whitelabel": "Vollständig white-labeled Umfragen",
"trial_feature_respondent_identification": "Befragten-Identifikation",
"trial_feature_unlimited_seats": "Unbegrenzte Benutzerplätze",
"trial_feature_webhooks": "Individuelle Webhooks",
"trial_no_credit_card": "14 Tage Testversion, keine Kreditkarte erforderlich", "trial_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": "Pro-Funktionen kostenlos testen!",
"trial_title": "Hol dir Formbricks Pro kostenlos!",
"unlimited_responses": "Unbegrenzte Antworten", "unlimited_responses": "Unbegrenzte Antworten",
"unlimited_workspaces": "Unbegrenzte Projekte", "unlimited_workspaces": "Unbegrenzte Projekte",
"upgrade": "Upgrade", "upgrade": "Upgrade",
"upgrade_now": "Jetzt upgraden",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "verwendet", "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" "your_plan": "Dein Tarif"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Unternehmensfunktionen", "enterprise_features": "Unternehmensfunktionen",
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.", "get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.", "keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
"license_feature_access_control": "Zugriffskontrolle (RBAC)",
"license_feature_audit_logs": "Audit-Protokolle",
"license_feature_contacts": "Kontakte & Segmente",
"license_feature_projects": "Arbeitsbereiche",
"license_feature_quotas": "Kontingente",
"license_feature_remove_branding": "Branding entfernen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam-Schutz",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
"license_feature_whitelabel": "White-Label-E-Mails",
"license_features_table_access": "Zugriff",
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
"license_features_table_disabled": "Deaktiviert",
"license_features_table_enabled": "Aktiviert",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Lizenzierte Funktionen",
"license_features_table_unlimited": "Unbegrenzt",
"license_features_table_value": "Wert",
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.", "license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
"license_status": "Lizenzstatus", "license_status": "Lizenzstatus",
"license_status_active": "Aktiv", "license_status_active": "Aktiv",
"license_status_description": "Status deiner Enterprise-Lizenz.", "license_status_description": "Status deiner Enterprise-Lizenz.",
"license_status_expired": "Abgelaufen", "license_status_expired": "Abgelaufen",
"license_status_instance_mismatch": "An andere Instanz gebunden",
"license_status_invalid": "Ungültige Lizenz", "license_status_invalid": "Ungültige Lizenz",
"license_status_unreachable": "Nicht erreichbar", "license_status_unreachable": "Nicht erreichbar",
"license_unreachable_grace_period": "Der Lizenzserver ist nicht erreichbar. Deine Enterprise-Funktionen bleiben während einer 3-tägigen Kulanzfrist bis zum {gracePeriodEnd} aktiv.", "license_unreachable_grace_period": "Der Lizenzserver ist nicht erreichbar. Deine Enterprise-Funktionen bleiben während einer 3-tägigen Kulanzfrist bis zum {gracePeriodEnd} aktiv.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Fragen? Bitte melde Dich bei", "questions_please_reach_out_to": "Fragen? Bitte melde Dich bei",
"recheck_license": "Lizenz erneut prüfen", "recheck_license": "Lizenz erneut prüfen",
"recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.", "recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.",
"recheck_license_instance_mismatch": "Diese Lizenz ist an eine andere Formbricks-Instanz gebunden. Bitte den Formbricks-Support, die vorherige Bindung zu entfernen.",
"recheck_license_invalid": "Der Lizenzschlüssel ist ungültig. Bitte überprüfe deinen ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "Der Lizenzschlüssel ist ungültig. Bitte überprüfe deinen ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Lizenzprüfung erfolgreich", "recheck_license_success": "Lizenzprüfung erfolgreich",
"recheck_license_unreachable": "Lizenzserver ist nicht erreichbar. Bitte versuche es später erneut.", "recheck_license_unreachable": "Lizenzserver ist nicht erreichbar. Bitte versuche es später erneut.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen", "error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"everyone": "Jeder", "everyone": "Jeder",
"expand_preview": "Vorschau erweitern",
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.", "external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
"fallback_missing": "Fehlender Fallback", "fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.", "spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.", "spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
"spam_protection_threshold_heading": "Antwortschwelle", "spam_protection_threshold_heading": "Antwortschwelle",
"shrink_preview": "Vorschau verkleinern",
"star": "Stern", "star": "Stern",
"starts_with": "Fängt an mit", "starts_with": "Fängt an mit",
"state": "Bundesland", "state": "Bundesland",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt", "styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift", "subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -", "subtract": "Subtrahieren -",
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
"survey_completed_heading": "Umfrage abgeschlossen", "survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen", "survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage", "survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage", "survey_placement": "Platzierung der Umfrage",
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling", "survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage", "survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉", "switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!", "preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?", "preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.", "preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?", "preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...", "preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.", "preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
"preview_survey_welcome_card_headline": "Willkommen!", "preview_survey_welcome_card_headline": "Willkommen!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.", "coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!", "coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Möchten Sie noch etwas hinzufügen?", "follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?", "follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
"generate_button": "Workflow generieren", "generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?", "heading": "Welchen Workflow möchtest du erstellen?",
+19 -87
View File
@@ -294,7 +294,6 @@
"new": "New", "new": "New",
"new_version_available": "Formbricks {version} is here. Upgrade now!", "new_version_available": "Formbricks {version} is here. Upgrade now!",
"next": "Next", "next": "Next",
"no_actions_found": "No actions found",
"no_background_image_found": "No background image found.", "no_background_image_found": "No background image found.",
"no_code": "No code", "no_code": "No code",
"no_files_uploaded": "No files were uploaded", "no_files_uploaded": "No files were uploaded",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Please select at least one survey", "please_select_at_least_one_survey": "Please select at least one survey",
"please_select_at_least_one_trigger": "Please select at least one trigger", "please_select_at_least_one_trigger": "Please select at least one trigger",
"please_upgrade_your_plan": "Please upgrade your plan", "please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview", "preview": "Preview",
"preview_survey": "Preview Survey", "preview_survey": "Preview Survey",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
@@ -401,7 +399,7 @@
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.", "something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"sort_by": "Sort by", "sort_by": "Sort by",
"start_free_trial": "Start free trial", "start_free_trial": "Start Free Trial",
"status": "Status", "status": "Status",
"step_by_step_manual": "Step by step manual", "step_by_step_manual": "Step by step manual",
"storage_not_configured": "File storage not set up, uploads will likely fail", "storage_not_configured": "File storage not set up, uploads will likely fail",
@@ -436,9 +434,6 @@
"title": "Title", "title": "Title",
"top_left": "Top Left", "top_left": "Top Left",
"top_right": "Top Right", "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", "try_again": "Try again",
"type": "Type", "type": "Type",
"unknown_survey": "Unknown survey", "unknown_survey": "Unknown survey",
@@ -446,7 +441,6 @@
"update": "Update", "update": "Update",
"updated": "Updated", "updated": "Updated",
"updated_at": "Updated at", "updated_at": "Updated at",
"upgrade_plan": "Upgrade plan",
"upload": "Upload", "upload": "Upload",
"upload_failed": "Upload failed. Please try again.", "upload_failed": "Upload failed. Please try again.",
"upload_input_description": "Click or drag to upload files.", "upload_input_description": "Click or drag to upload files.",
@@ -974,80 +968,44 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs" "api_keys_description": "Manage API keys to access Formbricks management APIs"
}, },
"billing": { "billing": {
"add_payment_method": "Add payment method", "cancelling": "Cancelling",
"add_payment_method_to_upgrade_tooltip": "Please add a payment method above to upgrade to a paid plan",
"billing_interval_toggle": "Billing interval",
"current_plan_badge": "Current",
"current_plan_cta": "Current plan",
"custom_plan_description": "Your organization is on a custom billing setup. You can still switch to one of the standard plans below.",
"custom_plan_title": "Custom plan",
"failed_to_start_trial": "Failed to start trial. Please try again.", "failed_to_start_trial": "Failed to start trial. Please try again.",
"keep_current_plan": "Keep current plan", "manage_subscription": "Manage subscription",
"manage_billing_details": "Manage card details & invoices",
"monthly": "Monthly",
"most_popular": "Most popular",
"pending_change_removed": "Scheduled plan change removed.",
"pending_plan_badge": "Scheduled",
"pending_plan_change_description": "Your plan will switch to {{plan}} on {{date}}.",
"pending_plan_change_title": "Scheduled plan change",
"pending_plan_cta": "Scheduled",
"per_month": "per month",
"per_year": "per year",
"plan_change_applied": "Plan updated successfully.",
"plan_change_scheduled": "Plan change scheduled successfully.",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Everything in Hobby",
"plan_feature_everything_in_pro": "Everything in Pro",
"plan_hobby": "Hobby", "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": "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": "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", "plan_unknown": "Unknown",
"remove_branding": "Remove Branding", "remove_branding": "Remove Branding",
"retry_setup": "Retry setup", "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_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", "status_trialing": "Trial",
"stay_on_hobby_plan": "I want to stay on the Hobby plan", "stay_on_hobby_plan": "I want to stay on the Hobby plan",
"stripe_setup_incomplete": "Billing setup incomplete", "stripe_setup_incomplete": "Billing setup incomplete",
"stripe_setup_incomplete_description": "Billing setup did not complete successfully. Please retry to activate your subscription.", "stripe_setup_incomplete_description": "Billing setup did not complete successfully. Please retry to activate your subscription.",
"subscription": "Subscription", "subscription": "Subscription",
"subscription_description": "Manage your subscription plan and monitor your usage", "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_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_api_access": "Get full API access",
"trial_feature_attribute_segmentation": "Attribute-based Segmentation", "trial_feature_collaboration": "All team & collaboration features",
"trial_feature_contact_segment_management": "Contact & Segment Management", "trial_feature_email_followups": "Setup email follow-ups",
"trial_feature_email_followups": "Email Follow-ups", "trial_feature_quotas": "Manage quotas",
"trial_feature_hide_branding": "Hide Formbricks Branding", "trial_feature_webhooks": "Setup custom webhooks",
"trial_feature_mobile_sdks": "iOS & Android SDKs", "trial_feature_whitelabel": "Fully white-labeled surveys",
"trial_feature_respondent_identification": "Respondent Identification",
"trial_feature_unlimited_seats": "Unlimited Seats",
"trial_feature_webhooks": "Custom Webhooks",
"trial_no_credit_card": "14 days trial, no credit card required", "trial_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": "Try Pro features for free!",
"trial_title": "Get Formbricks Pro for free!",
"unlimited_responses": "Unlimited Responses", "unlimited_responses": "Unlimited Responses",
"unlimited_workspaces": "Unlimited Workspaces", "unlimited_workspaces": "Unlimited Workspaces",
"upgrade": "Upgrade", "upgrade": "Upgrade",
"upgrade_now": "Upgrade now",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "used", "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" "your_plan": "Your plan"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Enterprise Features", "enterprise_features": "Enterprise Features",
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.", "get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.", "keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
"license_feature_access_control": "Access control (RBAC)",
"license_feature_audit_logs": "Audit logs",
"license_feature_contacts": "Contacts & Segments",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remove branding",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam protection",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Two-factor authentication",
"license_feature_whitelabel": "White-label emails",
"license_features_table_access": "Access",
"license_features_table_description": "Enterprise features and limits currently available to this instance.",
"license_features_table_disabled": "Disabled",
"license_features_table_enabled": "Enabled",
"license_features_table_feature": "Feature",
"license_features_table_title": "Licensed Features",
"license_features_table_unlimited": "Unlimited",
"license_features_table_value": "Value",
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.", "license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
"license_status": "License Status", "license_status": "License Status",
"license_status_active": "Active", "license_status_active": "Active",
"license_status_description": "Status of your enterprise license.", "license_status_description": "Status of your enterprise license.",
"license_status_expired": "Expired", "license_status_expired": "Expired",
"license_status_instance_mismatch": "Bound to Another Instance",
"license_status_invalid": "Invalid License", "license_status_invalid": "Invalid License",
"license_status_unreachable": "Unreachable", "license_status_unreachable": "Unreachable",
"license_unreachable_grace_period": "License server cannot be reached. Your enterprise features remain active during a 3-day grace period ending {gracePeriodEnd}.", "license_unreachable_grace_period": "License server cannot be reached. Your enterprise features remain active during a 3-day grace period ending {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Questions? Please reach out to", "questions_please_reach_out_to": "Questions? Please reach out to",
"recheck_license": "Recheck license", "recheck_license": "Recheck license",
"recheck_license_failed": "License check failed. The license server may be unreachable.", "recheck_license_failed": "License check failed. The license server may be unreachable.",
"recheck_license_instance_mismatch": "This license is bound to a different Formbricks instance. Ask Formbricks support to disconnect the previous binding.",
"recheck_license_invalid": "The license key is invalid. Please verify your ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "The license key is invalid. Please verify your ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "License check successful", "recheck_license_success": "License check successful",
"recheck_license_unreachable": "License server is unreachable. Please try again later.", "recheck_license_unreachable": "License server is unreachable. Please try again later.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Error saving changes", "error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone", "everyone": "Everyone",
"expand_preview": "Expand Preview",
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.", "external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing", "fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.", "spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.", "spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
"spam_protection_threshold_heading": "Response threshold", "spam_protection_threshold_heading": "Response threshold",
"shrink_preview": "Shrink Preview",
"star": "Star", "star": "Star",
"starts_with": "Starts with", "starts_with": "Starts with",
"state": "State", "state": "State",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Styling set to theme styles", "styling_set_to_theme_styles": "Styling set to theme styles",
"subheading": "Subheading", "subheading": "Subheading",
"subtract": "Subtract -", "subtract": "Subtract -",
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
"survey_completed_heading": "Survey Completed", "survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed", "survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings", "survey_display_settings": "Survey Display Settings",
"survey_placement": "Survey Placement", "survey_placement": "Survey Placement",
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling", "survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger", "survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉", "switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "No, thank you!", "preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?", "preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_question_2_subheader": "This is an example description.", "preview_survey_question_2_subheader": "This is an example description.",
"preview_survey_question_open_text_headline": "Anything else you would like to share?", "preview_survey_question_open_text_headline": "Anything else you'd like to share?",
"preview_survey_question_open_text_placeholder": "Type your answer here…", "preview_survey_question_open_text_placeholder": "Type your answer here…",
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.", "preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
"preview_survey_welcome_card_headline": "Welcome!", "preview_survey_welcome_card_headline": "Welcome!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.", "coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!", "coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you would like to add?", "follow_up_label": "Is there anything else you'd like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?", "follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
"generate_button": "Generate workflow", "generate_button": "Generate workflow",
"heading": "What workflow do you want to create?", "heading": "What workflow do you want to create?",
+16 -84
View File
@@ -294,7 +294,6 @@
"new": "Nuevo", "new": "Nuevo",
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!", "new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
"next": "Siguiente", "next": "Siguiente",
"no_actions_found": "No se encontraron acciones",
"no_background_image_found": "No se encontró imagen de fondo.", "no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código", "no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos", "no_files_uploaded": "No se subieron archivos",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta", "please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador", "please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
"please_upgrade_your_plan": "Por favor, actualiza tu plan", "please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa", "preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta", "preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad", "privacy": "Política de privacidad",
@@ -436,9 +434,6 @@
"title": "Título", "title": "Título",
"top_left": "Superior izquierda", "top_left": "Superior izquierda",
"top_right": "Superior derecha", "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", "try_again": "Intentar de nuevo",
"type": "Tipo", "type": "Tipo",
"unknown_survey": "Encuesta desconocida", "unknown_survey": "Encuesta desconocida",
@@ -446,7 +441,6 @@
"update": "Actualizar", "update": "Actualizar",
"updated": "Actualizado", "updated": "Actualizado",
"updated_at": "Actualizado el", "updated_at": "Actualizado el",
"upgrade_plan": "Mejorar plan",
"upload": "Subir", "upload": "Subir",
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.", "upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
"upload_input_description": "Haz clic o arrastra para subir archivos.", "upload_input_description": "Haz clic o arrastra para subir archivos.",
@@ -974,80 +968,44 @@
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks" "api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
}, },
"billing": { "billing": {
"add_payment_method": "Añadir método de pago", "cancelling": "Cancelando",
"add_payment_method_to_upgrade_tooltip": "Por favor, añade un método de pago arriba para mejorar a un plan de pago",
"billing_interval_toggle": "Intervalo de facturación",
"current_plan_badge": "Actual",
"current_plan_cta": "Plan actual",
"custom_plan_description": "Tu organización tiene una configuración de facturación personalizada. Aún puedes cambiar a uno de los planes estándar a continuación.",
"custom_plan_title": "Plan personalizado",
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.", "failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
"keep_current_plan": "Mantener plan actual", "manage_subscription": "Gestionar suscripción",
"manage_billing_details": "Gestionar datos de tarjeta y facturas",
"monthly": "Mensual",
"most_popular": "Más popular",
"pending_change_removed": "Cambio de plan programado eliminado.",
"pending_plan_badge": "Programado",
"pending_plan_change_description": "Tu plan cambiará a {{plan}} el {{date}}.",
"pending_plan_change_title": "Cambio de plan programado",
"pending_plan_cta": "Programado",
"per_month": "por mes",
"per_year": "por año",
"plan_change_applied": "Plan actualizado correctamente.",
"plan_change_scheduled": "Cambio de plan programado correctamente.",
"plan_custom": "Custom", "plan_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": "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": "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": "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", "plan_unknown": "Desconocido",
"remove_branding": "Eliminar marca", "remove_branding": "Eliminar marca",
"retry_setup": "Reintentar configuración", "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_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", "status_trialing": "Prueba",
"stay_on_hobby_plan": "Quiero quedarme en el plan Hobby", "stay_on_hobby_plan": "Quiero quedarme en el plan Hobby",
"stripe_setup_incomplete": "Configuración de facturación incompleta", "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.", "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": "Suscripción",
"subscription_description": "Gestiona tu plan de suscripción y monitorea tu uso", "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_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_api_access": "Acceso completo a la API",
"trial_feature_attribute_segmentation": "Segmentación basada en atributos", "trial_feature_collaboration": "Todas las funciones de equipo y colaboración",
"trial_feature_contact_segment_management": "Gestión de contactos y segmentos", "trial_feature_email_followups": "Configurar seguimientos por correo electrónico",
"trial_feature_email_followups": "Seguimientos por correo electrónico", "trial_feature_quotas": "Gestionar cuotas",
"trial_feature_hide_branding": "Ocultar la marca Formbricks", "trial_feature_webhooks": "Configurar webhooks personalizados",
"trial_feature_mobile_sdks": "SDKs para iOS y Android", "trial_feature_whitelabel": "Encuestas totalmente personalizadas",
"trial_feature_respondent_identification": "Identificación de encuestados",
"trial_feature_unlimited_seats": "Asientos ilimitados",
"trial_feature_webhooks": "Webhooks personalizados",
"trial_no_credit_card": "Prueba de 14 días, sin tarjeta de crédito", "trial_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": "¡Prueba las funciones Pro gratis!",
"trial_title": "¡Consigue Formbricks Pro gratis!",
"unlimited_responses": "Respuestas ilimitadas", "unlimited_responses": "Respuestas ilimitadas",
"unlimited_workspaces": "Proyectos ilimitados", "unlimited_workspaces": "Proyectos ilimitados",
"upgrade": "Actualizar", "upgrade": "Actualizar",
"upgrade_now": "Actualizar ahora",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "usados", "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" "your_plan": "Tu plan"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Características empresariales", "enterprise_features": "Características empresariales",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.", "get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.", "keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
"license_feature_access_control": "Control de acceso (RBAC)",
"license_feature_audit_logs": "Registros de auditoría",
"license_feature_contacts": "Contactos y segmentos",
"license_feature_projects": "Espacios de trabajo",
"license_feature_quotas": "Cuotas",
"license_feature_remove_branding": "Eliminar marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protección contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticación de dos factores",
"license_feature_whitelabel": "Correos sin marca",
"license_features_table_access": "Acceso",
"license_features_table_description": "Funciones y límites empresariales disponibles actualmente para esta instancia.",
"license_features_table_disabled": "Desactivado",
"license_features_table_enabled": "Activado",
"license_features_table_feature": "Función",
"license_features_table_title": "Funciones con licencia",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.", "license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
"license_status": "Estado de la licencia", "license_status": "Estado de la licencia",
"license_status_active": "Activa", "license_status_active": "Activa",
"license_status_description": "Estado de tu licencia enterprise.", "license_status_description": "Estado de tu licencia enterprise.",
"license_status_expired": "Caducada", "license_status_expired": "Caducada",
"license_status_instance_mismatch": "Vinculada a Otra Instancia",
"license_status_invalid": "Licencia no válida", "license_status_invalid": "Licencia no válida",
"license_status_unreachable": "Inaccesible", "license_status_unreachable": "Inaccesible",
"license_unreachable_grace_period": "No se puede acceder al servidor de licencias. Tus funciones empresariales permanecen activas durante un período de gracia de 3 días que finaliza el {gracePeriodEnd}.", "license_unreachable_grace_period": "No se puede acceder al servidor de licencias. Tus funciones empresariales permanecen activas durante un período de gracia de 3 días que finaliza el {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "¿Preguntas? Por favor, contacta con", "questions_please_reach_out_to": "¿Preguntas? Por favor, contacta con",
"recheck_license": "Volver a comprobar licencia", "recheck_license": "Volver a comprobar licencia",
"recheck_license_failed": "Error al comprobar la licencia. Es posible que el servidor de licencias no esté disponible.", "recheck_license_failed": "Error al comprobar la licencia. Es posible que el servidor de licencias no esté disponible.",
"recheck_license_instance_mismatch": "Esta licencia está vinculada a una instancia diferente de Formbricks. Solicita al soporte de Formbricks que desconecte la vinculación anterior.",
"recheck_license_invalid": "La clave de licencia no es válida. Por favor, verifica tu ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "La clave de licencia no es válida. Por favor, verifica tu ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Comprobación de licencia correcta", "recheck_license_success": "Comprobación de licencia correcta",
"recheck_license_unreachable": "El servidor de licencias no está disponible. Inténtalo de nuevo más tarde.", "recheck_license_unreachable": "El servidor de licencias no está disponible. Inténtalo de nuevo más tarde.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Error al guardar los cambios", "error_saving_changes": "Error al guardar los cambios",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
"everyone": "Todos", "everyone": "Todos",
"expand_preview": "Expandir vista previa",
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.", "external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
"fallback_missing": "Falta respaldo", "fallback_missing": "Falta respaldo",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.", "spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.", "spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
"spam_protection_threshold_heading": "Umbral de respuesta", "spam_protection_threshold_heading": "Umbral de respuesta",
"shrink_preview": "Contraer vista previa",
"star": "Estrella", "star": "Estrella",
"starts_with": "Comienza con", "starts_with": "Comienza con",
"state": "Estado", "state": "Estado",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema", "styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
"subheading": "Subtítulo", "subheading": "Subtítulo",
"subtract": "Restar -", "subtract": "Restar -",
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
"survey_completed_heading": "Encuesta completada", "survey_completed_heading": "Encuesta completada",
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada", "survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta", "survey_display_settings": "Ajustes de visualización de la encuesta",
"survey_placement": "Ubicación de la encuesta", "survey_placement": "Ubicación de la encuesta",
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario", "survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta", "survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉", "switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
+19 -87
View File
@@ -294,7 +294,6 @@
"new": "Nouveau", "new": "Nouveau",
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !", "new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
"next": "Suivant", "next": "Suivant",
"no_actions_found": "Aucune action trouvée",
"no_background_image_found": "Aucune image de fond trouvée.", "no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Sans code", "no_code": "Sans code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.", "no_files_uploaded": "Aucun fichier n'a été téléchargé.",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.", "please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.", "please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan", "please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu", "preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête", "preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité", "privacy": "Politique de confidentialité",
@@ -401,7 +399,7 @@
"something_went_wrong": "Quelque chose s'est mal passé.", "something_went_wrong": "Quelque chose s'est mal passé.",
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.", "something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
"sort_by": "Trier par", "sort_by": "Trier par",
"start_free_trial": "Commencer l'essai gratuit", "start_free_trial": "Essayer gratuitement",
"status": "Statut", "status": "Statut",
"step_by_step_manual": "Manuel étape par étape", "step_by_step_manual": "Manuel étape par étape",
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer", "storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
@@ -436,9 +434,6 @@
"title": "Titre", "title": "Titre",
"top_left": "En haut à gauche", "top_left": "En haut à gauche",
"top_right": "En haut à droite", "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", "try_again": "Réessayer",
"type": "Type", "type": "Type",
"unknown_survey": "Enquête inconnue", "unknown_survey": "Enquête inconnue",
@@ -446,7 +441,6 @@
"update": "Mise à jour", "update": "Mise à jour",
"updated": "Mise à jour", "updated": "Mise à jour",
"updated_at": "Mis à jour à", "updated_at": "Mis à jour à",
"upgrade_plan": "Améliorer le forfait",
"upload": "Télécharger", "upload": "Télécharger",
"upload_failed": "Échec du téléchargement. Veuillez réessayer.", "upload_failed": "Échec du téléchargement. Veuillez réessayer.",
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.", "upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
@@ -974,80 +968,44 @@
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks." "api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
}, },
"billing": { "billing": {
"add_payment_method": "Ajouter un moyen de paiement", "cancelling": "Annulation en cours",
"add_payment_method_to_upgrade_tooltip": "Veuillez ajouter un moyen de paiement ci-dessus pour passer à un forfait payant",
"billing_interval_toggle": "Intervalle de facturation",
"current_plan_badge": "Actuel",
"current_plan_cta": "Formule actuelle",
"custom_plan_description": "Votre organisation dispose d'une configuration de facturation personnalisée. Tu peux toujours basculer vers l'une des formules standard ci-dessous.",
"custom_plan_title": "Formule personnalisée",
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.", "failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
"keep_current_plan": "Conserver la formule actuelle", "manage_subscription": "Gérer l'abonnement",
"manage_billing_details": "Gérer les détails de la carte et les factures",
"monthly": "Mensuel",
"most_popular": "Le plus populaire",
"pending_change_removed": "Changement de formule programmé supprimé.",
"pending_plan_badge": "Programmé",
"pending_plan_change_description": "Ta formule passera à {{plan}} le {{date}}.",
"pending_plan_change_title": "Changement de formule programmé",
"pending_plan_cta": "Programmé",
"per_month": "par mois",
"per_year": "par an",
"plan_change_applied": "Formule mise à jour avec succès.",
"plan_change_scheduled": "Changement de formule programmé avec succès.",
"plan_custom": "Custom", "plan_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": "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": "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": "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", "plan_unknown": "Inconnu",
"remove_branding": "Suppression du logo", "remove_branding": "Suppression du logo",
"retry_setup": "Réessayer la configuration", "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_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", "status_trialing": "Essai",
"stay_on_hobby_plan": "Je veux rester sur le plan Hobby", "stay_on_hobby_plan": "Je veux rester sur le plan Hobby",
"stripe_setup_incomplete": "Configuration de la facturation incomplète", "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.", "stripe_setup_incomplete_description": "La configuration de la facturation na pas abouti. Merci de réessayer pour activer ton abonnement.",
"subscription": "Abonnement", "subscription": "Abonnement",
"subscription_description": "Gère ton abonnement et surveille ta consommation", "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_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_api_access": "Accès complet à l'API",
"trial_feature_attribute_segmentation": "Segmentation basée sur les attributs", "trial_feature_collaboration": "Toutes les fonctionnalités d'équipe et de collaboration",
"trial_feature_contact_segment_management": "Gestion des contacts et segments", "trial_feature_email_followups": "Configure des relances par e-mail",
"trial_feature_email_followups": "Relances par e-mail", "trial_feature_quotas": "Gère les quotas",
"trial_feature_hide_branding": "Masquer l'image de marque Formbricks", "trial_feature_webhooks": "Configure des webhooks personnalisés",
"trial_feature_mobile_sdks": "SDKs iOS et Android", "trial_feature_whitelabel": "Enquêtes entièrement en marque blanche",
"trial_feature_respondent_identification": "Identification des répondants",
"trial_feature_unlimited_seats": "Places illimitées",
"trial_feature_webhooks": "Webhooks personnalisés",
"trial_no_credit_card": "Essai de 14 jours, aucune carte bancaire requise", "trial_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": "Essaie les fonctionnalités Pro gratuitement !",
"trial_title": "Obtenez Formbricks Pro gratuitement !",
"unlimited_responses": "Réponses illimitées", "unlimited_responses": "Réponses illimitées",
"unlimited_workspaces": "Projets illimités", "unlimited_workspaces": "Projets illimités",
"upgrade": "Mise à niveau", "upgrade": "Mise à niveau",
"upgrade_now": "Passer à la formule supérieure maintenant",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "utilisé(s)", "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" "your_plan": "Ton offre"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Fonctionnalités d'entreprise", "enterprise_features": "Fonctionnalités d'entreprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.", "get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.", "keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
"license_feature_access_control": "Contrôle d'accès (RBAC)",
"license_feature_audit_logs": "Journaux d'audit",
"license_feature_contacts": "Contacts et segments",
"license_feature_projects": "Espaces de travail",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Retirer l'image de marque",
"license_feature_saml": "SSO SAML",
"license_feature_spam_protection": "Protection anti-spam",
"license_feature_sso": "SSO OIDC",
"license_feature_two_factor_auth": "Authentification à deux facteurs",
"license_feature_whitelabel": "E-mails en marque blanche",
"license_features_table_access": "Accès",
"license_features_table_description": "Fonctionnalités Enterprise et limites actuellement disponibles pour cette instance.",
"license_features_table_disabled": "Désactivé",
"license_features_table_enabled": "Activé",
"license_features_table_feature": "Fonctionnalité",
"license_features_table_title": "Fonctionnalités sous licence",
"license_features_table_unlimited": "Illimité",
"license_features_table_value": "Valeur",
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.", "license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
"license_status": "Statut de la licence", "license_status": "Statut de la licence",
"license_status_active": "Active", "license_status_active": "Active",
"license_status_description": "Statut de votre licence entreprise.", "license_status_description": "Statut de votre licence entreprise.",
"license_status_expired": "Expirée", "license_status_expired": "Expirée",
"license_status_instance_mismatch": "Liée à une autre instance",
"license_status_invalid": "Licence invalide", "license_status_invalid": "Licence invalide",
"license_status_unreachable": "Inaccessible", "license_status_unreachable": "Inaccessible",
"license_unreachable_grace_period": "Le serveur de licence est injoignable. Vos fonctionnalités entreprise restent actives pendant une période de grâce de 3 jours se terminant le {gracePeriodEnd}.", "license_unreachable_grace_period": "Le serveur de licence est injoignable. Vos fonctionnalités entreprise restent actives pendant une période de grâce de 3 jours se terminant le {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Des questions ? Veuillez contacter", "questions_please_reach_out_to": "Des questions ? Veuillez contacter",
"recheck_license": "Revérifier la licence", "recheck_license": "Revérifier la licence",
"recheck_license_failed": "La vérification de la licence a échoué. Le serveur de licences est peut-être inaccessible.", "recheck_license_failed": "La vérification de la licence a échoué. Le serveur de licences est peut-être inaccessible.",
"recheck_license_instance_mismatch": "Cette licence est liée à une autre instance Formbricks. Demande au support Formbricks de déconnecter la liaison précédente.",
"recheck_license_invalid": "La clé de licence est invalide. Veuillez vérifier votre ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "La clé de licence est invalide. Veuillez vérifier votre ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Vérification de la licence réussie", "recheck_license_success": "Vérification de la licence réussie",
"recheck_license_unreachable": "Le serveur de licences est inaccessible. Veuillez réessayer plus tard.", "recheck_license_unreachable": "Le serveur de licences est inaccessible. Veuillez réessayer plus tard.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications", "error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).", "even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
"everyone": "Tout le monde", "everyone": "Tout le monde",
"expand_preview": "Agrandir l'aperçu",
"external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher lhameçonnage.", "external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher lhameçonnage.",
"fallback_missing": "Fallback manquant", "fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.", "spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.", "spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
"spam_protection_threshold_heading": "Seuil de réponse", "spam_protection_threshold_heading": "Seuil de réponse",
"shrink_preview": "Réduire l'aperçu",
"star": "Étoile", "star": "Étoile",
"starts_with": "Commence par", "starts_with": "Commence par",
"state": "État", "state": "État",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Style défini sur les styles du thème", "styling_set_to_theme_styles": "Style défini sur les styles du thème",
"subheading": "Sous-titre", "subheading": "Sous-titre",
"subtract": "Soustraire -", "subtract": "Soustraire -",
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
"survey_completed_heading": "Enquête terminée", "survey_completed_heading": "Enquête terminée",
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée", "survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête", "survey_display_settings": "Paramètres d'affichage de l'enquête",
"survey_placement": "Placement de l'enquête", "survey_placement": "Placement de l'enquête",
"survey_preview": "Aperçu du sondage 👀",
"survey_styling": "Style de formulaire", "survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête", "survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉", "switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Non, merci !", "preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?", "preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_question_2_subheader": "Ceci est un exemple de description.", "preview_survey_question_2_subheader": "Ceci est un exemple de description.",
"preview_survey_question_open_text_headline": "Souhaitez-vous partager autre chose ?", "preview_survey_question_open_text_headline": "Autre chose que vous aimeriez partager?",
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...", "preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.", "preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
"preview_survey_welcome_card_headline": "Bienvenue !", "preview_survey_welcome_card_headline": "Bienvenue !",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.", "coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
"coming_soon_title": "Nous y sommes presque!", "coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Souhaitez-vous ajouter quelque chose ?", "follow_up_label": "Y a-t-il autre chose que vous aimeriez ajouter?",
"follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?", "follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?",
"generate_button": "Générer le workflow", "generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?", "heading": "Quel workflow souhaitez-vous créer?",
+59 -127
View File
@@ -175,7 +175,7 @@
"copy_code": "Kód másolása", "copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása", "copy_link": "Hivatkozás másolása",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}", "count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}", "count_contacts": "{count, plural, one {{count} kontakt}} other {{count} kontakt}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}", "count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}", "count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}", "count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
@@ -294,7 +294,6 @@
"new": "Új", "new": "Új",
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!", "new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
"next": "Következő", "next": "Következő",
"no_actions_found": "Nem találhatók műveletek",
"no_background_image_found": "Nem található háttérkép.", "no_background_image_found": "Nem található háttérkép.",
"no_code": "Kód nélkül", "no_code": "Kód nélkül",
"no_files_uploaded": "Nem lettek fájlok feltöltve", "no_files_uploaded": "Nem lettek fájlok feltöltve",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet", "please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet",
"please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót", "please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót",
"please_upgrade_your_plan": "Váltson magasabb csomagra", "please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet", "preview": "Előnézet",
"preview_survey": "Kérdőív előnézete", "preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek", "privacy": "Adatvédelmi irányelvek",
@@ -362,7 +360,7 @@
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése", "reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
"replace": "Csere", "replace": "Csere",
"report_survey": "Kérdőív jelentése", "report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbaidőszaki licenc kérése", "request_trial_license": "Próbalicenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre", "reset_to_default": "Visszaállítás az alapértelmezettre",
"response": "Válasz", "response": "Válasz",
"response_id": "Válaszazonosító", "response_id": "Válaszazonosító",
@@ -401,7 +399,7 @@
"something_went_wrong": "Valami probléma történt", "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.", "something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
"sort_by": "Rendezési sorrend", "sort_by": "Rendezési sorrend",
"start_free_trial": "Ingyenes próbaidőszak indítása", "start_free_trial": "Ingyenes próba indítása",
"status": "Állapot", "status": "Állapot",
"step_by_step_manual": "Lépésenkénti kézikönyv", "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", "storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
@@ -436,9 +434,6 @@
"title": "Cím", "title": "Cím",
"top_left": "Balra fent", "top_left": "Balra fent",
"top_right": "Jobbra fent", "top_right": "Jobbra fent",
"trial_days_remaining": "{count} nap van hátra a próbaidőszakából",
"trial_expired": "A próbaidőszaka lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
"try_again": "Próbálja újra", "try_again": "Próbálja újra",
"type": "Típus", "type": "Típus",
"unknown_survey": "Ismeretlen kérdőív", "unknown_survey": "Ismeretlen kérdőív",
@@ -446,7 +441,6 @@
"update": "Frissítés", "update": "Frissítés",
"updated": "Frissítve", "updated": "Frissítve",
"updated_at": "Frissítve", "updated_at": "Frissítve",
"upgrade_plan": "Magasabb csomagra váltás",
"upload": "Feltöltés", "upload": "Feltöltés",
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.", "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.", "upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
@@ -539,7 +533,7 @@
"survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése", "survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése",
"text_variable": "Szöveg változó", "text_variable": "Szöveg változó",
"verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:", "verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:",
"verification_email_heading": "Már majdnem kész vagyunk!", "verification_email_heading": "Már majdnem megvagyunk!",
"verification_email_hey": "Helló 👋", "verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:", "verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.", "verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
@@ -607,15 +601,15 @@
"test_match": "Illeszkedés tesztelése", "test_match": "Illeszkedés tesztelése",
"test_your_url": "URL tesztelése", "test_your_url": "URL tesztelése",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.", "this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.",
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet azután lesz aktiválva, hogy a felhasználó az oldalon marad a megadott időtartamig.", "this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet akkor fog aktiválódni, miután a felhasználó a megadott ideig az oldalon tartózkodik.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.", "this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.", "this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.", "this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.", "this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
"time_in_seconds": "Idő másodpercben", "time_in_seconds": "Idő másodpercben",
"time_in_seconds_placeholder": "például 10", "time_in_seconds_placeholder": "pl. 10",
"time_in_seconds_with_unit": "{seconds} mp", "time_in_seconds_with_unit": "{seconds} mp",
"time_on_page": "Idő az oldalon", "time_on_page": "Oldalon töltött idő",
"track_new_user_action": "Új felhasználói művelet követése", "track_new_user_action": "Új felhasználói művelet követése",
"track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.", "track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.",
"url": "URL", "url": "URL",
@@ -974,80 +968,44 @@
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez" "api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
}, },
"billing": { "billing": {
"add_payment_method": "Fizetési mód hozzáadása", "cancelling": "Lemondás folyamatban",
"add_payment_method_to_upgrade_tooltip": "Adjon hozzá fizetési módot fent, hogy fizetős csomagra váltson", "failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
"billing_interval_toggle": "Számlázási időköz", "manage_subscription": "Előfizetés kezelése",
"current_plan_badge": "Jelenlegi", "plan_custom": "Custom",
"current_plan_cta": "Jelenlegi csomag", "plan_hobby": "Hobby",
"custom_plan_description": "A szervezete egyéni számlázási beállítással rendelkezik. Ugyanakkor áttérhet az alábbi szabványos csomagok egyikére.",
"custom_plan_title": "Egyéni csomag",
"failed_to_start_trial": "Nem sikerült a próbaidőszak indítása. Próbálja meg újra.",
"keep_current_plan": "Jelenlegi csomag megtartása",
"manage_billing_details": "Kártyarészletek és számlák kezelése",
"monthly": "Havi",
"most_popular": "Legnépszerűbb",
"pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
"pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {{plan}} csomagra fog váltani ekkor: {{date}}.",
"pending_plan_change_title": "Ütemezett csomagváltoztatás",
"pending_plan_cta": "Ütemezett",
"per_month": "havonta",
"per_year": "évente",
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
"plan_custom": "Egyéni",
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
"plan_hobby": "Hobbi",
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
"plan_hobby_feature_responses": "250 válasz/hónap",
"plan_hobby_feature_workspaces": "1 munkaterület",
"plan_pro": "Pro", "plan_pro": "Pro",
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.", "plan_scale": "Scale",
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Méretezés",
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek több kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Csomag kiválasztása",
"plan_unknown": "Ismeretlen", "plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása", "remove_branding": "Márkajel eltávolítása",
"retry_setup": "Beállítás újrapróbálása", "retry_setup": "Újrapróbálkozás a beállítással",
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.", "scale_banner_description": "Nagyobb limitek, csapatmunka és fejlett biztonsági funkciók a Scale csomaggal.",
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.", "scale_banner_title": "Készen áll a növekedésre?",
"status_trialing": "Próbaidőszak", "scale_feature_api": "Teljes API hozzáférés",
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni", "scale_feature_quota": "Keretkezelés",
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen", "scale_feature_spam": "Spamvédelem",
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.", "scale_feature_teams": "Csapatok és hozzáférési szerepkörök",
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
"select_plan_header_title": "Küldjön professzionális, márkajelzés nélküli felméréseket még ma!",
"status_trialing": "Próbaverzió",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
"stripe_setup_incomplete": "Számlázás beállítása nem teljes",
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.",
"subscription": "Előfizetés", "subscription": "Előfizetés",
"subscription_description": "Az előfizetési csomag kezelése és a használat felügyelete", "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", "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.",
"switch_plan_now": "Csomag váltása most", "trial_feature_api_access": "Teljes API-hozzáférés megszerzése",
"this_includes": "Ezeket tartalmazza", "trial_feature_collaboration": "Minden csapat- és együttműködési funkció",
"trial_alert_description": "Fizetési mód hozzáadása az összes funkcióhoz való hozzáférés megtartásához.", "trial_feature_email_followups": "E-mail követések beállítása",
"trial_already_used": "Ehhez az e-mail-címhez már használatban van egy ingyenes próbaidőszak. Váltson inkább fizetős csomagra.", "trial_feature_quotas": "Kvóták kezelése",
"trial_feature_api_access": "API-hozzáférés", "trial_feature_webhooks": "Egyéni webhookok beállítása",
"trial_feature_attribute_segmentation": "Attribútumalapú szakaszolás", "trial_feature_whitelabel": "Teljesen fehércímkés felmérések",
"trial_feature_contact_segment_management": "Partner- és szakaszkezelés", "trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül",
"trial_feature_email_followups": "E-mailes utókövetések", "trial_title": "Próbálja ki a Pro funkciókat ingyen!",
"trial_feature_hide_branding": "Formbricks márkajel elrejtése",
"trial_feature_mobile_sdks": "iOS és Android SDK-k",
"trial_feature_respondent_identification": "Válaszadó-azonosítás",
"trial_feature_unlimited_seats": "Korlátlan számú hely",
"trial_feature_webhooks": "Egyéni webhorgok",
"trial_no_credit_card": "14 napos próbaidőszak, nincs szükség hitelkártyára",
"trial_payment_method_added_description": "Mindent beállított! A Pro csomagja a próbaidőszak vége után automatikusan folytatódik.",
"trial_title": "Szerezze meg a Formbricks Pro csomagot ingyen!",
"unlimited_responses": "Korlátlan válaszok", "unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület", "unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés", "upgrade": "Frissítés",
"upgrade_now": "Frissítés most", "usage_cycle": "Usage cycle",
"usage_cycle": "Használati ciklus", "used": "felhasználva",
"used": "használva",
"yearly": "Évente",
"yearly_checkout_unavailable": "Az éves fizetési lehetőség még nem érhető el. Először adjon hozzá fizetési módot egy havi csomaghoz, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
"your_plan": "Az Ön csomagja" "your_plan": "Az Ön csomagja"
}, },
"domain": { "domain": {
@@ -1073,48 +1031,26 @@
"enterprise_features": "Vállalati funkciók", "enterprise_features": "Vállalati funkciók",
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.", "get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.", "keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
"license_feature_access_control": "Hozzáférés-vezérlés (RBAC)",
"license_feature_audit_logs": "Auditálási naplók",
"license_feature_contacts": "Partnerek és szakaszok",
"license_feature_projects": "Munkaterületek",
"license_feature_quotas": "Kvóták",
"license_feature_remove_branding": "Márkajel eltávolítása",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Szemét elleni védekezés",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
"license_feature_whitelabel": "Fehér címkés e-mailek",
"license_features_table_access": "Hozzáférés",
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_disabled": "Letiltva",
"license_features_table_enabled": "Engedélyezve",
"license_features_table_feature": "Funkció",
"license_features_table_title": "Licencelt funkciók",
"license_features_table_unlimited": "Korlátlan",
"license_features_table_value": "Érték",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks-példányhoz van kötve. Ha ezt a telepítést újraépítették vagy áthelyezték, akkor kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi példányhoz való kötést.",
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.", "license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
"license_status": "Licencállapot", "license_status": "Licencállapot",
"license_status_active": "Aktív", "license_status_active": "Aktív",
"license_status_description": "A vállalati licenc állapota.", "license_status_description": "A vállalati licenc állapota.",
"license_status_expired": "Lejárt", "license_status_expired": "Lejárt",
"license_status_instance_mismatch": "Másik példányhoz kötve",
"license_status_invalid": "Érvénytelen licenc", "license_status_invalid": "Érvénytelen licenc",
"license_status_unreachable": "Nem érhető el", "license_status_unreachable": "Nem érhető el",
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.", "license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos próbaidőszaki licencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos ingyenes próbalicencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
"no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)", "no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)",
"on_request": "Kérésre", "on_request": "Kérésre",
"organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)", "organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)",
"questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:", "questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:",
"recheck_license": "Licenc újraellenőrzése", "recheck_license": "Licenc újraellenőrzése",
"recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.", "recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.",
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks-példányhoz van kötve. Kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi kötést.",
"recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.", "recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.",
"recheck_license_success": "A licencellenőrzés sikeres", "recheck_license_success": "A licencellenőrzés sikeres",
"recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.", "recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.",
"rechecking": "Újraellenőrzés…", "rechecking": "Újraellenőrzés…",
"request_30_day_trial_license": "30 napos próbaidőszaki licenc kérése", "request_30_day_trial_license": "30 napos ingyenes licenc kérése",
"saml_sso": "SAML SSO", "saml_sso": "SAML SSO",
"service_level_agreement": "Szolgáltatási megállapodás", "service_level_agreement": "Szolgáltatási megállapodás",
"soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés", "soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés",
@@ -1451,22 +1387,21 @@
"error_saving_changes": "Hiba a változtatások mentésekor", "error_saving_changes": "Hiba a változtatások mentésekor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).", "even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).",
"everyone": "Mindenki", "everyone": "Mindenki",
"expand_preview": "Előnézet kinyitása", "external_urls_paywall_tooltip": "Kérjük, váltson fizetős csomagra, hogy testre szabhassa a külső URL-eket. Ez segít megelőzni az adathalászatot.",
"external_urls_paywall_tooltip": "Váltson a magasabb fizetős csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
"fallback_missing": "Tartalék hiányzik", "fallback_missing": "Tartalék hiányzik",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában",
"field_name_eg_score_price": "Mező neve, például pontszám, ár", "field_name_eg_score_price": "Mező neve, például pontszám, ár",
"first_name": "Keresztnév", "first_name": "Keresztnév",
"five_points_recommended": "5 pont (ajánlott)", "five_points_recommended": "5 pont (ajánlott)",
"follow_ups": "Utókövetések", "follow_ups": "Követések",
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt az utókövetést?", "follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt a követést?",
"follow_ups_delete_modal_title": "Törli az utókövetést?", "follow_ups_delete_modal_title": "Törli a követést?",
"follow_ups_empty_description": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak.", "follow_ups_empty_description": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak.",
"follow_ups_empty_heading": "Automatikus utókövetések küldése", "follow_ups_empty_heading": "Automatikus követések küldése",
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van az utókövetésekben. A törlése eltávolítja az összes utókövetésből. Biztosan törölni szeretné?", "follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van a követésekben. A törlése eltávolítja az összes követésből. Biztosan törölni szeretné?",
"follow_ups_ending_card_delete_modal_title": "Törli a befejező kártyát?", "follow_ups_ending_card_delete_modal_title": "Törli a befejező kártyát?",
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy utókövetésben. Először távolítsa el az utókövetésből.", "follow_ups_hidden_field_error": "A rejtett mező használatban van egy követésben. Először távolítsa el a követésből.",
"follow_ups_include_hidden_fields": "Rejtett mezők értékeinek felvétele", "follow_ups_include_hidden_fields": "Rejtett mezők értékeinek felvétele",
"follow_ups_include_variables": "Változó értékeinek felvétele", "follow_ups_include_variables": "Változó értékeinek felvétele",
"follow_ups_item_ending_tag": "Befejezések", "follow_ups_item_ending_tag": "Befejezések",
@@ -1490,21 +1425,21 @@
"follow_ups_modal_action_to_description": "Az az e-mail-cím, ahova az e-mail elküldésre kerül", "follow_ups_modal_action_to_description": "Az az e-mail-cím, ahova az e-mail elküldésre kerül",
"follow_ups_modal_action_to_label": "Címzett", "follow_ups_modal_action_to_label": "Címzett",
"follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt", "follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt",
"follow_ups_modal_create_heading": "Új utókövetés létrehozása", "follow_ups_modal_create_heading": "Új követés létrehozása",
"follow_ups_modal_created_successfull_toast": "Az utókövetés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.", "follow_ups_modal_created_successfull_toast": "A követés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_modal_edit_heading": "Az utókövetés szerkesztése", "follow_ups_modal_edit_heading": "A követés szerkesztése",
"follow_ups_modal_edit_no_id": "Nincs kérdőív-utókövetési azonosító megadva, nem lehet frissíteni a kérdőív utókövetését", "follow_ups_modal_edit_no_id": "Nincs kérdőívkövetési azonosító megadva, nem lehet frissíteni a kérdőívkövetést",
"follow_ups_modal_name_label": "Utókövetés neve", "follow_ups_modal_name_label": "Követés neve",
"follow_ups_modal_name_placeholder": "Az utókövetés elnevezése", "follow_ups_modal_name_placeholder": "A követés elnevezése",
"follow_ups_modal_subheading": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak", "follow_ups_modal_subheading": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak",
"follow_ups_modal_trigger_description": "Mikor kell ezt az utókövetést aktiválni?", "follow_ups_modal_trigger_description": "Mikor kell ezt a követést aktiválni?",
"follow_ups_modal_trigger_label": "Aktiváló", "follow_ups_modal_trigger_label": "Aktiváló",
"follow_ups_modal_trigger_type_ending": "A válaszadó egy adott befejezést lát", "follow_ups_modal_trigger_type_ending": "A válaszadó egy adott befejezést lát",
"follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ", "follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ",
"follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát", "follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát",
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet", "follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
"follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.", "follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új utókövetés", "follow_ups_new": "Új követés",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva", "formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont", "four_points": "4 pont",
"heading": "Címsor", "heading": "Címsor",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.", "spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.", "spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
"spam_protection_threshold_heading": "Válasz küszöbszintje", "spam_protection_threshold_heading": "Válasz küszöbszintje",
"shrink_preview": "Előnézet összecsukása",
"star": "Csillag", "star": "Csillag",
"starts_with": "Ezzel kezdődik", "starts_with": "Ezzel kezdődik",
"state": "Állapot", "state": "Állapot",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva", "styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
"subheading": "Alcím", "subheading": "Alcím",
"subtract": "Kivonás -", "subtract": "Kivonás -",
"survey_closed_message_heading_required": "Címsor hozzáadása az egyéni kérdőív záró üzenetéhez.",
"survey_completed_heading": "A kérdőív kitöltve", "survey_completed_heading": "A kérdőív kitöltve",
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva", "survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai", "survey_display_settings": "Kérdőív megjelenítésének beállításai",
"survey_placement": "Kérdőív elhelyezése", "survey_placement": "Kérdőív elhelyezése",
"survey_preview": "Kérdőív előnézete 👀",
"survey_styling": "Kérdőív stílusának beállítása", "survey_styling": "Kérdőív stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója", "survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉", "switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
@@ -2789,8 +2721,8 @@
"evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…", "evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…",
"evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?", "evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?",
"evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…", "evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…",
"fake_door_follow_up_description": "Utókövetés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.", "fake_door_follow_up_description": "Követés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
"fake_door_follow_up_name": "„Fake door” utókövetés", "fake_door_follow_up_name": "„Fake door” követés",
"fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?", "fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?",
"fake_door_follow_up_question_1_lower_label": "Nem fontos", "fake_door_follow_up_question_1_lower_label": "Nem fontos",
"fake_door_follow_up_question_1_upper_label": "Nagyon fontos", "fake_door_follow_up_question_1_upper_label": "Nagyon fontos",
@@ -2799,7 +2731,7 @@
"fake_door_follow_up_question_2_choice_3": "3. szempont", "fake_door_follow_up_question_2_choice_3": "3. szempont",
"fake_door_follow_up_question_2_choice_4": "4. szempont", "fake_door_follow_up_question_2_choice_4": "4. szempont",
"fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?", "fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?",
"feature_chaser_description": "Utókövetés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.", "feature_chaser_description": "Követés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
"feature_chaser_name": "Funkcióvadász", "feature_chaser_name": "Funkcióvadász",
"feature_chaser_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?", "feature_chaser_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?",
"feature_chaser_question_1_lower_label": "Nem fontos", "feature_chaser_question_1_lower_label": "Nem fontos",
+16 -84
View File
@@ -294,7 +294,6 @@
"new": "新規", "new": "新規",
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!", "new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
"next": "次へ", "next": "次へ",
"no_actions_found": "アクションが見つかりません",
"no_background_image_found": "背景画像が見つかりません。", "no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード", "no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません", "no_files_uploaded": "ファイルがアップロードされていません",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください", "please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください", "please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
"please_upgrade_your_plan": "プランをアップグレードしてください", "please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー", "preview": "プレビュー",
"preview_survey": "フォームをプレビュー", "preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー", "privacy": "プライバシーポリシー",
@@ -436,9 +434,6 @@
"title": "タイトル", "title": "タイトル",
"top_left": "左上", "top_left": "左上",
"top_right": "右上", "top_right": "右上",
"trial_days_remaining": "トライアル期間の残り{count}日",
"trial_expired": "トライアル期間が終了しました",
"trial_one_day_remaining": "トライアル期間の残り1日",
"try_again": "もう一度お試しください", "try_again": "もう一度お試しください",
"type": "種類", "type": "種類",
"unknown_survey": "不明なフォーム", "unknown_survey": "不明なフォーム",
@@ -446,7 +441,6 @@
"update": "更新", "update": "更新",
"updated": "更新済み", "updated": "更新済み",
"updated_at": "更新日時", "updated_at": "更新日時",
"upgrade_plan": "プランをアップグレード",
"upload": "アップロード", "upload": "アップロード",
"upload_failed": "アップロードに失敗しました。もう一度お試しください。", "upload_failed": "アップロードに失敗しました。もう一度お試しください。",
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。", "upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
@@ -974,80 +968,44 @@
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します" "api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
}, },
"billing": { "billing": {
"add_payment_method": "支払い方法を追加", "cancelling": "キャンセル中",
"add_payment_method_to_upgrade_tooltip": "有料プランにアップグレードするには、上記で支払い方法を追加してください",
"billing_interval_toggle": "請求間隔",
"current_plan_badge": "現在のプラン",
"current_plan_cta": "現在のプラン",
"custom_plan_description": "あなたの組織はカスタム請求設定を利用しています。以下の標準プランに切り替えることもできます。",
"custom_plan_title": "カスタムプラン",
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。", "failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
"keep_current_plan": "現在のプランを継続", "manage_subscription": "サブスクリプションを管理",
"manage_billing_details": "カード情報と請求書を管理",
"monthly": "月払い",
"most_popular": "人気",
"pending_change_removed": "予定されていたプラン変更を取り消しました。",
"pending_plan_badge": "変更予定",
"pending_plan_change_description": "{{date}}に{{plan}}へ切り替わります。",
"pending_plan_change_title": "プラン変更の予定",
"pending_plan_cta": "変更予定",
"per_month": "/月",
"per_year": "/年",
"plan_change_applied": "プランを更新しました。",
"plan_change_scheduled": "プラン変更を予約しました。",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Hobbyプランの全機能",
"plan_feature_everything_in_pro": "Proプランの全機能",
"plan_hobby": "Hobby", "plan_hobby": "Hobby",
"plan_hobby_description": "Formbricks Cloudを始める個人や小規模チーム向けのプランです。",
"plan_hobby_feature_responses": "月250回の回答",
"plan_hobby_feature_workspaces": "1ワークスペース",
"plan_pro": "Pro", "plan_pro": "Pro",
"plan_pro_description": "より高い制限、自動化、動的なオーバーエージが必要な成長中のチーム向け。",
"plan_pro_feature_responses": "月2,000回の回答(超過分は従量制)",
"plan_pro_feature_workspaces": "3つのワークスペース",
"plan_scale": "Scale", "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": "不明", "plan_unknown": "不明",
"remove_branding": "ブランディングを削除", "remove_branding": "ブランディングを削除",
"retry_setup": "セットアップを再試行", "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_subtitle": "クレジットカード不要、縛りなし。",
"select_plan_header_title": "シームレスに統合されたアンケート、100%あなたのブランド。", "select_plan_header_title": "今すぐプロフェッショナルなブランドフリーのアンケートを配信しよう!",
"status_trialing": "Trial", "status_trialing": "Trial",
"stay_on_hobby_plan": "Hobbyプランを継続する", "stay_on_hobby_plan": "Hobbyプランを継続する",
"stripe_setup_incomplete": "請求情報の設定が未完了", "stripe_setup_incomplete": "請求情報の設定が未完了",
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。", "stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
"subscription": "サブスクリプション", "subscription": "サブスクリプション",
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら", "subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
"switch_at_period_end": "期間終了時に切り替え",
"switch_plan_now": "今すぐプランを切り替え",
"this_includes": "これには以下が含まれます",
"trial_alert_description": "すべての機能へのアクセスを維持するには、支払い方法を追加してください。",
"trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。", "trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。",
"trial_feature_api_access": "APIアクセス", "trial_feature_api_access": "フルAPIアクセスを利用",
"trial_feature_attribute_segmentation": "属性ベースのセグメンテーション", "trial_feature_collaboration": "すべてのチーム・コラボレーション機能",
"trial_feature_contact_segment_management": "連絡先とセグメントの管理", "trial_feature_email_followups": "メールフォローアップの設定",
"trial_feature_email_followups": "メールフォローアップ", "trial_feature_quotas": "クォータの管理",
"trial_feature_hide_branding": "Formbricksブランディングを非表示", "trial_feature_webhooks": "カスタムWebhookの設定",
"trial_feature_mobile_sdks": "iOS & Android SDK", "trial_feature_whitelabel": "完全ホワイトラベル対応のアンケート",
"trial_feature_respondent_identification": "回答者の識別",
"trial_feature_unlimited_seats": "無制限のシート数",
"trial_feature_webhooks": "カスタムWebhook",
"trial_no_credit_card": "14日間トライアル、クレジットカード不要", "trial_no_credit_card": "14日間トライアル、クレジットカード不要",
"trial_payment_method_added_description": "準備完了です!トライアル終了後、Proプランが自動的に継続されます。", "trial_title": "Pro機能を無料でお試し!",
"trial_title": "Formbricks Proを無料で入手しよう!",
"unlimited_responses": "無制限の回答", "unlimited_responses": "無制限の回答",
"unlimited_workspaces": "無制限ワークスペース", "unlimited_workspaces": "無制限ワークスペース",
"upgrade": "アップグレード", "upgrade": "アップグレード",
"upgrade_now": "今すぐアップグレード",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "使用済み", "used": "使用済み",
"yearly": "年間",
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
"your_plan": "ご利用プラン" "your_plan": "ご利用プラン"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "エンタープライズ機能", "enterprise_features": "エンタープライズ機能",
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。", "get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。", "keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
"license_feature_access_control": "アクセス制御(RBAC",
"license_feature_audit_logs": "監査ログ",
"license_feature_contacts": "連絡先とセグメント",
"license_feature_projects": "ワークスペース",
"license_feature_quotas": "クォータ",
"license_feature_remove_branding": "ブランディングの削除",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "スパム保護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "二要素認証",
"license_feature_whitelabel": "ホワイトラベルメール",
"license_features_table_access": "アクセス",
"license_features_table_description": "このインスタンスで現在利用可能なエンタープライズ機能と制限。",
"license_features_table_disabled": "無効",
"license_features_table_enabled": "有効",
"license_features_table_feature": "機能",
"license_features_table_title": "ライセンス機能",
"license_features_table_unlimited": "無制限",
"license_features_table_value": "値",
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。", "license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
"license_status": "ライセンスステータス", "license_status": "ライセンスステータス",
"license_status_active": "有効", "license_status_active": "有効",
"license_status_description": "エンタープライズライセンスのステータス。", "license_status_description": "エンタープライズライセンスのステータス。",
"license_status_expired": "期限切れ", "license_status_expired": "期限切れ",
"license_status_instance_mismatch": "別のインスタンスに紐付け済み",
"license_status_invalid": "無効なライセンス", "license_status_invalid": "無効なライセンス",
"license_status_unreachable": "接続不可", "license_status_unreachable": "接続不可",
"license_unreachable_grace_period": "ライセンスサーバーに接続できません。エンタープライズ機能は{gracePeriodEnd}までの3日間の猶予期間中は引き続き利用できます。", "license_unreachable_grace_period": "ライセンスサーバーに接続できません。エンタープライズ機能は{gracePeriodEnd}までの3日間の猶予期間中は引き続き利用できます。",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "質問はありますか?こちらまでお問い合わせください", "questions_please_reach_out_to": "質問はありますか?こちらまでお問い合わせください",
"recheck_license": "ライセンスを再確認", "recheck_license": "ライセンスを再確認",
"recheck_license_failed": "ライセンスの確認に失敗しました。ライセンスサーバーに接続できない可能性があります。", "recheck_license_failed": "ライセンスの確認に失敗しました。ライセンスサーバーに接続できない可能性があります。",
"recheck_license_instance_mismatch": "このライセンスは別のFormbricksインスタンスに紐付けられています。Formbricksサポートに連絡して、以前の紐付けを解除してもらってください。",
"recheck_license_invalid": "ライセンスキーが無効です。ENTERPRISE_LICENSE_KEYを確認してください。", "recheck_license_invalid": "ライセンスキーが無効です。ENTERPRISE_LICENSE_KEYを確認してください。",
"recheck_license_success": "ライセンスの確認に成功しました", "recheck_license_success": "ライセンスの確認に成功しました",
"recheck_license_unreachable": "ライセンスサーバーに接続できません。後ほど再度お試しください。", "recheck_license_unreachable": "ライセンスサーバーに接続できません。後ほど再度お試しください。",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "変更の保存中にエラーが発生しました", "error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。", "even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
"everyone": "全員", "everyone": "全員",
"expand_preview": "プレビューを展開",
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。", "external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。",
"fallback_missing": "フォールバックがありません", "fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。", "spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。", "spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
"spam_protection_threshold_heading": "回答のしきい値", "spam_protection_threshold_heading": "回答のしきい値",
"shrink_preview": "プレビューを縮小",
"star": "星", "star": "星",
"starts_with": "で始まる", "starts_with": "で始まる",
"state": "都道府県", "state": "都道府県",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました", "styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
"subheading": "サブ見出し", "subheading": "サブ見出し",
"subtract": "減算 -", "subtract": "減算 -",
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
"survey_completed_heading": "フォームが完了しました", "survey_completed_heading": "フォームが完了しました",
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました", "survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定", "survey_display_settings": "フォーム表示設定",
"survey_placement": "フォームの配置", "survey_placement": "フォームの配置",
"survey_preview": "アンケートプレビュー 👀",
"survey_styling": "フォームのスタイル", "survey_styling": "フォームのスタイル",
"survey_trigger": "フォームのトリガー", "survey_trigger": "フォームのトリガー",
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉", "switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
+19 -87
View File
@@ -294,7 +294,6 @@
"new": "Nieuw", "new": "Nieuw",
"new_version_available": "Formbricks {version} is hier. Upgrade nu!", "new_version_available": "Formbricks {version} is hier. Upgrade nu!",
"next": "Volgende", "next": "Volgende",
"no_actions_found": "Geen acties gevonden",
"no_background_image_found": "Geen achtergrondafbeelding gevonden.", "no_background_image_found": "Geen achtergrondafbeelding gevonden.",
"no_code": "Geen code", "no_code": "Geen code",
"no_files_uploaded": "Er zijn geen bestanden geüpload", "no_files_uploaded": "Er zijn geen bestanden geüpload",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Selecteer ten minste één enquête", "please_select_at_least_one_survey": "Selecteer ten minste één enquête",
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger", "please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
"please_upgrade_your_plan": "Upgrade je abonnement", "please_upgrade_your_plan": "Upgrade je abonnement",
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
"preview": "Voorbeeld", "preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête", "preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid", "privacy": "Privacybeleid",
@@ -401,7 +399,7 @@
"something_went_wrong": "Er is iets misgegaan", "something_went_wrong": "Er is iets misgegaan",
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.", "something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
"sort_by": "Sorteer op", "sort_by": "Sorteer op",
"start_free_trial": "Start gratis proefperiode", "start_free_trial": "Gratis proefperiode starten",
"status": "Status", "status": "Status",
"step_by_step_manual": "Stap voor stap handleiding", "step_by_step_manual": "Stap voor stap handleiding",
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken", "storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
@@ -436,9 +434,6 @@
"title": "Titel", "title": "Titel",
"top_left": "Linksboven", "top_left": "Linksboven",
"top_right": "Rechtsboven", "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", "try_again": "Probeer het opnieuw",
"type": "Type", "type": "Type",
"unknown_survey": "Onbekende enquête", "unknown_survey": "Onbekende enquête",
@@ -446,7 +441,6 @@
"update": "Update", "update": "Update",
"updated": "Bijgewerkt", "updated": "Bijgewerkt",
"updated_at": "Bijgewerkt op", "updated_at": "Bijgewerkt op",
"upgrade_plan": "Abonnement upgraden",
"upload": "Uploaden", "upload": "Uploaden",
"upload_failed": "Upload mislukt. Probeer het opnieuw.", "upload_failed": "Upload mislukt. Probeer het opnieuw.",
"upload_input_description": "Klik of sleep om bestanden te uploaden.", "upload_input_description": "Klik of sleep om bestanden te uploaden.",
@@ -974,80 +968,44 @@
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's" "api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
}, },
"billing": { "billing": {
"add_payment_method": "Betaalmethode toevoegen", "cancelling": "Bezig met annuleren",
"add_payment_method_to_upgrade_tooltip": "Voeg hierboven een betaalmethode toe om te upgraden naar een betaald abonnement",
"billing_interval_toggle": "Factureringsinterval",
"current_plan_badge": "Huidig",
"current_plan_cta": "Huidig abonnement",
"custom_plan_description": "Je organisatie heeft een aangepaste factureringsopzet. Je kunt nog steeds overstappen naar een van de standaard abonnementen hieronder.",
"custom_plan_title": "Aangepast abonnement",
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.", "failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
"keep_current_plan": "Huidig abonnement behouden", "manage_subscription": "Abonnement beheren",
"manage_billing_details": "Kaartgegevens en facturen beheren",
"monthly": "Maandelijks",
"most_popular": "Meest populair",
"pending_change_removed": "Geplande abonnementswijziging verwijderd.",
"pending_plan_badge": "Gepland",
"pending_plan_change_description": "Je abonnement wordt op {{date}} omgezet naar {{plan}}.",
"pending_plan_change_title": "Geplande abonnementswijziging",
"pending_plan_cta": "Gepland",
"per_month": "per maand",
"per_year": "per jaar",
"plan_change_applied": "Abonnement succesvol bijgewerkt.",
"plan_change_scheduled": "Abonnementswijziging succesvol ingepland.",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Alles in Hobby",
"plan_feature_everything_in_pro": "Alles in Pro",
"plan_hobby": "Hobby", "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": "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": "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", "plan_unknown": "Onbekend",
"remove_branding": "Branding verwijderen", "remove_branding": "Branding verwijderen",
"retry_setup": "Opnieuw proberen", "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_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", "status_trialing": "Proefperiode",
"stay_on_hobby_plan": "Ik wil op het Hobby-abonnement blijven", "stay_on_hobby_plan": "Ik wil op het Hobby-abonnement blijven",
"stripe_setup_incomplete": "Facturatie-instelling niet voltooid", "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.", "stripe_setup_incomplete_description": "Het instellen van de facturatie is niet gelukt. Probeer het opnieuw om je abonnement te activeren.",
"subscription": "Abonnement", "subscription": "Abonnement",
"subscription_description": "Beheer je abonnement en houd je gebruik bij", "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_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_api_access": "Krijg volledige API-toegang",
"trial_feature_attribute_segmentation": "Segmentatie op basis van attributen", "trial_feature_collaboration": "Alle team- en samenwerkingsfuncties",
"trial_feature_contact_segment_management": "Contact- en segmentbeheer", "trial_feature_email_followups": "E-mail follow-ups instellen",
"trial_feature_email_followups": "E-mail follow-ups", "trial_feature_quotas": "Quota's beheren",
"trial_feature_hide_branding": "Verberg Formbricks-branding", "trial_feature_webhooks": "Aangepaste webhooks instellen",
"trial_feature_mobile_sdks": "iOS- en Android-SDK's", "trial_feature_whitelabel": "Volledig white-label enquêtes",
"trial_feature_respondent_identification": "Identificatie van respondenten",
"trial_feature_unlimited_seats": "Onbeperkt aantal gebruikers",
"trial_feature_webhooks": "Aangepaste webhooks",
"trial_no_credit_card": "14 dagen proefperiode, geen creditcard vereist", "trial_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": "Probeer Pro-functies gratis!",
"trial_title": "Krijg Formbricks Pro gratis!",
"unlimited_responses": "Onbeperkte reacties", "unlimited_responses": "Onbeperkte reacties",
"unlimited_workspaces": "Onbeperkt werkruimtes", "unlimited_workspaces": "Onbeperkt werkruimtes",
"upgrade": "Upgraden", "upgrade": "Upgraden",
"upgrade_now": "Nu upgraden",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "gebruikt", "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" "your_plan": "Jouw abonnement"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Enterprise-functies", "enterprise_features": "Enterprise-functies",
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.", "get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.", "keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
"license_feature_access_control": "Toegangscontrole (RBAC)",
"license_feature_audit_logs": "Auditlogboeken",
"license_feature_contacts": "Contacten & Segmenten",
"license_feature_projects": "Werkruimtes",
"license_feature_quotas": "Quota's",
"license_feature_remove_branding": "Branding verwijderen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spambescherming",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tweefactorauthenticatie",
"license_feature_whitelabel": "Whitelabel-e-mails",
"license_features_table_access": "Toegang",
"license_features_table_description": "Enterprise-functies en limieten die momenteel beschikbaar zijn voor deze instantie.",
"license_features_table_disabled": "Uitgeschakeld",
"license_features_table_enabled": "Ingeschakeld",
"license_features_table_feature": "Functie",
"license_features_table_title": "Gelicentieerde Functies",
"license_features_table_unlimited": "Onbeperkt",
"license_features_table_value": "Waarde",
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.", "license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
"license_status": "Licentiestatus", "license_status": "Licentiestatus",
"license_status_active": "Actief", "license_status_active": "Actief",
"license_status_description": "Status van je enterprise-licentie.", "license_status_description": "Status van je enterprise-licentie.",
"license_status_expired": "Verlopen", "license_status_expired": "Verlopen",
"license_status_instance_mismatch": "Gekoppeld aan Andere Instantie",
"license_status_invalid": "Ongeldige licentie", "license_status_invalid": "Ongeldige licentie",
"license_status_unreachable": "Niet bereikbaar", "license_status_unreachable": "Niet bereikbaar",
"license_unreachable_grace_period": "Licentieserver is niet bereikbaar. Je enterprise functies blijven actief tijdens een respijtperiode van 3 dagen die eindigt op {gracePeriodEnd}.", "license_unreachable_grace_period": "Licentieserver is niet bereikbaar. Je enterprise functies blijven actief tijdens een respijtperiode van 3 dagen die eindigt op {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Vragen? Neem contact op met", "questions_please_reach_out_to": "Vragen? Neem contact op met",
"recheck_license": "Licentie opnieuw controleren", "recheck_license": "Licentie opnieuw controleren",
"recheck_license_failed": "Licentiecontrole mislukt. De licentieserver is mogelijk niet bereikbaar.", "recheck_license_failed": "Licentiecontrole mislukt. De licentieserver is mogelijk niet bereikbaar.",
"recheck_license_instance_mismatch": "Deze licentie is gekoppeld aan een andere Formbricks-instantie. Vraag Formbricks-support om de vorige koppeling te verbreken.",
"recheck_license_invalid": "De licentiesleutel is ongeldig. Controleer je ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "De licentiesleutel is ongeldig. Controleer je ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Licentiecontrole geslaagd", "recheck_license_success": "Licentiecontrole geslaagd",
"recheck_license_unreachable": "Licentieserver is niet bereikbaar. Probeer het later opnieuw.", "recheck_license_unreachable": "Licentieserver is niet bereikbaar. Probeer het later opnieuw.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Fout bij het opslaan van wijzigingen", "error_saving_changes": "Fout bij het opslaan van wijzigingen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).", "even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
"everyone": "Iedereen", "everyone": "Iedereen",
"expand_preview": "Voorbeeld uitvouwen",
"external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.", "external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.",
"fallback_missing": "Terugval ontbreekt", "fallback_missing": "Terugval ontbreekt",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.", "spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.",
"spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.", "spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
"spam_protection_threshold_heading": "Reactiedrempel", "spam_protection_threshold_heading": "Reactiedrempel",
"shrink_preview": "Voorbeeld invouwen",
"star": "Ster", "star": "Ster",
"starts_with": "Begint met", "starts_with": "Begint met",
"state": "Staat", "state": "Staat",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen", "styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
"subheading": "Ondertitel", "subheading": "Ondertitel",
"subtract": "Aftrekken -", "subtract": "Aftrekken -",
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
"survey_completed_heading": "Enquête voltooid", "survey_completed_heading": "Enquête voltooid",
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten", "survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen", "survey_display_settings": "Enquêteweergave-instellingen",
"survey_placement": "Enquête plaatsing", "survey_placement": "Enquête plaatsing",
"survey_preview": "Enquêtevoorbeeld 👀",
"survey_styling": "Vorm styling", "survey_styling": "Vorm styling",
"survey_trigger": "Enquêtetrigger", "survey_trigger": "Enquêtetrigger",
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉", "switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Nee, dank je!", "preview_survey_question_2_choice_2_label": "Nee, dank je!",
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?", "preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.", "preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
"preview_survey_question_open_text_headline": "Wilt u nog iets anders delen?", "preview_survey_question_open_text_headline": "Wil je nog iets delen?",
"preview_survey_question_open_text_placeholder": "Typ hier je antwoord...", "preview_survey_question_open_text_placeholder": "Typ hier je antwoord...",
"preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.", "preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.",
"preview_survey_welcome_card_headline": "Welkom!", "preview_survey_welcome_card_headline": "Welkom!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.", "coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
"coming_soon_title": "We zijn er bijna!", "coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat u wilt toevoegen?", "follow_up_label": "Is er nog iets dat je wilt toevoegen?",
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?", "follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
"generate_button": "Genereer workflow", "generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?", "heading": "Welke workflow wil je maken?",
+19 -87
View File
@@ -294,7 +294,6 @@
"new": "Novo", "new": "Novo",
"new_version_available": "Formbricks {version} chegou. Atualize agora!", "new_version_available": "Formbricks {version} chegou. Atualize agora!",
"next": "Próximo", "next": "Próximo",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Imagem de fundo não encontrada.", "no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código", "no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado", "no_files_uploaded": "Nenhum arquivo foi enviado",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa", "please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho", "please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize seu plano", "please_upgrade_your_plan": "Por favor, atualize seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Prévia", "preview": "Prévia",
"preview_survey": "Prévia da Pesquisa", "preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade", "privacy": "Política de Privacidade",
@@ -401,7 +399,7 @@
"something_went_wrong": "Algo deu errado", "something_went_wrong": "Algo deu errado",
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.", "something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
"sort_by": "Ordenar por", "sort_by": "Ordenar por",
"start_free_trial": "Iniciar teste gratuito", "start_free_trial": "Iniciar Teste Grátis",
"status": "status", "status": "status",
"step_by_step_manual": "Manual passo a passo", "step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão", "storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
@@ -436,9 +434,6 @@
"title": "Título", "title": "Título",
"top_left": "Canto superior esquerdo", "top_left": "Canto superior esquerdo",
"top_right": "Canto Superior Direito", "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", "try_again": "Tenta de novo",
"type": "Tipo", "type": "Tipo",
"unknown_survey": "Pesquisa desconhecida", "unknown_survey": "Pesquisa desconhecida",
@@ -446,7 +441,6 @@
"update": "atualizar", "update": "atualizar",
"updated": "atualizado", "updated": "atualizado",
"updated_at": "Atualizado em", "updated_at": "Atualizado em",
"upgrade_plan": "Fazer upgrade do plano",
"upload": "Enviar", "upload": "Enviar",
"upload_failed": "Falha no upload. Tente novamente.", "upload_failed": "Falha no upload. Tente novamente.",
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.", "upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
@@ -974,80 +968,44 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks" "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
}, },
"billing": { "billing": {
"add_payment_method": "Adicionar forma de pagamento", "cancelling": "Cancelando",
"add_payment_method_to_upgrade_tooltip": "Por favor, adicione uma forma de pagamento acima para fazer upgrade para um plano pago",
"billing_interval_toggle": "Intervalo de cobrança",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "Sua organização está em uma configuração de cobrança personalizada. Você ainda pode mudar para um dos planos padrão abaixo.",
"custom_plan_title": "Plano personalizado",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.", "failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
"keep_current_plan": "Manter plano atual", "manage_subscription": "Gerenciar assinatura",
"manage_billing_details": "Gerenciar detalhes do cartão e faturas",
"monthly": "Mensal",
"most_popular": "Mais popular",
"pending_change_removed": "Mudança de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "Seu plano mudará para {{plan}} em {{date}}.",
"pending_plan_change_title": "Mudança de plano agendada",
"pending_plan_cta": "Agendado",
"per_month": "por mês",
"per_year": "por ano",
"plan_change_applied": "Plano atualizado com sucesso.",
"plan_change_scheduled": "Mudança de plano agendada com sucesso.",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tudo do Hobby",
"plan_feature_everything_in_pro": "Tudo do Pro",
"plan_hobby": "Hobby", "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": "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": "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", "plan_unknown": "desconhecido",
"remove_branding": "Remover Marca", "remove_branding": "Remover Marca",
"retry_setup": "Tentar novamente", "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_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", "status_trialing": "Trial",
"stay_on_hobby_plan": "Quero continuar no plano Hobby", "stay_on_hobby_plan": "Quero continuar no plano Hobby",
"stripe_setup_incomplete": "Configuração de cobrança incompleta", "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.", "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": "Assinatura",
"subscription_description": "Gerencie seu plano de assinatura e acompanhe seu uso", "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_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_api_access": "Obtenha acesso completo à API",
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos", "trial_feature_collaboration": "Todos os recursos de equipe e colaboração",
"trial_feature_contact_segment_management": "Gerenciamento de Contatos e Segmentos", "trial_feature_email_followups": "Configure acompanhamentos por e-mail",
"trial_feature_email_followups": "Follow-ups por E-mail", "trial_feature_quotas": "Gerencie cotas",
"trial_feature_hide_branding": "Ocultar Marca Formbricks", "trial_feature_webhooks": "Configure webhooks personalizados",
"trial_feature_mobile_sdks": "SDKs para iOS e Android", "trial_feature_whitelabel": "Pesquisas totalmente personalizadas",
"trial_feature_respondent_identification": "Identificação de Respondentes",
"trial_feature_unlimited_seats": "Assentos Ilimitados",
"trial_feature_webhooks": "Webhooks Personalizados",
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito", "trial_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": "Experimente os recursos Pro gratuitamente!",
"trial_title": "Ganhe o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas", "unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados", "unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar", "upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "usado", "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" "your_plan": "Seu plano"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Recursos Empresariais", "enterprise_features": "Recursos Empresariais",
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.", "get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.", "keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controle de acesso (RBAC)",
"license_feature_audit_logs": "Logs de auditoria",
"license_feature_contacts": "Contatos e Segmentos",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Cotas",
"license_feature_remove_branding": "Remover identidade visual",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails white-label",
"license_features_table_access": "Acesso",
"license_features_table_description": "Recursos empresariais e limites disponíveis atualmente para esta instância.",
"license_features_table_disabled": "Desabilitado",
"license_features_table_enabled": "Habilitado",
"license_features_table_feature": "Recurso",
"license_features_table_title": "Recursos Licenciados",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.", "license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
"license_status": "Status da licença", "license_status": "Status da licença",
"license_status_active": "Ativa", "license_status_active": "Ativa",
"license_status_description": "Status da sua licença enterprise.", "license_status_description": "Status da sua licença enterprise.",
"license_status_expired": "Expirada", "license_status_expired": "Expirada",
"license_status_instance_mismatch": "Vinculada a Outra Instância",
"license_status_invalid": "Licença inválida", "license_status_invalid": "Licença inválida",
"license_status_unreachable": "Inacessível", "license_status_unreachable": "Inacessível",
"license_unreachable_grace_period": "O servidor de licenças não pode ser alcançado. Seus recursos empresariais permanecem ativos durante um período de carência de 3 dias que termina em {gracePeriodEnd}.", "license_unreachable_grace_period": "O servidor de licenças não pode ser alcançado. Seus recursos empresariais permanecem ativos durante um período de carência de 3 dias que termina em {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Perguntas? Entre em contato com", "questions_please_reach_out_to": "Perguntas? Entre em contato com",
"recheck_license": "Verificar licença novamente", "recheck_license": "Verificar licença novamente",
"recheck_license_failed": "Falha na verificação da licença. O servidor de licenças pode estar inacessível.", "recheck_license_failed": "Falha na verificação da licença. O servidor de licenças pode estar inacessível.",
"recheck_license_instance_mismatch": "Esta licença está vinculada a uma instância diferente do Formbricks. Peça ao suporte do Formbricks para desconectar a vinculação anterior.",
"recheck_license_invalid": "A chave de licença é inválida. Verifique sua ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "A chave de licença é inválida. Verifique sua ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Verificação da licença bem-sucedida", "recheck_license_success": "Verificação da licença bem-sucedida",
"recheck_license_unreachable": "Servidor de licenças inacessível. Por favor, tente novamente mais tarde.", "recheck_license_unreachable": "Servidor de licenças inacessível. Por favor, tente novamente mais tarde.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Erro ao salvar alterações", "error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
"everyone": "Todo mundo", "everyone": "Todo mundo",
"expand_preview": "Expandir prévia",
"external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.", "external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.",
"fallback_missing": "Faltando alternativa", "fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.", "spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.", "spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta", "spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Recolher prévia",
"star": "Estrela", "star": "Estrela",
"starts_with": "Começa com", "starts_with": "Começa com",
"state": "Estado", "state": "Estado",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema", "styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
"subheading": "Subtítulo", "subheading": "Subtítulo",
"subtract": "Subtrair -", "subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
"survey_completed_heading": "Pesquisa Concluída", "survey_completed_heading": "Pesquisa Concluída",
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada", "survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa", "survey_display_settings": "Configurações de Exibição da Pesquisa",
"survey_placement": "Posicionamento da Pesquisa", "survey_placement": "Posicionamento da Pesquisa",
"survey_preview": "Prévia da pesquisa 👀",
"survey_styling": "Estilização de Formulários", "survey_styling": "Estilização de Formulários",
"survey_trigger": "Gatilho de Pesquisa", "survey_trigger": "Gatilho de Pesquisa",
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉", "switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!", "preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?", "preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.", "preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Há algo mais que você gostaria de compartilhar?", "preview_survey_question_open_text_headline": "Tem mais alguma coisa que você gostaria de compartilhar?",
"preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...", "preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...",
"preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.", "preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!", "preview_survey_welcome_card_headline": "Bem-vindo!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.", "coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
"coming_soon_title": "Estamos quase lá!", "coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de acrescentar?", "follow_up_label": "Há algo mais que você gostaria de adicionar?",
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?", "follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho", "generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?", "heading": "Qual fluxo de trabalho você quer criar?",
+18 -86
View File
@@ -294,7 +294,6 @@
"new": "Novo", "new": "Novo",
"new_version_available": "Formbricks {version} está aqui. Atualize agora!", "new_version_available": "Formbricks {version} está aqui. Atualize agora!",
"next": "Seguinte", "next": "Seguinte",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Nenhuma imagem de fundo encontrada.", "no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código", "no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado", "no_files_uploaded": "Nenhum ficheiro foi carregado",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito", "please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho", "please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize o seu plano", "please_upgrade_your_plan": "Por favor, atualize o seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Pré-visualização", "preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito", "preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade", "privacy": "Política de Privacidade",
@@ -401,7 +399,7 @@
"something_went_wrong": "Algo correu mal", "something_went_wrong": "Algo correu mal",
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.", "something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
"sort_by": "Ordem", "sort_by": "Ordem",
"start_free_trial": "Iniciar teste gratuito", "start_free_trial": "Iniciar Teste Grátis",
"status": "Estado", "status": "Estado",
"step_by_step_manual": "Manual passo a passo", "step_by_step_manual": "Manual passo a passo",
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão", "storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
@@ -436,9 +434,6 @@
"title": "Título", "title": "Título",
"top_left": "Superior Esquerdo", "top_left": "Superior Esquerdo",
"top_right": "Superior Direito", "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", "try_again": "Tente novamente",
"type": "Tipo", "type": "Tipo",
"unknown_survey": "Inquérito desconhecido", "unknown_survey": "Inquérito desconhecido",
@@ -446,7 +441,6 @@
"update": "Atualizar", "update": "Atualizar",
"updated": "Atualizado", "updated": "Atualizado",
"updated_at": "Atualizado em", "updated_at": "Atualizado em",
"upgrade_plan": "Fazer upgrade do plano",
"upload": "Carregar", "upload": "Carregar",
"upload_failed": "Falha no carregamento. Por favor, tente novamente.", "upload_failed": "Falha no carregamento. Por favor, tente novamente.",
"upload_input_description": "Clique ou arraste para carregar ficheiros.", "upload_input_description": "Clique ou arraste para carregar ficheiros.",
@@ -974,80 +968,44 @@
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks" "api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks"
}, },
"billing": { "billing": {
"add_payment_method": "Adicionar método de pagamento", "cancelling": "A cancelar",
"add_payment_method_to_upgrade_tooltip": "Por favor, adiciona um método de pagamento acima para fazeres upgrade para um plano pago",
"billing_interval_toggle": "Intervalo de faturação",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "A tua organização tem uma configuração de faturação personalizada. Podes mudar para um dos planos padrão abaixo.",
"custom_plan_title": "Plano personalizado",
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.", "failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
"keep_current_plan": "Manter plano atual", "manage_subscription": "Gerir subscrição",
"manage_billing_details": "Gerir detalhes do cartão e faturas",
"monthly": "Mensal",
"most_popular": "Mais popular",
"pending_change_removed": "Alteração de plano agendada removida.",
"pending_plan_badge": "Agendado",
"pending_plan_change_description": "O teu plano mudará para {{plan}} em {{date}}.",
"pending_plan_change_title": "Alteração de plano agendada",
"pending_plan_cta": "Agendado",
"per_month": "por mês",
"per_year": "por ano",
"plan_change_applied": "Plano atualizado com sucesso.",
"plan_change_scheduled": "Alteração de plano agendada com sucesso.",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Tudo no Hobby",
"plan_feature_everything_in_pro": "Tudo no Pro",
"plan_hobby": "Hobby", "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": "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": "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", "plan_unknown": "Desconhecido",
"remove_branding": "Possibilidade de remover o logo", "remove_branding": "Possibilidade de remover o logo",
"retry_setup": "Tentar novamente configurar", "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_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", "status_trialing": "Trial",
"stay_on_hobby_plan": "Quero manter o plano Hobby", "stay_on_hobby_plan": "Quero manter o plano Hobby",
"stripe_setup_incomplete": "Configuração de faturação incompleta", "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.", "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": "Subscrição",
"subscription_description": "Gere o teu plano de subscrição e acompanha a tua utilizaçã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_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_api_access": "Obtém acesso completo à API",
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos", "trial_feature_collaboration": "Todas as funcionalidades de equipa e colaboração",
"trial_feature_contact_segment_management": "Gestão de Contactos e Segmentos", "trial_feature_email_followups": "Configura acompanhamentos por email",
"trial_feature_email_followups": "Seguimentos por E-mail", "trial_feature_quotas": "Gere quotas",
"trial_feature_hide_branding": "Ocultar Marca Formbricks", "trial_feature_webhooks": "Configura webhooks personalizados",
"trial_feature_mobile_sdks": "SDKs para iOS e Android", "trial_feature_whitelabel": "Inquéritos totalmente personalizados",
"trial_feature_respondent_identification": "Identificação de Inquiridos",
"trial_feature_unlimited_seats": "Lugares Ilimitados",
"trial_feature_webhooks": "Webhooks Personalizados",
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito", "trial_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": "Experimenta as funcionalidades Pro gratuitamente!",
"trial_title": "Obtém o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas", "unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados", "unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar", "upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "utilizado", "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" "your_plan": "O teu plano"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Funcionalidades da Empresa", "enterprise_features": "Funcionalidades da Empresa",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.", "get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.", "keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controlo de acesso (RBAC)",
"license_feature_audit_logs": "Registos de auditoria",
"license_feature_contacts": "Contactos e Segmentos",
"license_feature_projects": "Áreas de trabalho",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remover marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails personalizados",
"license_features_table_access": "Acesso",
"license_features_table_description": "Funcionalidades e limites empresariais atualmente disponíveis para esta instância.",
"license_features_table_disabled": "Desativado",
"license_features_table_enabled": "Ativado",
"license_features_table_feature": "Funcionalidade",
"license_features_table_title": "Funcionalidades Licenciadas",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.", "license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
"license_status": "Estado da licença", "license_status": "Estado da licença",
"license_status_active": "Ativa", "license_status_active": "Ativa",
"license_status_description": "Estado da sua licença empresarial.", "license_status_description": "Estado da sua licença empresarial.",
"license_status_expired": "Expirada", "license_status_expired": "Expirada",
"license_status_instance_mismatch": "Associada a Outra Instância",
"license_status_invalid": "Licença inválida", "license_status_invalid": "Licença inválida",
"license_status_unreachable": "Inacessível", "license_status_unreachable": "Inacessível",
"license_unreachable_grace_period": "Não é possível contactar o servidor de licenças. As suas funcionalidades empresariais permanecem ativas durante um período de tolerância de 3 dias que termina a {gracePeriodEnd}.", "license_unreachable_grace_period": "Não é possível contactar o servidor de licenças. As suas funcionalidades empresariais permanecem ativas durante um período de tolerância de 3 dias que termina a {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Questões? Por favor entre em contacto com", "questions_please_reach_out_to": "Questões? Por favor entre em contacto com",
"recheck_license": "Verificar licença novamente", "recheck_license": "Verificar licença novamente",
"recheck_license_failed": "A verificação da licença falhou. O servidor de licenças pode estar inacessível.", "recheck_license_failed": "A verificação da licença falhou. O servidor de licenças pode estar inacessível.",
"recheck_license_instance_mismatch": "Esta licença está associada a uma instância Formbricks diferente. Pede ao suporte da Formbricks para desconectar a associação anterior.",
"recheck_license_invalid": "A chave de licença é inválida. Por favor, verifique a sua ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "A chave de licença é inválida. Por favor, verifique a sua ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Verificação da licença bem-sucedida", "recheck_license_success": "Verificação da licença bem-sucedida",
"recheck_license_unreachable": "O servidor de licenças está inacessível. Por favor, tenta novamente mais tarde.", "recheck_license_unreachable": "O servidor de licenças está inacessível. Por favor, tenta novamente mais tarde.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Erro ao guardar alterações", "error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
"everyone": "Todos", "everyone": "Todos",
"expand_preview": "Expandir pré-visualização",
"external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.", "external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.",
"fallback_missing": "Substituição em falta", "fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.", "spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.", "spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta", "spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Reduzir pré-visualização",
"star": "Estrela", "star": "Estrela",
"starts_with": "Começa com", "starts_with": "Começa com",
"state": "Estado", "state": "Estado",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Estilo definido para estilos do tema", "styling_set_to_theme_styles": "Estilo definido para estilos do tema",
"subheading": "Subtítulo", "subheading": "Subtítulo",
"subtract": "Subtrair -", "subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
"survey_completed_heading": "Inquérito Concluído", "survey_completed_heading": "Inquérito Concluído",
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado", "survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito", "survey_display_settings": "Configurações de Exibição do Inquérito",
"survey_placement": "Colocação do Inquérito", "survey_placement": "Colocação do Inquérito",
"survey_preview": "Pré-visualização do questionário 👀",
"survey_styling": "Estilo do formulário", "survey_styling": "Estilo do formulário",
"survey_trigger": "Desencadeador de Inquérito", "survey_trigger": "Desencadeador de Inquérito",
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉", "switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!", "preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?", "preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.", "preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Há mais alguma coisa que gostaria de partilhar?", "preview_survey_question_open_text_headline": "Mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...", "preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...",
"preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.", "preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!", "preview_survey_welcome_card_headline": "Bem-vindo!",
+19 -87
View File
@@ -294,7 +294,6 @@
"new": "Nou", "new": "Nou",
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!", "new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
"next": "Următorul", "next": "Următorul",
"no_actions_found": "Nu au fost găsite acțiuni",
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.", "no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
"no_code": "Fără Cod", "no_code": "Fără Cod",
"no_files_uploaded": "Nu au fost încărcate fișiere", "no_files_uploaded": "Nu au fost încărcate fișiere",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj", "please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator", "please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră", "please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"powered_by_formbricks": "Oferit de Formbricks",
"preview": "Previzualizare", "preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar", "preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate", "privacy": "Politica de Confidențialitate",
@@ -401,7 +399,7 @@
"something_went_wrong": "Ceva nu a mers bine", "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.", "something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
"sort_by": "Sortare după", "sort_by": "Sortare după",
"start_free_trial": "Începe perioada de probă gratuită", "start_free_trial": "Începe perioada de testare gratuită",
"status": "Stare", "status": "Stare",
"step_by_step_manual": "Manual pas cu pas", "step_by_step_manual": "Manual pas cu pas",
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil", "storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
@@ -436,9 +434,6 @@
"title": "Titlu", "title": "Titlu",
"top_left": "Stânga Sus", "top_left": "Stânga Sus",
"top_right": "Dreapta 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", "try_again": "Încearcă din nou",
"type": "Tip", "type": "Tip",
"unknown_survey": "Chestionar necunoscut", "unknown_survey": "Chestionar necunoscut",
@@ -446,7 +441,6 @@
"update": "Actualizare", "update": "Actualizare",
"updated": "Actualizat", "updated": "Actualizat",
"updated_at": "Actualizat la", "updated_at": "Actualizat la",
"upgrade_plan": "Actualizează planul",
"upload": "Încărcați", "upload": "Încărcați",
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.", "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.", "upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
@@ -974,80 +968,44 @@
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks" "api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
}, },
"billing": { "billing": {
"add_payment_method": "Adaugă o metodă de plată", "cancelling": "Anulare în curs",
"add_payment_method_to_upgrade_tooltip": "Te rugăm să adaugi o metodă de plată mai sus pentru a trece la un plan plătit",
"billing_interval_toggle": "Interval de facturare",
"current_plan_badge": "Curent",
"current_plan_cta": "Plan curent",
"custom_plan_description": "Organizația ta folosește o configurație de facturare personalizată. Poți totuși să treci la unul dintre planurile standard de mai jos.",
"custom_plan_title": "Plan personalizat",
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.", "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_subscription": "Gestionează abonamentul",
"manage_billing_details": "Gestionează detaliile cardului și facturile",
"monthly": "Lunar",
"most_popular": "Cel mai popular",
"pending_change_removed": "Schimbarea de plan programată a fost anulată.",
"pending_plan_badge": "Programat",
"pending_plan_change_description": "Planul tău va trece la {{plan}} pe {{date}}.",
"pending_plan_change_title": "Schimbare de plan programată",
"pending_plan_cta": "Programat",
"per_month": "pe lună",
"per_year": "pe an",
"plan_change_applied": "Planul a fost actualizat cu succes.",
"plan_change_scheduled": "Schimbarea de plan a fost programată cu succes.",
"plan_custom": "Custom", "plan_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": "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": "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": "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", "plan_unknown": "Necunoscut",
"remove_branding": "Eliminare branding", "remove_branding": "Eliminare branding",
"retry_setup": "Încearcă din nou configurarea", "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_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", "status_trialing": "Trial",
"stay_on_hobby_plan": "Vreau să rămân pe planul Hobby", "stay_on_hobby_plan": "Vreau să rămân pe planul Hobby",
"stripe_setup_incomplete": "Configurare facturare incompletă", "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.", "stripe_setup_incomplete_description": "Configurarea facturării nu a fost finalizată cu succes. Încearcă din nou pentru a activa abonamentul.",
"subscription": "Abonament", "subscription": "Abonament",
"subscription_description": "Gestionează-ți abonamentul și monitorizează-ți consumul", "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_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_api_access": "Obține acces complet la API",
"trial_feature_attribute_segmentation": "Segmentare bazată pe atribute", "trial_feature_collaboration": "Toate funcțiile de echipă și colaborare",
"trial_feature_contact_segment_management": "Gestionare contacte și segmente", "trial_feature_email_followups": "Configurează urmăriri prin email",
"trial_feature_email_followups": "Urmăriri prin email", "trial_feature_quotas": "Gestionează cotele",
"trial_feature_hide_branding": "Ascunde branding-ul Formbricks", "trial_feature_webhooks": "Configurează webhook-uri personalizate",
"trial_feature_mobile_sdks": "SDK-uri iOS și Android", "trial_feature_whitelabel": "Chestionare complet personalizate (white-label)",
"trial_feature_respondent_identification": "Identificarea respondenților",
"trial_feature_unlimited_seats": "Locuri nelimitate",
"trial_feature_webhooks": "Webhook-uri personalizate",
"trial_no_credit_card": "14 zile de probă, fără card necesar", "trial_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": "Încearcă funcțiile Pro gratuit!",
"trial_title": "Obține Formbricks Pro gratuit!",
"unlimited_responses": "Răspunsuri nelimitate", "unlimited_responses": "Răspunsuri nelimitate",
"unlimited_workspaces": "Workspaces nelimitate", "unlimited_workspaces": "Workspaces nelimitate",
"upgrade": "Actualizare", "upgrade": "Actualizare",
"upgrade_now": "Actualizează acum",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "utilizat", "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" "your_plan": "Planul tău"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Funcții Enterprise", "enterprise_features": "Funcții Enterprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.", "get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.", "keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
"license_feature_access_control": "Control acces (RBAC)",
"license_feature_audit_logs": "Jurnale de audit",
"license_feature_contacts": "Contacte și segmente",
"license_feature_projects": "Spații de lucru",
"license_feature_quotas": "Cote",
"license_feature_remove_branding": "Elimină branding-ul",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protecție spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autentificare cu doi factori",
"license_feature_whitelabel": "E-mailuri white-label",
"license_features_table_access": "Acces",
"license_features_table_description": "Funcționalități și limite enterprise disponibile în prezent pentru această instanță.",
"license_features_table_disabled": "Dezactivat",
"license_features_table_enabled": "Activat",
"license_features_table_feature": "Funcționalitate",
"license_features_table_title": "Funcționalități licențiate",
"license_features_table_unlimited": "Nelimitat",
"license_features_table_value": "Valoare",
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.", "license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
"license_status": "Stare licență", "license_status": "Stare licență",
"license_status_active": "Activă", "license_status_active": "Activă",
"license_status_description": "Starea licenței tale enterprise.", "license_status_description": "Starea licenței tale enterprise.",
"license_status_expired": "Expirată", "license_status_expired": "Expirată",
"license_status_instance_mismatch": "Asociată cu Altă Instanță",
"license_status_invalid": "Licență invalidă", "license_status_invalid": "Licență invalidă",
"license_status_unreachable": "Indisponibilă", "license_status_unreachable": "Indisponibilă",
"license_unreachable_grace_period": "Serverul de licențe nu poate fi contactat. Funcționalitățile enterprise rămân active timp de 3 zile, până la data de {gracePeriodEnd}.", "license_unreachable_grace_period": "Serverul de licențe nu poate fi contactat. Funcționalitățile enterprise rămân active timp de 3 zile, până la data de {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Întrebări? Vă rugăm să trimiteți mesaj către", "questions_please_reach_out_to": "Întrebări? Vă rugăm să trimiteți mesaj către",
"recheck_license": "Verifică din nou licența", "recheck_license": "Verifică din nou licența",
"recheck_license_failed": "Verificarea licenței a eșuat. Serverul de licențe poate fi indisponibil.", "recheck_license_failed": "Verificarea licenței a eșuat. Serverul de licențe poate fi indisponibil.",
"recheck_license_instance_mismatch": "Această licență este asociată cu o altă instanță Formbricks. Solicită echipei de suport Formbricks să deconecteze asocierea anterioară.",
"recheck_license_invalid": "Cheia de licență este invalidă. Te rugăm să verifici variabila ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "Cheia de licență este invalidă. Te rugăm să verifici variabila ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Licența a fost verificată cu succes", "recheck_license_success": "Licența a fost verificată cu succes",
"recheck_license_unreachable": "Serverul de licențe este indisponibil. Te rugăm să încerci din nou mai târziu.", "recheck_license_unreachable": "Serverul de licențe este indisponibil. Te rugăm să încerci din nou mai târziu.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Eroare la salvarea modificărilor", "error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
"everyone": "Toată lumea", "everyone": "Toată lumea",
"expand_preview": "Extinde previzualizarea",
"external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.", "external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.",
"fallback_missing": "Rezerva lipsă", "fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.", "spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.",
"spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.", "spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
"spam_protection_threshold_heading": "Pragul răspunsurilor", "spam_protection_threshold_heading": "Pragul răspunsurilor",
"shrink_preview": "Restrânge previzualizarea",
"star": "Stea", "star": "Stea",
"starts_with": "Începe cu", "starts_with": "Începe cu",
"state": "Stare", "state": "Stare",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei", "styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
"subheading": "Subtitlu", "subheading": "Subtitlu",
"subtract": "Scade -", "subtract": "Scade -",
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
"survey_completed_heading": "Sondaj Completat", "survey_completed_heading": "Sondaj Completat",
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis", "survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
"survey_display_settings": "Setări de afișare a sondajului", "survey_display_settings": "Setări de afișare a sondajului",
"survey_placement": "Amplasarea sondajului", "survey_placement": "Amplasarea sondajului",
"survey_preview": "Previzualizare chestionar 👀",
"survey_styling": "Stilizare formular", "survey_styling": "Stilizare formular",
"survey_trigger": "Declanșator sondaj", "survey_trigger": "Declanșator sondaj",
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉", "switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!", "preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?", "preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.", "preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
"preview_survey_question_open_text_headline": "Mai aveți ceva de adăugat?", "preview_survey_question_open_text_headline": "Mai vrei să împărtășești ceva?",
"preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...", "preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...",
"preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.", "preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
"preview_survey_welcome_card_headline": "Bun venit!", "preview_survey_welcome_card_headline": "Bun venit!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.", "coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
"coming_soon_title": "Suntem aproape gata!", "coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ați dori să adăugi?", "follow_up_label": "Mai este ceva ce ai vrea să adaugi?",
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?", "follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
"generate_button": "Generează workflow", "generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?", "heading": "Ce workflow vrei să creezi?",
+18 -86
View File
@@ -294,7 +294,6 @@
"new": "Новый", "new": "Новый",
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!", "new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
"next": "Далее", "next": "Далее",
"no_actions_found": "Действия не найдены",
"no_background_image_found": "Фоновое изображение не найдено.", "no_background_image_found": "Фоновое изображение не найдено.",
"no_code": "Нет кода", "no_code": "Нет кода",
"no_files_uploaded": "Файлы не были загружены", "no_files_uploaded": "Файлы не были загружены",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос", "please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер", "please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план", "please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр", "preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса", "preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности", "privacy": "Политика конфиденциальности",
@@ -436,9 +434,6 @@
"title": "Заголовок", "title": "Заголовок",
"top_left": "Вверху слева", "top_left": "Вверху слева",
"top_right": "Вверху справа", "top_right": "Вверху справа",
"trial_days_remaining": "{count, plural, one {Остался # день пробного периода} few {Осталось # дня пробного периода} many {Осталось # дней пробного периода} other {Осталось # дней пробного периода}}",
"trial_expired": "Пробный период истёк",
"trial_one_day_remaining": "Остался 1 день пробного периода",
"try_again": "Попробуйте ещё раз", "try_again": "Попробуйте ещё раз",
"type": "Тип", "type": "Тип",
"unknown_survey": "Неизвестный опрос", "unknown_survey": "Неизвестный опрос",
@@ -446,7 +441,6 @@
"update": "Обновить", "update": "Обновить",
"updated": "Обновлено", "updated": "Обновлено",
"updated_at": "Обновлено", "updated_at": "Обновлено",
"upgrade_plan": "Перейти на другой тариф",
"upload": "Загрузить", "upload": "Загрузить",
"upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.", "upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.",
"upload_input_description": "Кликните или перетащите файлы для загрузки.", "upload_input_description": "Кликните или перетащите файлы для загрузки.",
@@ -974,80 +968,44 @@
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks" "api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
}, },
"billing": { "billing": {
"add_payment_method": "Добавить способ оплаты", "cancelling": "Отмена",
"add_payment_method_to_upgrade_tooltip": "Пожалуйста, добавьте способ оплаты выше, чтобы перейти на платный тариф",
"billing_interval_toggle": "Интервал выставления счетов",
"current_plan_badge": "Текущий",
"current_plan_cta": "Текущий тариф",
"custom_plan_description": "Ваша организация использует индивидуальные настройки оплаты. Вы все равно можете переключиться на один из стандартных тарифов ниже.",
"custom_plan_title": "Индивидуальный тариф",
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.", "failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
"keep_current_plan": "Оставить текущий тариф", "manage_subscription": "Управление подпиской",
"manage_billing_details": "Управление данными карты и счетами",
"monthly": "Ежемесячно",
"most_popular": "Самый популярный",
"pending_change_removed": "Запланированное изменение тарифа отменено.",
"pending_plan_badge": "Запланирован",
"pending_plan_change_description": "Ваш тариф изменится на {{plan}} {{date}}.",
"pending_plan_change_title": "Запланированное изменение тарифа",
"pending_plan_cta": "Запланирован",
"per_month": "в месяц",
"per_year": "в год",
"plan_change_applied": "Тариф успешно обновлен.",
"plan_change_scheduled": "Изменение тарифа успешно запланировано.",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Все возможности Hobby",
"plan_feature_everything_in_pro": "Все возможности Pro",
"plan_hobby": "Хобби", "plan_hobby": "Хобби",
"plan_hobby_description": "Для частных лиц и небольших команд, начинающих работу с Formbricks Cloud.",
"plan_hobby_feature_responses": "250 ответов в месяц",
"plan_hobby_feature_workspaces": "1 рабочее пространство",
"plan_pro": "Pro", "plan_pro": "Pro",
"plan_pro_description": "Для растущих команд, которым нужны более высокие лимиты, автоматизация и динамические дополнительные ресурсы.",
"plan_pro_feature_responses": "2 000 ответов в месяц (динамическое превышение)",
"plan_pro_feature_workspaces": "3 рабочих пространства",
"plan_scale": "Scale", "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": "Неизвестно", "plan_unknown": "Неизвестно",
"remove_branding": "Удалить брендинг", "remove_branding": "Удалить брендинг",
"retry_setup": "Повторить настройку", "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_subtitle": "Кредитная карта не требуется, никаких обязательств.",
"select_plan_header_title": "Бесшовно интегрированные опросы, 100% ваш бренд.", "select_plan_header_title": "Создавайте профессиональные опросы без брендинга уже сегодня!",
"status_trialing": "Пробный", "status_trialing": "Пробный",
"stay_on_hobby_plan": "Я хочу остаться на тарифе Hobby", "stay_on_hobby_plan": "Я хочу остаться на тарифе Hobby",
"stripe_setup_incomplete": "Настройка оплаты не завершена", "stripe_setup_incomplete": "Настройка оплаты не завершена",
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.", "stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
"subscription": "Подписка", "subscription": "Подписка",
"subscription_description": "Управляйте своим тарифом и следите за использованием", "subscription_description": "Управляйте своим тарифом и следите за использованием",
"switch_at_period_end": "Переключить в конце периода",
"switch_plan_now": "Переключить план сейчас",
"this_includes": "Это включает",
"trial_alert_description": "Добавьте способ оплаты, чтобы сохранить доступ ко всем функциям.",
"trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.", "trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.",
"trial_feature_api_access": "Доступ к API", "trial_feature_api_access": "Получите полный доступ к API",
"trial_feature_attribute_segmentation": "Сегментация на основе атрибутов", "trial_feature_collaboration": "Все функции для работы в команде и совместной работы",
"trial_feature_contact_segment_management": "Управление контактами и сегментами", "trial_feature_email_followups": "Настройте последующие письма",
"trial_feature_email_followups": "Email-уведомления", "trial_feature_quotas": "Управляйте квотами",
"trial_feature_hide_branding": "Скрыть брендинг Formbricks", "trial_feature_webhooks": "Настройте собственные вебхуки",
"trial_feature_mobile_sdks": "iOS и Android SDK", "trial_feature_whitelabel": "Полностью персонализированные опросы без брендинга",
"trial_feature_respondent_identification": "Идентификация респондентов",
"trial_feature_unlimited_seats": "Неограниченное количество мест",
"trial_feature_webhooks": "Пользовательские вебхуки",
"trial_no_credit_card": "14 дней пробного периода, кредитная карта не требуется", "trial_no_credit_card": "14 дней пробного периода, кредитная карта не требуется",
"trial_payment_method_added_description": "Всё готово! Твой тарифный план Pro продолжится автоматически после окончания пробного периода.", "trial_title": "Попробуйте Pro функции бесплатно!",
"trial_title": "Получите Formbricks Pro бесплатно!",
"unlimited_responses": "Неограниченное количество ответов", "unlimited_responses": "Неограниченное количество ответов",
"unlimited_workspaces": "Неограниченное количество рабочих пространств", "unlimited_workspaces": "Неограниченное количество рабочих пространств",
"upgrade": "Обновить", "upgrade": "Обновить",
"upgrade_now": "Обновить сейчас",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "использовано", "used": "использовано",
"yearly": "Годовой",
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
"your_plan": "Ваш тариф" "your_plan": "Ваш тариф"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Функции для предприятий", "enterprise_features": "Функции для предприятий",
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.", "get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.", "keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
"license_feature_access_control": "Управление доступом (RBAC)",
"license_feature_audit_logs": "Журналы аудита",
"license_feature_contacts": "Контакты и сегменты",
"license_feature_projects": "Рабочие пространства",
"license_feature_quotas": "Квоты",
"license_feature_remove_branding": "Удаление брендирования",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Защита от спама",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Двухфакторная аутентификация",
"license_feature_whitelabel": "Электронные письма без брендирования",
"license_features_table_access": "Доступ",
"license_features_table_description": "Корпоративные функции и ограничения, доступные для этого экземпляра.",
"license_features_table_disabled": "Отключено",
"license_features_table_enabled": "Включено",
"license_features_table_feature": "Функция",
"license_features_table_title": "Лицензированные функции",
"license_features_table_unlimited": "Без ограничений",
"license_features_table_value": "Значение",
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.", "license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
"license_status": "Статус лицензии", "license_status": "Статус лицензии",
"license_status_active": "Активна", "license_status_active": "Активна",
"license_status_description": "Статус вашей корпоративной лицензии.", "license_status_description": "Статус вашей корпоративной лицензии.",
"license_status_expired": "Срок действия истёк", "license_status_expired": "Срок действия истёк",
"license_status_instance_mismatch": "Привязана к другому экземпляру",
"license_status_invalid": "Недействительная лицензия", "license_status_invalid": "Недействительная лицензия",
"license_status_unreachable": "Недоступна", "license_status_unreachable": "Недоступна",
"license_unreachable_grace_period": "Не удаётся подключиться к серверу лицензий. Корпоративные функции останутся активными в течение 3-дневного льготного периода, который закончится {gracePeriodEnd}.", "license_unreachable_grace_period": "Не удаётся подключиться к серверу лицензий. Корпоративные функции останутся активными в течение 3-дневного льготного периода, который закончится {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Вопросы? Свяжитесь с", "questions_please_reach_out_to": "Вопросы? Свяжитесь с",
"recheck_license": "Проверить лицензию ещё раз", "recheck_license": "Проверить лицензию ещё раз",
"recheck_license_failed": "Не удалось проверить лицензию. Сервер лицензий может быть недоступен.", "recheck_license_failed": "Не удалось проверить лицензию. Сервер лицензий может быть недоступен.",
"recheck_license_instance_mismatch": "Эта лицензия привязана к другому экземпляру Formbricks. Обратитесь в службу поддержки Formbricks для отключения предыдущей привязки.",
"recheck_license_invalid": "Ключ лицензии недействителен. Пожалуйста, проверь свою переменную ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "Ключ лицензии недействителен. Пожалуйста, проверь свою переменную ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Проверка лицензии прошла успешно", "recheck_license_success": "Проверка лицензии прошла успешно",
"recheck_license_unreachable": "Сервер лицензий недоступен. Пожалуйста, попробуй позже.", "recheck_license_unreachable": "Сервер лицензий недоступен. Пожалуйста, попробуй позже.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Ошибка при сохранении изменений", "error_saving_changes": "Ошибка при сохранении изменений",
"even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).", "even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).",
"everyone": "Все", "everyone": "Все",
"expand_preview": "Развернуть предпросмотр",
"external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.", "external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.",
"fallback_missing": "Запасное значение отсутствует", "fallback_missing": "Запасное значение отсутствует",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.", "spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.", "spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
"spam_protection_threshold_heading": "Порог ответа", "spam_protection_threshold_heading": "Порог ответа",
"shrink_preview": "Свернуть предпросмотр",
"star": "Звезда", "star": "Звезда",
"starts_with": "Начинается с", "starts_with": "Начинается с",
"state": "Состояние", "state": "Состояние",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой", "styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
"subheading": "Подзаголовок", "subheading": "Подзаголовок",
"subtract": "Вычесть -", "subtract": "Вычесть -",
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
"survey_completed_heading": "Опрос завершён", "survey_completed_heading": "Опрос завершён",
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт", "survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
"survey_display_settings": "Настройки отображения опроса", "survey_display_settings": "Настройки отображения опроса",
"survey_placement": "Размещение опроса", "survey_placement": "Размещение опроса",
"survey_preview": "Предпросмотр опроса 👀",
"survey_styling": "Оформление формы", "survey_styling": "Оформление формы",
"survey_trigger": "Триггер опроса", "survey_trigger": "Триггер опроса",
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉", "switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Нет, спасибо!", "preview_survey_question_2_choice_2_label": "Нет, спасибо!",
"preview_survey_question_2_headline": "Хотите быть в курсе событий?", "preview_survey_question_2_headline": "Хотите быть в курсе событий?",
"preview_survey_question_2_subheader": "Это пример описания.", "preview_survey_question_2_subheader": "Это пример описания.",
"preview_survey_question_open_text_headline": "Хотите ли вы чем-то ещё поделиться?", "preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?",
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...", "preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.", "preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
"preview_survey_welcome_card_headline": "Добро пожаловать!", "preview_survey_welcome_card_headline": "Добро пожаловать!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.", "coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!", "coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хотите ли вы что-нибудь добавить?", "follow_up_label": "Хочешь что-то ещё добавить?",
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?", "follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
"generate_button": "Сгенерировать воркфлоу", "generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?", "heading": "Какой воркфлоу ты хочешь создать?",
+18 -86
View File
@@ -294,7 +294,6 @@
"new": "Ny", "new": "Ny",
"new_version_available": "Formbricks {version} är här. Uppgradera nu!", "new_version_available": "Formbricks {version} är här. Uppgradera nu!",
"next": "Nästa", "next": "Nästa",
"no_actions_found": "Inga åtgärder hittades",
"no_background_image_found": "Ingen bakgrundsbild hittades.", "no_background_image_found": "Ingen bakgrundsbild hittades.",
"no_code": "Ingen kod", "no_code": "Ingen kod",
"no_files_uploaded": "Inga filer laddades upp", "no_files_uploaded": "Inga filer laddades upp",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Vänligen välj minst en enkät", "please_select_at_least_one_survey": "Vänligen välj minst en enkät",
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare", "please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
"please_upgrade_your_plan": "Vänligen uppgradera din plan", "please_upgrade_your_plan": "Vänligen uppgradera din plan",
"powered_by_formbricks": "Drivs av Formbricks",
"preview": "Förhandsgranska", "preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät", "preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy", "privacy": "Integritetspolicy",
@@ -436,9 +434,6 @@
"title": "Titel", "title": "Titel",
"top_left": "Övre vänster", "top_left": "Övre vänster",
"top_right": "Övre höger", "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", "try_again": "Försök igen",
"type": "Typ", "type": "Typ",
"unknown_survey": "Okänd enkät", "unknown_survey": "Okänd enkät",
@@ -446,7 +441,6 @@
"update": "Uppdatera", "update": "Uppdatera",
"updated": "Uppdaterad", "updated": "Uppdaterad",
"updated_at": "Uppdaterad", "updated_at": "Uppdaterad",
"upgrade_plan": "Uppgradera plan",
"upload": "Ladda upp", "upload": "Ladda upp",
"upload_failed": "Uppladdning misslyckades. Vänligen försök igen.", "upload_failed": "Uppladdning misslyckades. Vänligen försök igen.",
"upload_input_description": "Klicka eller dra för att ladda upp filer.", "upload_input_description": "Klicka eller dra för att ladda upp filer.",
@@ -974,80 +968,44 @@
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er" "api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
}, },
"billing": { "billing": {
"add_payment_method": "Lägg till betalningsmetod", "cancelling": "Avbryter",
"add_payment_method_to_upgrade_tooltip": "Lägg till en betalningsmetod ovan för att uppgradera till en betald plan",
"billing_interval_toggle": "Faktureringsintervall",
"current_plan_badge": "Nuvarande",
"current_plan_cta": "Nuvarande abonnemang",
"custom_plan_description": "Din organisation har en anpassad faktureringslösning. Du kan fortfarande byta till något av standardabonnemangen nedan.",
"custom_plan_title": "Anpassat abonnemang",
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.", "failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
"keep_current_plan": "Behåll nuvarande abonnemang", "manage_subscription": "Hantera prenumeration",
"manage_billing_details": "Hantera kortuppgifter & fakturor",
"monthly": "Månatlig",
"most_popular": "Mest populär",
"pending_change_removed": "Schemalagd abonnemangsändring har tagits bort.",
"pending_plan_badge": "Schemalagd",
"pending_plan_change_description": "Ditt abonnemang kommer att ändras till {{plan}} den {{date}}.",
"pending_plan_change_title": "Schemalagd abonnemangsändring",
"pending_plan_cta": "Schemalagd",
"per_month": "per månad",
"per_year": "per år",
"plan_change_applied": "Abonnemanget har uppdaterats.",
"plan_change_scheduled": "Abonnemangsändring har schemalagts.",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Allt i Hobby",
"plan_feature_everything_in_pro": "Allt i Pro",
"plan_hobby": "Hobby", "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": "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": "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", "plan_unknown": "Okänd",
"remove_branding": "Ta bort varumärke", "remove_branding": "Ta bort varumärke",
"retry_setup": "Försök igen med inställningen", "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_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", "status_trialing": "Testperiod",
"stay_on_hobby_plan": "Jag vill behålla Hobby-planen", "stay_on_hobby_plan": "Jag vill behålla Hobby-planen",
"stripe_setup_incomplete": "Faktureringsinställningar ofullständiga", "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.", "stripe_setup_incomplete_description": "Faktureringsinställningen slutfördes inte riktigt. Försök igen för att aktivera ditt abonnemang.",
"subscription": "Abonnemang", "subscription": "Abonnemang",
"subscription_description": "Hantera din abonnemangsplan och följ din användning", "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_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_api_access": "Få full API-åtkomst",
"trial_feature_attribute_segmentation": "Attributbaserad segmentering", "trial_feature_collaboration": "Alla team- och samarbetsfunktioner",
"trial_feature_contact_segment_management": "Kontakt- och segmenthantering", "trial_feature_email_followups": "Konfigurera uppföljningsmejl",
"trial_feature_email_followups": "E-postuppföljningar", "trial_feature_quotas": "Hantera kvoter",
"trial_feature_hide_branding": "Dölj Formbricks-branding", "trial_feature_webhooks": "Konfigurera anpassade webhooks",
"trial_feature_mobile_sdks": "iOS- och Android-SDK:er", "trial_feature_whitelabel": "Helt white-label-anpassade enkäter",
"trial_feature_respondent_identification": "Respondentidentifiering",
"trial_feature_unlimited_seats": "Obegränsade platser",
"trial_feature_webhooks": "Anpassade webhooks",
"trial_no_credit_card": "14 dagars provperiod, inget kreditkort krävs", "trial_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": "Testa Pro-funktioner gratis!",
"trial_title": "Få Formbricks Pro gratis!",
"unlimited_responses": "Obegränsade svar", "unlimited_responses": "Obegränsade svar",
"unlimited_workspaces": "Obegränsat antal arbetsytor", "unlimited_workspaces": "Obegränsat antal arbetsytor",
"upgrade": "Uppgradera", "upgrade": "Uppgradera",
"upgrade_now": "Uppgradera nu",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "använt", "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" "your_plan": "Din plan"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "Enterprise-funktioner", "enterprise_features": "Enterprise-funktioner",
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.", "get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.", "keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
"license_feature_access_control": "Åtkomstkontroll (RBAC)",
"license_feature_audit_logs": "Granskningsloggar",
"license_feature_contacts": "Kontakter & Segment",
"license_feature_projects": "Arbetsytor",
"license_feature_quotas": "Kvoter",
"license_feature_remove_branding": "Ta bort varumärkning",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Skräppostskydd",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tvåfaktorsautentisering",
"license_feature_whitelabel": "White-label-mejl",
"license_features_table_access": "Åtkomst",
"license_features_table_description": "Företagsfunktioner och begränsningar som för närvarande är tillgängliga för den här instansen.",
"license_features_table_disabled": "Inaktiverad",
"license_features_table_enabled": "Aktiverad",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Licensierade funktioner",
"license_features_table_unlimited": "Obegränsad",
"license_features_table_value": "Värde",
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.", "license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
"license_status": "Licensstatus", "license_status": "Licensstatus",
"license_status_active": "Aktiv", "license_status_active": "Aktiv",
"license_status_description": "Status för din företagslicens.", "license_status_description": "Status för din företagslicens.",
"license_status_expired": "Utgången", "license_status_expired": "Utgången",
"license_status_instance_mismatch": "Kopplad till en annan instans",
"license_status_invalid": "Ogiltig licens", "license_status_invalid": "Ogiltig licens",
"license_status_unreachable": "Otillgänglig", "license_status_unreachable": "Otillgänglig",
"license_unreachable_grace_period": "Licensservern kan inte nås. Dina enterprise-funktioner är aktiva under en 3-dagars respitperiod som slutar {gracePeriodEnd}.", "license_unreachable_grace_period": "Licensservern kan inte nås. Dina enterprise-funktioner är aktiva under en 3-dagars respitperiod som slutar {gracePeriodEnd}.",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "Frågor? Kontakta", "questions_please_reach_out_to": "Frågor? Kontakta",
"recheck_license": "Kontrollera licensen igen", "recheck_license": "Kontrollera licensen igen",
"recheck_license_failed": "Licenskontrollen misslyckades. Licensservern kan vara otillgänglig.", "recheck_license_failed": "Licenskontrollen misslyckades. Licensservern kan vara otillgänglig.",
"recheck_license_instance_mismatch": "Den här licensen är kopplad till en annan Formbricks-instans. Be Formbricks support att koppla bort den tidigare bindningen.",
"recheck_license_invalid": "Licensnyckeln är ogiltig. Kontrollera din ENTERPRISE_LICENSE_KEY.", "recheck_license_invalid": "Licensnyckeln är ogiltig. Kontrollera din ENTERPRISE_LICENSE_KEY.",
"recheck_license_success": "Licenskontrollen lyckades", "recheck_license_success": "Licenskontrollen lyckades",
"recheck_license_unreachable": "Licensservern är otillgänglig. Försök igen senare.", "recheck_license_unreachable": "Licensservern är otillgänglig. Försök igen senare.",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "Fel vid sparande av ändringar", "error_saving_changes": "Fel vid sparande av ändringar",
"even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).", "even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).",
"everyone": "Alla", "everyone": "Alla",
"expand_preview": "Expandera förhandsgranskning",
"external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.", "external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
"fallback_missing": "Reservvärde saknas", "fallback_missing": "Reservvärde saknas",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.", "spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.",
"spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.", "spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.",
"spam_protection_threshold_heading": "Svarströskel", "spam_protection_threshold_heading": "Svarströskel",
"shrink_preview": "Minimera förhandsgranskning",
"star": "Stjärna", "star": "Stjärna",
"starts_with": "Börjar med", "starts_with": "Börjar med",
"state": "Delstat", "state": "Delstat",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "Styling inställd på temastil", "styling_set_to_theme_styles": "Styling inställd på temastil",
"subheading": "Underrubrik", "subheading": "Underrubrik",
"subtract": "Subtrahera -", "subtract": "Subtrahera -",
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
"survey_completed_heading": "Enkät slutförd", "survey_completed_heading": "Enkät slutförd",
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts", "survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
"survey_display_settings": "Visningsinställningar för enkät", "survey_display_settings": "Visningsinställningar för enkät",
"survey_placement": "Enkätplacering", "survey_placement": "Enkätplacering",
"survey_preview": "Enkätförhandsgranskning 👀",
"survey_styling": "Formulärstil", "survey_styling": "Formulärstil",
"survey_trigger": "Enkätutlösare", "survey_trigger": "Enkätutlösare",
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉", "switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "Nej, tack!", "preview_survey_question_2_choice_2_label": "Nej, tack!",
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?", "preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.", "preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
"preview_survey_question_open_text_headline": "Finns det något annat du vill dela med dig av?", "preview_survey_question_open_text_headline": "Något mer du vill dela med dig av?",
"preview_survey_question_open_text_placeholder": "Skriv ditt svar här...", "preview_survey_question_open_text_placeholder": "Skriv ditt svar här...",
"preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.", "preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.",
"preview_survey_welcome_card_headline": "Välkommen!", "preview_survey_welcome_card_headline": "Välkommen!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.", "coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
"coming_soon_title": "Vi är nästan där!", "coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Finns det något annat du vill lägga till?", "follow_up_label": "Är det något mer du vill lägga till?",
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?", "follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde", "generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?", "heading": "Vilket arbetsflöde vill du skapa?",
+19 -87
View File
@@ -294,7 +294,6 @@
"new": "新建", "new": "新建",
"new_version_available": "Formbricks {version} 在 这里。立即 升级!", "new_version_available": "Formbricks {version} 在 这里。立即 升级!",
"next": "下一步", "next": "下一步",
"no_actions_found": "未找到操作",
"no_background_image_found": "未找到 背景 图片。", "no_background_image_found": "未找到 背景 图片。",
"no_code": "无代码", "no_code": "无代码",
"no_files_uploaded": "没有 文件 被 上传", "no_files_uploaded": "没有 文件 被 上传",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "请选择至少 一个调查", "please_select_at_least_one_survey": "请选择至少 一个调查",
"please_select_at_least_one_trigger": "请选择至少 一个触发条件", "please_select_at_least_one_trigger": "请选择至少 一个触发条件",
"please_upgrade_your_plan": "请升级您的计划", "please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览", "preview": "预览",
"preview_survey": "预览 Survey", "preview_survey": "预览 Survey",
"privacy": "隐私政策", "privacy": "隐私政策",
@@ -401,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": "开始 免费试用",
"status": "状态", "status": "状态",
"step_by_step_manual": "分步 手册", "step_by_step_manual": "分步 手册",
"storage_not_configured": "文件存储 未设置,上传 可能 失败", "storage_not_configured": "文件存储 未设置,上传 可能 失败",
@@ -436,9 +434,6 @@
"title": "标题", "title": "标题",
"top_left": "左上", "top_left": "左上",
"top_right": "右上", "top_right": "右上",
"trial_days_remaining": "试用期还剩 {count} 天",
"trial_expired": "您的试用期已过期",
"trial_one_day_remaining": "试用期还剩 1 天",
"try_again": "再试一次", "try_again": "再试一次",
"type": "类型", "type": "类型",
"unknown_survey": "未知调查", "unknown_survey": "未知调查",
@@ -446,7 +441,6 @@
"update": "更新", "update": "更新",
"updated": "已更新", "updated": "已更新",
"updated_at": "更新 于", "updated_at": "更新 于",
"upgrade_plan": "升级套餐",
"upload": "上传", "upload": "上传",
"upload_failed": "上传失败,请重试。", "upload_failed": "上传失败,请重试。",
"upload_input_description": "点击 或 拖动 上传 文件", "upload_input_description": "点击 或 拖动 上传 文件",
@@ -974,80 +968,44 @@
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API" "api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
}, },
"billing": { "billing": {
"add_payment_method": "添加支付方式", "cancelling": "正在取消",
"add_payment_method_to_upgrade_tooltip": "请先在上方添加付款方式以升级到付费套餐",
"billing_interval_toggle": "账单周期",
"current_plan_badge": "当前",
"current_plan_cta": "当前方案",
"custom_plan_description": "您的组织使用的是自定义计费设置。您仍然可以切换到下面的标准方案。",
"custom_plan_title": "自定义方案",
"failed_to_start_trial": "试用启动失败,请重试。", "failed_to_start_trial": "试用启动失败,请重试。",
"keep_current_plan": "保持当前方案", "manage_subscription": "管理订阅",
"manage_billing_details": "管理卡片详情与发票",
"monthly": "按月",
"most_popular": "最受欢迎",
"pending_change_removed": "已取消预定的方案变更。",
"pending_plan_badge": "已预定",
"pending_plan_change_description": "您的方案将在 {{date}} 切换至 {{plan}}。",
"pending_plan_change_title": "预定的方案变更",
"pending_plan_cta": "已预定",
"per_month": "每月",
"per_year": "每年",
"plan_change_applied": "方案更新成功。",
"plan_change_scheduled": "方案变更预定成功。",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
"plan_hobby": "兴趣版", "plan_hobby": "兴趣版",
"plan_hobby_description": "适合开始使用 Formbricks Cloud 的个人和小团队。",
"plan_hobby_feature_responses": "250 条回复 / 月",
"plan_hobby_feature_workspaces": "1 个工作区",
"plan_pro": "专业版", "plan_pro": "专业版",
"plan_pro_description": "适合需要更高限额、自动化功能和动态超额使用的成长型团队。",
"plan_pro_feature_responses": "2,000 条回复 / 月(动态超额)",
"plan_pro_feature_workspaces": "3 个工作区",
"plan_scale": "规模版", "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": "未知", "plan_unknown": "未知",
"remove_branding": "移除 品牌", "remove_branding": "移除 品牌",
"retry_setup": "重试设置", "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_subtitle": "无需信用卡,没有任何附加条件。",
"select_plan_header_title": "无缝集成的调查问卷,100% 展现您的品牌。", "select_plan_header_title": "立即发布专业的无品牌调查!",
"status_trialing": "试用版", "status_trialing": "试用版",
"stay_on_hobby_plan": "我想继续使用免费版计划", "stay_on_hobby_plan": "我想继续使用免费版计划",
"stripe_setup_incomplete": "账单设置未完成", "stripe_setup_incomplete": "账单设置未完成",
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。", "stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
"subscription": "订阅", "subscription": "订阅",
"subscription_description": "管理你的订阅套餐并监控用量", "subscription_description": "管理你的订阅套餐并监控用量",
"switch_at_period_end": "在周期结束时切换",
"switch_plan_now": "立即切换套餐",
"this_includes": "包含以下内容",
"trial_alert_description": "添加支付方式以继续使用所有功能。",
"trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。", "trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。",
"trial_feature_api_access": "API 访问", "trial_feature_api_access": "获取完整 API 访问权限",
"trial_feature_attribute_segmentation": "基于属性的细分", "trial_feature_collaboration": "所有团队和协作功能",
"trial_feature_contact_segment_management": "联系人和细分管理", "trial_feature_email_followups": "设置邮件跟进",
"trial_feature_email_followups": "电子邮件跟进", "trial_feature_quotas": "管理配额",
"trial_feature_hide_branding": "隐藏 Formbricks 品牌标识", "trial_feature_webhooks": "设置自定义 Webhook",
"trial_feature_mobile_sdks": "iOS 和 Android SDK", "trial_feature_whitelabel": "完全白标化的问卷调查",
"trial_feature_respondent_identification": "受访者识别",
"trial_feature_unlimited_seats": "无限席位",
"trial_feature_webhooks": "自定义 Webhook",
"trial_no_credit_card": "14 天试用,无需信用卡", "trial_no_credit_card": "14 天试用,无需信用卡",
"trial_payment_method_added_description": "一切就绪!试用期结束后,您的专业版计划将自动继续。", "trial_title": "免费试用专业版功能!",
"trial_title": "免费获取 Formbricks Pro!",
"unlimited_responses": "无限反馈", "unlimited_responses": "无限反馈",
"unlimited_workspaces": "无限工作区", "unlimited_workspaces": "无限工作区",
"upgrade": "升级", "upgrade": "升级",
"upgrade_now": "立即升级",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "已用", "used": "已用",
"yearly": "按年付费",
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
"your_plan": "你的套餐" "your_plan": "你的套餐"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "企业 功能", "enterprise_features": "企业 功能",
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。", "get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。", "keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
"license_feature_access_control": "访问控制(RBAC",
"license_feature_audit_logs": "审计日志",
"license_feature_contacts": "联系人与细分",
"license_feature_projects": "工作空间",
"license_feature_quotas": "配额",
"license_feature_remove_branding": "移除品牌标识",
"license_feature_saml": "SAML 单点登录",
"license_feature_spam_protection": "垃圾信息防护",
"license_feature_sso": "OIDC 单点登录",
"license_feature_two_factor_auth": "双因素认证",
"license_feature_whitelabel": "白标电子邮件",
"license_features_table_access": "访问权限",
"license_features_table_description": "此实例当前可用的企业功能和限制。",
"license_features_table_disabled": "已禁用",
"license_features_table_enabled": "已启用",
"license_features_table_feature": "功能",
"license_features_table_title": "许可功能",
"license_features_table_unlimited": "无限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。", "license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
"license_status": "许可证状态", "license_status": "许可证状态",
"license_status_active": "已激活", "license_status_active": "已激活",
"license_status_description": "你的企业许可证状态。", "license_status_description": "你的企业许可证状态。",
"license_status_expired": "已过期", "license_status_expired": "已过期",
"license_status_instance_mismatch": "已绑定到其他实例",
"license_status_invalid": "许可证无效", "license_status_invalid": "许可证无效",
"license_status_unreachable": "无法访问", "license_status_unreachable": "无法访问",
"license_unreachable_grace_period": "无法连接到许可证服务器。在为期 3 天的宽限期内,你的企业功能仍然可用,宽限期将于 {gracePeriodEnd} 结束。", "license_unreachable_grace_period": "无法连接到许可证服务器。在为期 3 天的宽限期内,你的企业功能仍然可用,宽限期将于 {gracePeriodEnd} 结束。",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "问题 请 联系", "questions_please_reach_out_to": "问题 请 联系",
"recheck_license": "重新检查许可证", "recheck_license": "重新检查许可证",
"recheck_license_failed": "许可证检查失败。许可证服务器可能无法访问。", "recheck_license_failed": "许可证检查失败。许可证服务器可能无法访问。",
"recheck_license_instance_mismatch": "此许可证已绑定到另一个 Formbricks 实例。请联系 Formbricks 支持团队解除先前的绑定。",
"recheck_license_invalid": "许可证密钥无效。请确认你的 ENTERPRISE_LICENSE_KEY。", "recheck_license_invalid": "许可证密钥无效。请确认你的 ENTERPRISE_LICENSE_KEY。",
"recheck_license_success": "许可证检查成功", "recheck_license_success": "许可证检查成功",
"recheck_license_unreachable": "许可证服务器无法访问,请稍后再试。", "recheck_license_unreachable": "许可证服务器无法访问,请稍后再试。",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "保存 更改 时 出错", "error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。", "even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
"everyone": "所有 人", "everyone": "所有 人",
"expand_preview": "展开预览",
"external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。", "external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。",
"fallback_missing": "备用 缺失", "fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。", "spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。", "spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
"spam_protection_threshold_heading": "响应 阈值", "spam_protection_threshold_heading": "响应 阈值",
"shrink_preview": "收起预览",
"star": "星", "star": "星",
"starts_with": "以...开始", "starts_with": "以...开始",
"state": "状态", "state": "状态",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "样式 设置 为 主题 风格", "styling_set_to_theme_styles": "样式 设置 为 主题 风格",
"subheading": "子标题", "subheading": "子标题",
"subtract": "减 -", "subtract": "减 -",
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
"survey_completed_heading": "调查 完成", "survey_completed_heading": "调查 完成",
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭", "survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
"survey_display_settings": "调查显示设置", "survey_display_settings": "调查显示设置",
"survey_placement": "调查 放置", "survey_placement": "调查 放置",
"survey_preview": "问卷预览 👀",
"survey_styling": "表单 样式", "survey_styling": "表单 样式",
"survey_trigger": "调查 触发", "survey_trigger": "调查 触发",
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉", "switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!", "preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?", "preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_question_2_subheader": "这是一个示例描述。", "preview_survey_question_2_subheader": "这是一个示例描述。",
"preview_survey_question_open_text_headline": "还有其他想分享的内容吗?", "preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?",
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...", "preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。", "preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
"preview_survey_welcome_card_headline": "欢迎!", "preview_survey_welcome_card_headline": "欢迎!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。", "coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!", "coming_soon_title": "我们快完成啦!",
"follow_up_label": "还有其他想补充的内容吗?", "follow_up_label": "还有其他想补充的吗?",
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?", "follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
"generate_button": "生成工作流", "generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?", "heading": "你想创建什么样的工作流?",
+18 -86
View File
@@ -294,7 +294,6 @@
"new": "新增", "new": "新增",
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!", "new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
"next": "下一步", "next": "下一步",
"no_actions_found": "找不到動作",
"no_background_image_found": "找不到背景圖片。", "no_background_image_found": "找不到背景圖片。",
"no_code": "無程式碼", "no_code": "無程式碼",
"no_files_uploaded": "沒有上傳任何檔案", "no_files_uploaded": "沒有上傳任何檔案",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "請選擇至少一個問卷", "please_select_at_least_one_survey": "請選擇至少一個問卷",
"please_select_at_least_one_trigger": "請選擇至少一個觸發器", "please_select_at_least_one_trigger": "請選擇至少一個觸發器",
"please_upgrade_your_plan": "請升級您的方案", "please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽", "preview": "預覽",
"preview_survey": "預覽問卷", "preview_survey": "預覽問卷",
"privacy": "隱私權政策", "privacy": "隱私權政策",
@@ -436,9 +434,6 @@
"title": "標題", "title": "標題",
"top_left": "左上", "top_left": "左上",
"top_right": "右上", "top_right": "右上",
"trial_days_remaining": "試用期剩餘 {count} 天",
"trial_expired": "您的試用期已結束",
"trial_one_day_remaining": "試用期剩餘 1 天",
"try_again": "再試一次", "try_again": "再試一次",
"type": "類型", "type": "類型",
"unknown_survey": "未知問卷", "unknown_survey": "未知問卷",
@@ -446,7 +441,6 @@
"update": "更新", "update": "更新",
"updated": "已更新", "updated": "已更新",
"updated_at": "更新時間", "updated_at": "更新時間",
"upgrade_plan": "升級方案",
"upload": "上傳", "upload": "上傳",
"upload_failed": "上傳失敗。請再試一次。", "upload_failed": "上傳失敗。請再試一次。",
"upload_input_description": "點擊或拖曳以上傳檔案。", "upload_input_description": "點擊或拖曳以上傳檔案。",
@@ -974,80 +968,44 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API" "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
}, },
"billing": { "billing": {
"add_payment_method": "新增付款方式", "cancelling": "正在取消",
"add_payment_method_to_upgrade_tooltip": "請先在上方新增付款方式以升級至付費方案",
"billing_interval_toggle": "帳單週期",
"current_plan_badge": "目前",
"current_plan_cta": "目前方案",
"custom_plan_description": "您的組織使用自訂計費設定。您仍可切換至下方的標準方案。",
"custom_plan_title": "自訂方案",
"failed_to_start_trial": "無法開始試用。請再試一次。", "failed_to_start_trial": "無法開始試用。請再試一次。",
"keep_current_plan": "保留目前方案", "manage_subscription": "管理訂閱",
"manage_billing_details": "管理卡片資訊與發票",
"monthly": "每月",
"most_popular": "最受歡迎",
"pending_change_removed": "已取消預定的方案變更。",
"pending_plan_badge": "已排程",
"pending_plan_change_description": "您的方案將於 {{date}} 切換至 {{plan}}。",
"pending_plan_change_title": "已排程的方案變更",
"pending_plan_cta": "已排程",
"per_month": "每月",
"per_year": "每年",
"plan_change_applied": "方案更新成功。",
"plan_change_scheduled": "方案變更已成功排程。",
"plan_custom": "Custom", "plan_custom": "Custom",
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
"plan_hobby": "興趣版", "plan_hobby": "興趣版",
"plan_hobby_description": "適合個人與小型團隊開始使用 Formbricks Cloud。",
"plan_hobby_feature_responses": "每月 250 次回應",
"plan_hobby_feature_workspaces": "1 個工作區",
"plan_pro": "專業版", "plan_pro": "專業版",
"plan_pro_description": "適合需要更高限制、自動化功能和彈性超量使用的成長中團隊。",
"plan_pro_feature_responses": "每月 2,000 次回應(動態超量計費)",
"plan_pro_feature_workspaces": "3 個工作區",
"plan_scale": "規模版", "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": "未知", "plan_unknown": "未知",
"remove_branding": "移除品牌", "remove_branding": "移除品牌",
"retry_setup": "重新設定", "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_subtitle": "無需信用卡,完全沒有附加條件。",
"select_plan_header_title": "完美整合的問卷調查,100% 展現你的品牌。", "select_plan_header_title": "立即發送專業、無品牌標記的問卷調查!",
"status_trialing": "試用版", "status_trialing": "試用版",
"stay_on_hobby_plan": "我想繼續使用 Hobby 方案", "stay_on_hobby_plan": "我想繼續使用 Hobby 方案",
"stripe_setup_incomplete": "帳單設定尚未完成", "stripe_setup_incomplete": "帳單設定尚未完成",
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。", "stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
"subscription": "訂閱", "subscription": "訂閱",
"subscription_description": "管理您的訂閱方案並監控用量", "subscription_description": "管理您的訂閱方案並監控用量",
"switch_at_period_end": "週期結束時切換",
"switch_plan_now": "立即切換方案",
"this_includes": "包含內容",
"trial_alert_description": "新增付款方式以繼續使用所有功能。",
"trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。", "trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。",
"trial_feature_api_access": "API 存取", "trial_feature_api_access": "獲得完整 API 存取權限",
"trial_feature_attribute_segmentation": "基於屬性的分群", "trial_feature_collaboration": "所有團隊與協作功能",
"trial_feature_contact_segment_management": "聯絡人與分群管理", "trial_feature_email_followups": "設定電子郵件追蹤",
"trial_feature_email_followups": "電子郵件追蹤", "trial_feature_quotas": "管理配額",
"trial_feature_hide_branding": "隱藏 Formbricks 品牌標識", "trial_feature_webhooks": "設定自訂 Webhook",
"trial_feature_mobile_sdks": "iOS 與 Android SDK", "trial_feature_whitelabel": "完全白標問卷調查",
"trial_feature_respondent_identification": "受訪者識別",
"trial_feature_unlimited_seats": "無限座位數",
"trial_feature_webhooks": "自訂 Webhook",
"trial_no_credit_card": "14 天試用,無需信用卡", "trial_no_credit_card": "14 天試用,無需信用卡",
"trial_payment_method_added_description": "一切就緒!試用期結束後,您的 Pro 方案將自動繼續。", "trial_title": "免費試用 Pro 功能!",
"trial_title": "免費獲得 Formbricks Pro",
"unlimited_responses": "無限回應", "unlimited_responses": "無限回應",
"unlimited_workspaces": "無限工作區", "unlimited_workspaces": "無限工作區",
"upgrade": "升級", "upgrade": "升級",
"upgrade_now": "立即升級",
"usage_cycle": "Usage cycle", "usage_cycle": "Usage cycle",
"used": "已使用", "used": "已使用",
"yearly": "年繳",
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
"your_plan": "您的方案" "your_plan": "您的方案"
}, },
"domain": { "domain": {
@@ -1073,32 +1031,11 @@
"enterprise_features": "企業版功能", "enterprise_features": "企業版功能",
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。", "get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。", "keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
"license_feature_access_control": "存取控制 (RBAC)",
"license_feature_audit_logs": "稽核日誌",
"license_feature_contacts": "聯絡人與區隔",
"license_feature_projects": "工作區",
"license_feature_quotas": "配額",
"license_feature_remove_branding": "移除品牌標識",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "垃圾訊息防護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "雙重驗證",
"license_feature_whitelabel": "白標電子郵件",
"license_features_table_access": "存取權限",
"license_features_table_description": "此執行個體目前可使用的企業功能與限制。",
"license_features_table_disabled": "已停用",
"license_features_table_enabled": "已啟用",
"license_features_table_feature": "功能",
"license_features_table_title": "授權功能",
"license_features_table_unlimited": "無限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。", "license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
"license_status": "授權狀態", "license_status": "授權狀態",
"license_status_active": "有效", "license_status_active": "有效",
"license_status_description": "你的企業授權狀態。", "license_status_description": "你的企業授權狀態。",
"license_status_expired": "已過期", "license_status_expired": "已過期",
"license_status_instance_mismatch": "已綁定至其他執行個體",
"license_status_invalid": "授權無效", "license_status_invalid": "授權無效",
"license_status_unreachable": "無法連線", "license_status_unreachable": "無法連線",
"license_unreachable_grace_period": "無法連線至授權伺服器。在 3 天的寬限期內,你的企業功能仍可使用,寬限期將於 {gracePeriodEnd} 結束。", "license_unreachable_grace_period": "無法連線至授權伺服器。在 3 天的寬限期內,你的企業功能仍可使用,寬限期將於 {gracePeriodEnd} 結束。",
@@ -1109,7 +1046,6 @@
"questions_please_reach_out_to": "有任何問題?請聯絡", "questions_please_reach_out_to": "有任何問題?請聯絡",
"recheck_license": "重新檢查授權", "recheck_license": "重新檢查授權",
"recheck_license_failed": "授權檢查失敗。授權伺服器可能無法連線。", "recheck_license_failed": "授權檢查失敗。授權伺服器可能無法連線。",
"recheck_license_instance_mismatch": "此授權已綁定至不同的 Formbricks 執行個體。請聯繫 Formbricks 支援以解除先前的綁定。",
"recheck_license_invalid": "授權金鑰無效。請確認你的 ENTERPRISE_LICENSE_KEY。", "recheck_license_invalid": "授權金鑰無效。請確認你的 ENTERPRISE_LICENSE_KEY。",
"recheck_license_success": "授權檢查成功", "recheck_license_success": "授權檢查成功",
"recheck_license_unreachable": "授權伺服器無法連線,請稍後再試。", "recheck_license_unreachable": "授權伺服器無法連線,請稍後再試。",
@@ -1451,7 +1387,6 @@
"error_saving_changes": "儲存變更時發生錯誤", "error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。", "even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
"everyone": "所有人", "everyone": "所有人",
"expand_preview": "展開預覽",
"external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。", "external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。",
"fallback_missing": "遺失的回退", "fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1711,7 +1646,6 @@
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。", "spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。", "spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
"spam_protection_threshold_heading": "回應閾值", "spam_protection_threshold_heading": "回應閾值",
"shrink_preview": "收合預覽",
"star": "星形", "star": "星形",
"starts_with": "開頭為", "starts_with": "開頭為",
"state": "州/省", "state": "州/省",
@@ -1721,12 +1655,10 @@
"styling_set_to_theme_styles": "樣式設定為主題樣式", "styling_set_to_theme_styles": "樣式設定為主題樣式",
"subheading": "副標題", "subheading": "副標題",
"subtract": "減 -", "subtract": "減 -",
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
"survey_completed_heading": "問卷已完成", "survey_completed_heading": "問卷已完成",
"survey_completed_subheading": "此免費且開源的問卷已關閉", "survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定", "survey_display_settings": "問卷顯示設定",
"survey_placement": "問卷位置", "survey_placement": "問卷位置",
"survey_preview": "問卷預覽 👀",
"survey_styling": "表單樣式設定", "survey_styling": "表單樣式設定",
"survey_trigger": "問卷觸發器", "survey_trigger": "問卷觸發器",
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉", "switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
@@ -3077,7 +3009,7 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!", "preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?", "preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_question_2_subheader": "這是一個範例說明。", "preview_survey_question_2_subheader": "這是一個範例說明。",
"preview_survey_question_open_text_headline": "還有其他想分享的嗎?", "preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎",
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...", "preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。", "preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
"preview_survey_welcome_card_headline": "歡迎!", "preview_survey_welcome_card_headline": "歡迎!",
@@ -3332,7 +3264,7 @@
"workflows": { "workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。", "coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!", "coming_soon_title": "快完成囉!",
"follow_up_label": "還有其他想補充的嗎?", "follow_up_label": "還有什麼想補充的嗎",
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?", "follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
"generate_button": "產生工作流程", "generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?", "heading": "你想建立什麼樣的工作流程?",
@@ -5,9 +5,7 @@ import { useTranslation } from "react-i18next";
import { TResponseData } from "@formbricks/types/responses"; import { TResponseData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall"; import { parseRecallInfo } from "@/lib/utils/recall";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -17,7 +15,6 @@ interface ElementSkipProps {
elements: TSurveyElement[]; elements: TSurveyElement[];
isFirstElementAnswered?: boolean; isFirstElementAnswered?: boolean;
responseData: TResponseData; responseData: TResponseData;
locale: TUserLocale;
} }
export const ElementSkip = ({ export const ElementSkip = ({
@@ -26,10 +23,8 @@ export const ElementSkip = ({
elements, elements,
isFirstElementAnswered, isFirstElementAnswered,
responseData, responseData,
locale,
}: ElementSkipProps) => { }: ElementSkipProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const dateFormats = getSurveyDateFormatMap(elements);
return ( return (
<div> <div>
{skippedElements && ( {skippedElements && (
@@ -86,11 +81,7 @@ export const ElementSkip = ({
}, },
"default" "default"
), ),
responseData, responseData
undefined,
false,
locale,
dateFormats
) )
)} )}
</p> </p>
@@ -129,11 +120,7 @@ export const ElementSkip = ({
}, },
"default" "default"
), ),
responseData, responseData
undefined,
false,
locale,
dateFormats
) )
)} )}
</p> </p>
@@ -3,12 +3,11 @@ import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses"; import { TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils"; import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils"; import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses"; import { processResponseData } from "@/lib/responses";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display"; import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { renderHyperlinkedContent } from "@/modules/analysis/utils"; import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response"; import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response"; import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
@@ -22,7 +21,6 @@ interface RenderResponseProps {
element: TSurveyElement; element: TSurveyElement;
survey: TSurvey; survey: TSurvey;
language: string | null; language: string | null;
locale: TUserLocale;
isExpanded?: boolean; isExpanded?: boolean;
showId: boolean; showId: boolean;
} }
@@ -32,7 +30,6 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
element, element,
survey, survey,
language, language,
locale,
isExpanded = true, isExpanded = true,
showId, showId,
}) => { }) => {
@@ -66,8 +63,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break; break;
case TSurveyElementTypeEnum.Date: case TSurveyElementTypeEnum.Date:
if (typeof responseData === "string") { if (typeof responseData === "string") {
const formattedDate = const parsedDate = new Date(responseData);
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>; return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
} }
@@ -6,9 +6,7 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall"; import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas"; import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -23,17 +21,14 @@ interface SingleResponseCardBodyProps {
survey: TSurvey; survey: TSurvey;
response: TResponseWithQuotas; response: TResponseWithQuotas;
skippedQuestions: string[][]; skippedQuestions: string[][];
locale: TUserLocale;
} }
export const SingleResponseCardBody = ({ export const SingleResponseCardBody = ({
survey, survey,
response, response,
skippedQuestions, skippedQuestions,
locale,
}: SingleResponseCardBodyProps) => { }: SingleResponseCardBodyProps) => {
const elements = getElementsFromBlocks(survey.blocks); const elements = getElementsFromBlocks(survey.blocks);
const dateFormats = getSurveyDateFormatMap(elements);
const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false; const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false;
const { t } = useTranslation(); const { t } = useTranslation();
const formatTextWithSlashes = (text: string) => { const formatTextWithSlashes = (text: string) => {
@@ -66,7 +61,6 @@ export const SingleResponseCardBody = ({
status={"welcomeCard"} status={"welcomeCard"}
isFirstElementAnswered={isFirstElementAnswered} isFirstElementAnswered={isFirstElementAnswered}
responseData={response.data} responseData={response.data}
locale={locale}
/> />
)} )}
<div className="space-y-6"> <div className="space-y-6">
@@ -104,9 +98,7 @@ export const SingleResponseCardBody = ({
getLocalizedValue(question.headline, "default"), getLocalizedValue(question.headline, "default"),
response.data, response.data,
response.variables, response.variables,
true, true
locale,
dateFormats
) )
) )
)} )}
@@ -117,7 +109,6 @@ export const SingleResponseCardBody = ({
survey={survey} survey={survey}
responseData={response.data[question.id]} responseData={response.data[question.id]}
language={response.language} language={response.language}
locale={locale}
showId={true} showId={true}
/> />
</div> </div>
@@ -127,7 +118,6 @@ export const SingleResponseCardBody = ({
skippedElements={skipped} skippedElements={skipped}
elements={elements} elements={elements}
responseData={response.data} responseData={response.data}
locale={locale}
status={ status={
response.finished || response.finished ||
(skippedQuestions.length > 0 && (skippedQuestions.length > 0 &&
@@ -137,12 +137,7 @@ export const SingleResponseCard = ({
locale={locale} locale={locale}
/> />
<SingleResponseCardBody <SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
survey={survey}
response={response}
skippedQuestions={skippedQuestions}
locale={locale}
/>
<ResponseTagsWrapper <ResponseTagsWrapper
key={response.id} key={response.id}
@@ -217,7 +217,7 @@ describe("utils", () => {
}); });
describe("logApiError", () => { 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 // Mock the withContext method and its returned error method
const errorMock = vi.fn(); const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({ const withContextMock = vi.fn().mockReturnValue({
@@ -228,7 +228,7 @@ describe("utils", () => {
const originalWithContext = logger.withContext; const originalWithContext = logger.withContext;
logger.withContext = withContextMock; 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"); mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = { const error: ApiErrorResponseV2 = {
@@ -238,11 +238,9 @@ describe("utils", () => {
logApiError(mockRequest, error); 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({ expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123", correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error, error,
}); });
@@ -277,8 +275,6 @@ describe("utils", () => {
// Verify withContext was called with the expected context // Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({ expect(withContextMock).toHaveBeenCalledWith({
correlationId: "", correlationId: "",
method: "GET",
path: "/api/test",
error, error,
}); });
@@ -289,7 +285,7 @@ describe("utils", () => {
logger.withContext = originalWithContext; 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 // Mock the withContext method and its returned error method
const errorMock = vi.fn(); const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({ const withContextMock = vi.fn().mockReturnValue({
@@ -299,23 +295,11 @@ describe("utils", () => {
// Mock Sentry's captureException method // Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any); 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 // Replace the original withContext with our mock
const originalWithContext = logger.withContext; const originalWithContext = logger.withContext;
logger.withContext = withContextMock; 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"); mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = { const error: ApiErrorResponseV2 = {
@@ -325,60 +309,20 @@ describe("utils", () => {
logApiError(mockRequest, error); 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({ expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123", correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error, error,
}); });
// Verify error was called on the child logger // Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details"); 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 // Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled(); expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method // Restore the original method
logger.withContext = originalWithContext; 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 => { export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? ""; 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 // 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 // This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") { 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) => { Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId); scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error"); scope.setLevel("error");
scope.setExtra("originalError", error); scope.setExtra("originalError", error);
@@ -29,8 +24,6 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
logger logger
.withContext({ .withContext({
correlationId, correlationId,
method,
path,
error, error,
}) })
.error("API V2 Error Details"); .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 { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response"; import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils"; 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 { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; 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"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = async (request: Request, props: { params: Promise<TContactLinkParams> }) => 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); const surveyResult = await getSurvey(params.surveyId);
if (!surveyResult.ok) { if (!surveyResult.ok) {
+2 -2
View File
@@ -125,7 +125,7 @@ describe("Auth Utils", () => {
expect(hash1).not.toBe(hash2); expect(hash1).not.toBe(hash2);
expect(await verifyPassword(password, hash1)).toBe(true); expect(await verifyPassword(password, hash1)).toBe(true);
expect(await verifyPassword(password, hash2)).toBe(true); expect(await verifyPassword(password, hash2)).toBe(true);
}, 15000); });
test("should hash complex passwords correctly", async () => { test("should hash complex passwords correctly", async () => {
const complexPassword = "MyC0mpl3x!P@ssw0rd#2024$%^&*()"; const complexPassword = "MyC0mpl3x!P@ssw0rd#2024$%^&*()";
@@ -135,7 +135,7 @@ describe("Auth Utils", () => {
expect(hashedComplex.length).toBe(60); expect(hashedComplex.length).toBe(60);
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true); expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
expect(await verifyPassword("wrong", hashedComplex)).toBe(false); expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
}, 15000); });
test("should handle bcrypt errors gracefully and log warning", async () => { test("should handle bcrypt errors gracefully and log warning", async () => {
// Save the original bcryptjs implementation // 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 { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZCloudBillingInterval } from "@formbricks/types/organizations";
import { WEBAPP_URL } from "@/lib/constants"; import { WEBAPP_URL } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session"; 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 { import {
createPaidPlanCheckoutSession, createScaleTrialSubscription,
createProTrialSubscription,
ensureCloudStripeSetupForOrganization, ensureCloudStripeSetupForOrganization,
ensureStripeCustomerForOrganization,
reconcileCloudStripeSubscriptionsForOrganization, reconcileCloudStripeSubscriptionsForOrganization,
switchOrganizationToCloudPlan,
syncOrganizationBillingFromStripe, syncOrganizationBillingFromStripe,
undoPendingOrganizationPlanChange,
} from "@/modules/ee/billing/lib/organization-billing"; } from "@/modules/ee/billing/lib/organization-billing";
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
const ZManageSubscriptionAction = z.object({ const ZManageSubscriptionAction = z.object({
environmentId: ZId, environmentId: ZId,
@@ -49,7 +45,7 @@ export const manageSubscriptionAction = authenticatedActionClient
} }
if (!organization.billing.stripeCustomerId) { if (!organization.billing.stripeCustomerId) {
throw new ResourceNotFoundError("OrganizationBilling", organizationId); throw new AuthorizationError("You do not have an associated Stripe CustomerId");
} }
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
@@ -57,64 +53,75 @@ export const manageSubscriptionAction = authenticatedActionClient
organization.billing.stripeCustomerId, organization.billing.stripeCustomerId,
`${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing` `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`
); );
ctx.auditLoggingCtx.newObject = { portalSessionCreated: true }; ctx.auditLoggingCtx.newObject = { portalSession: result };
return result; return result;
}) })
); );
const ZCreatePlanCheckoutAction = z.object({ const ZIsSubscriptionCancelledAction = z.object({
environmentId: ZId, organizationId: ZId,
targetPlan: z.enum(["pro", "scale"]),
targetInterval: ZCloudBillingInterval,
}); });
export const createPlanCheckoutAction = authenticatedActionClient export const isSubscriptionCancelledAction = authenticatedActionClient
.inputSchema(ZCreatePlanCheckoutAction) .inputSchema(ZIsSubscriptionCancelledAction)
.action( .action(async ({ ctx, parsedInput }) => {
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { await checkAuthorizationUpdated({
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); userId: ctx.user.id,
await checkAuthorizationUpdated({ organizationId: parsedInput.organizationId,
userId: ctx.user.id, access: [
organizationId, {
access: [ type: "organization",
{ roles: ["owner", "manager", "billing"],
type: "organization", },
roles: ["owner", "manager", "billing"], ],
}, });
],
});
const organization = await getOrganization(organizationId); return await isSubscriptionCancelled(parsedInput.organizationId);
if (!organization) { });
throw new ResourceNotFoundError("organization", organizationId);
}
if (!organization.billing?.stripeCustomerId) { const ZCreatePricingTableCustomerSessionAction = z.object({
throw new ResourceNotFoundError("OrganizationBilling", organizationId); environmentId: ZId,
} });
if (organization.billing.stripe?.subscriptionId) { export const createPricingTableCustomerSessionAction = authenticatedActionClient
throw new OperationNotAllowedError("paid_checkout_requires_no_existing_subscription"); .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({ const organization = await getOrganization(organizationId);
organizationId, if (!organization) {
customerId: organization.billing.stripeCustomerId, throw new ResourceNotFoundError("organization", organizationId);
environmentId: parsedInput.environmentId, }
plan: parsedInput.targetPlan,
interval: parsedInput.targetInterval,
});
ctx.auditLoggingCtx.organizationId = organizationId; if (!organization.billing?.stripeCustomerId) {
ctx.auditLoggingCtx.newObject = { throw new ResourceNotFoundError("OrganizationBilling", organizationId);
checkoutCreated: true, }
targetPlan: parsedInput.targetPlan,
targetInterval: parsedInput.targetInterval,
};
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({ const ZRetryStripeSetupAction = z.object({
organizationId: ZId, organizationId: ZId,
@@ -138,59 +145,11 @@ export const retryStripeSetupAction = authenticatedActionClient
return { success: true }; 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({ const ZStartScaleTrialAction = z.object({
organizationId: ZId, organizationId: ZId,
}); });
export const startHobbyAction = authenticatedActionClient export const startScaleTrialAction = authenticatedActionClient
.inputSchema(ZStartScaleTrialAction) .inputSchema(ZStartScaleTrialAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
@@ -209,143 +168,12 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("organization", parsedInput.organizationId); throw new ResourceNotFoundError("organization", parsedInput.organizationId);
} }
const customerId = if (!organization.billing?.stripeCustomerId) {
organization.billing?.stripeCustomerId ??
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
if (!customerId) {
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId); 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); await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true }; 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.created",
"customer.subscription.updated", "customer.subscription.updated",
"customer.subscription.deleted", "customer.subscription.deleted",
"invoice.finalized",
"entitlements.active_entitlement_summary.updated", "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 => { const getMetadataOrganizationId = (eventObject: Stripe.Event.Data.Object): string | null => {
if (!("metadata" in eventObject) || !eventObject.metadata) { if (!("metadata" in eventObject) || !eventObject.metadata) {
return null; return null;
@@ -146,10 +101,6 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
} }
try { try {
if (event.type === "checkout.session.completed") {
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id); await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, { await syncOrganizationBillingFromStripe(organizationId, {
id: event.id, id: event.id,
@@ -1,39 +1,83 @@
"use client"; "use client";
import { CheckIcon } from "lucide-react"; import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation"; import Script from "next/script";
import { useEffect, useMemo, useState } from "react"; import { createElement, useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { TOrganization, TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
type TCloudBillingInterval,
type TOrganization,
type TOrganizationStripePendingChange,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { import {
changeBillingPlanAction, createPricingTableCustomerSessionAction,
createPlanCheckoutAction, isSubscriptionCancelledAction,
createTrialPaymentCheckoutAction,
manageSubscriptionAction, manageSubscriptionAction,
retryStripeSetupAction, retryStripeSetupAction,
undoPendingPlanChangeAction,
} from "../actions"; } from "../actions";
import type { TStripeBillingCatalogDisplay } from "../lib/stripe-billing-catalog";
import { TrialAlert } from "./trial-alert";
import { UsageCard } from "./usage-card"; 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"; const getStripeLocaleOverride = (locale?: string): string | undefined => {
type TStandardPlan = "hobby" | "pro" | "scale"; 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 { interface PricingTableProps {
organization: TOrganization; organization: TOrganization;
@@ -43,22 +87,17 @@ interface PricingTableProps {
usageCycleStart: Date; usageCycleStart: Date;
usageCycleEnd: Date; usageCycleEnd: Date;
hasBillingRights: boolean; hasBillingRights: boolean;
currentCloudPlan: TDisplayPlan; currentCloudPlan: "hobby" | "pro" | "scale" | "custom" | "unknown";
currentBillingInterval: TCloudBillingInterval | null;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null; currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
pendingChange: TOrganizationStripePendingChange | null; stripePublishableKey: string | null;
stripePricingTableId: string | null;
isStripeSetupIncomplete: boolean; isStripeSetupIncomplete: boolean;
trialDaysRemaining: number | null;
billingCatalog: TStripeBillingCatalogDisplay;
} }
const STANDARD_PLAN_LEVEL: Record<TStandardPlan, number> = { const getCurrentCloudPlanLabel = (
hobby: 0, plan: "hobby" | "pro" | "scale" | "custom" | "unknown",
pro: 1, t: (key: string) => string
scale: 2, ) => {
};
const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string) => {
if (plan === "hobby") return t("environments.settings.billing.plan_hobby"); if (plan === "hobby") return t("environments.settings.billing.plan_hobby");
if (plan === "pro") return t("environments.settings.billing.plan_pro"); if (plan === "pro") return t("environments.settings.billing.plan_pro");
if (plan === "scale") return t("environments.settings.billing.plan_scale"); if (plan === "scale") return t("environments.settings.billing.plan_scale");
@@ -66,70 +105,6 @@ const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string
return t("environments.settings.billing.plan_unknown"); 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);
};
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 = ({ export const PricingTable = ({
environmentId, environmentId,
organization, organization,
@@ -139,144 +114,102 @@ export const PricingTable = ({
usageCycleEnd, usageCycleEnd,
hasBillingRights, hasBillingRights,
currentCloudPlan, currentCloudPlan,
currentBillingInterval,
currentSubscriptionStatus, currentSubscriptionStatus,
pendingChange, stripePublishableKey,
stripePricingTableId,
isStripeSetupIncomplete, isStripeSetupIncomplete,
trialDaysRemaining,
billingCatalog,
}: PricingTableProps) => { }: PricingTableProps) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false); const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false);
const [isPlanActionPending, setIsPlanActionPending] = useState<string | null>(null); const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
const [selectedInterval, setSelectedInterval] = useState<TCloudBillingInterval>( const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
currentBillingInterval ?? "monthly" string | null
); >(null);
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US"; const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
const isTrialing = currentSubscriptionStatus === "trialing"; const showPricingTable =
const hasPaymentMethod = organization.billing.stripe?.hasPaymentMethod === true; hasBillingRights && isUpgradeablePlan && !!stripePublishableKey && !!stripePricingTableId;
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null; const canManageSubscription =
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId; hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod); const stripeLocaleOverride = useMemo(
const usageCycleLabel = `${formatDateForDisplay(usageCycleStart, locale, { () => getStripeLocaleOverride(i18n.resolvedLanguage ?? i18n.language),
year: "numeric", [i18n.language, i18n.resolvedLanguage]
month: "short", );
day: "numeric", const stripePricingTableProps = useMemo(() => {
timeZone: "UTC", const props: Record<string, string> = {
})} - ${formatDateForDisplay(usageCycleEnd, locale, { "pricing-table-id": stripePricingTableId ?? "",
year: "numeric", "publishable-key": stripePublishableKey ?? "",
month: "short", };
day: "numeric",
timeZone: "UTC", if (stripeLocaleOverride) {
})}`; props["__locale-override"] = stripeLocaleOverride;
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null; }
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const currentPlanLevel = if (pricingTableCustomerSessionClientSecret) {
currentCloudPlan === "hobby" || currentCloudPlan === "pro" || currentCloudPlan === "scale" props["customer-session-client-secret"] = pricingTableCustomerSessionClientSecret;
? STANDARD_PLAN_LEVEL[currentCloudPlan] } else {
: null; props["client-reference-id"] = organization.id;
}
return props;
}, [
organization.id,
pricingTableCustomerSessionClientSecret,
stripeLocaleOverride,
stripePricingTableId,
stripePublishableKey,
]);
useEffect(() => { useEffect(() => {
if (searchParams.get("checkout_success")) { const checkSubscriptionStatus = async () => {
const timer = setTimeout(() => router.refresh(), 2500); if (!hasBillingRights || !canManageSubscription) {
return () => clearTimeout(timer); 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) { if (globalThis.window !== undefined) {
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId); globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
} }
};
const navigateToExternalUrl = (url: string) => { const loadPricingTableCustomerSession = async () => {
if (globalThis.window !== undefined) { try {
globalThis.window.location.href = url; const response = await createPricingTableCustomerSessionAction({ environmentId });
} setPricingTableCustomerSessionClientSecret(response?.data?.clientSecret ?? null);
}; } catch {
setPricingTableCustomerSessionClientSecret(null);
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;
} }
if (response?.data && typeof response.data === "string") { };
navigateToExternalUrl(response.data);
return; void loadPricingTableCustomerSession();
} }, [environmentId, showPricingTable]);
toast.error(t("common.something_went_wrong_please_try_again"));
} catch (error) { const openCustomerPortal = async () => {
console.error("Failed to create setup checkout session:", error); const manageSubscriptionResponse = await manageSubscriptionAction({
toast.error(t("common.something_went_wrong_please_try_again")); environmentId,
});
if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") {
router.push(manageSubscriptionResponse.data);
} }
}; };
@@ -284,15 +217,11 @@ export const PricingTable = ({
setIsRetryingStripeSetup(true); setIsRetryingStripeSetup(true);
try { try {
const response = await retryStripeSetupAction({ organizationId: organization.id }); const response = await retryStripeSetupAction({ organizationId: organization.id });
if (response?.serverError) {
toast.error(getActionErrorMessage(response.serverError, t));
return;
}
if (response?.data) { if (response?.data) {
router.refresh(); 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 { } catch {
toast.error(t("common.something_went_wrong_please_try_again")); toast.error(t("common.something_went_wrong_please_try_again"));
} finally { } finally {
@@ -300,160 +229,23 @@ export const PricingTable = ({
} }
}; };
const redirectToPlanCheckout = async ( const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
plan: Exclude<TStandardPlan, "hobby">, const projectsUnlimitedCheck = organization.billing.limits.projects === null;
interval: TCloudBillingInterval const usageCycleLabel = `${usageCycleStart.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
): Promise<void> => { year: "numeric",
if (existingSubscriptionId) { month: "short",
await openTrialPaymentCheckout(); day: "numeric",
return; timeZone: "UTC",
} })} - ${usageCycleEnd.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
year: "numeric",
if (interval === "yearly") { month: "short",
toast.error(t("environments.settings.billing.yearly_checkout_unavailable")); day: "numeric",
return; timeZone: "UTC",
} })}`;
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");
};
return ( return (
<main> <main>
<div className="flex max-w-6xl flex-col gap-4"> <div className="flex max-w-4xl 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}}",
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
)}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
{t("environments.settings.billing.keep_current_plan")}
</AlertButton>
)}
</Alert>
)}
{isStripeSetupIncomplete && hasBillingRights && ( {isStripeSetupIncomplete && hasBillingRights && (
<Alert variant="warning"> <Alert variant="warning">
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle> <AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
@@ -465,24 +257,14 @@ export const PricingTable = ({
</AlertButton> </AlertButton>
</Alert> </Alert>
)} )}
{currentCloudPlan === "custom" && (
<Alert>
<AlertTitle>{t("environments.settings.billing.custom_plan_title")}</AlertTitle>
<AlertDescription>{t("environments.settings.billing.custom_plan_description")}</AlertDescription>
</Alert>
)}
<SettingsCard <SettingsCard
title={t("environments.settings.billing.subscription")} title={t("environments.settings.billing.subscription")}
description={t("environments.settings.billing.subscription_description")} description={t("environments.settings.billing.subscription_description")}
buttonInfo={ buttonInfo={
canShowSubscriptionButton canManageSubscription
? { ? {
text: hasPaymentMethod text: t("environments.settings.billing.manage_subscription"),
? t("environments.settings.billing.manage_billing_details") onClick: () => void openCustomerPortal(),
: t("environments.settings.billing.add_payment_method"),
onClick: () => void (hasPaymentMethod ? openBillingPortal() : openTrialPaymentCheckout()),
variant: "default", variant: "default",
} }
: undefined : undefined
@@ -492,19 +274,8 @@ export const PricingTable = ({
<p className="text-sm font-semibold text-slate-700"> <p className="text-sm font-semibold text-slate-700">
{t("environments.settings.billing.your_plan")} {t("environments.settings.billing.your_plan")}
</p> </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)} /> <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" && ( {currentSubscriptionStatus === "trialing" && (
<Badge <Badge
type="warning" type="warning"
@@ -512,9 +283,24 @@ export const PricingTable = ({
text={t("environments.settings.billing.status_trialing")} 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> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<UsageCard <UsageCard
metric={t("common.responses")} metric={t("common.responses")}
@@ -523,11 +309,11 @@ export const PricingTable = ({
isUnlimited={responsesUnlimitedCheck} isUnlimited={responsesUnlimitedCheck}
unlimitedLabel={t("environments.settings.billing.unlimited_responses")} unlimitedLabel={t("environments.settings.billing.unlimited_responses")}
/> />
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{t("environments.settings.billing.usage_cycle")}: {usageCycleLabel} {t("environments.settings.billing.usage_cycle")}: {usageCycleLabel}
</p> </p>
</div> </div>
<UsageCard <UsageCard
metric={t("common.workspaces")} metric={t("common.workspaces")}
currentCount={projectCount} currentCount={projectCount}
@@ -538,136 +324,35 @@ export const PricingTable = ({
</div> </div>
</SettingsCard> </SettingsCard>
{showPlanSelector && ( {currentCloudPlan === "pro" && (
<SettingsCard <div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-slate-800 p-6 shadow-sm">
title={t("environments.settings.billing.plan_selection_title")} <div className="flex items-center justify-between gap-6">
description={t("environments.settings.billing.plan_selection_description")}> <div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-6"> <h3 className="text-lg font-semibold text-white">
<div {t("environments.settings.billing.scale_banner_title")}
className="flex w-fit rounded-xl border border-slate-200 bg-slate-100 p-1" </h3>
role="tablist" <p className="text-sm text-slate-300">
aria-label={t("environments.settings.billing.billing_interval_toggle")}> {t("environments.settings.billing.scale_banner_description")}
{(["monthly", "yearly"] as const).map((interval) => ( </p>
<button <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-400">
key={interval} <span>&#10003; {t("environments.settings.billing.scale_feature_teams")}</span>
type="button" <span>&#10003; {t("environments.settings.billing.scale_feature_api")}</span>
role="tab" <span>&#10003; {t("environments.settings.billing.scale_feature_quota")}</span>
aria-selected={selectedInterval === interval} <span>&#10003; {t("environments.settings.billing.scale_feature_spam")}</span>
tabIndex={selectedInterval === interval ? 0 : -1} </div>
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>
);
})}
</div> </div>
<Button variant="secondary" size="sm" onClick={openCustomerPortal} className="shrink-0">
{t("environments.settings.billing.upgrade")}
</Button>
</div> </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> </div>
</main> </main>
@@ -11,8 +11,7 @@ import ethereumLogo from "@/images/customer-logos/ethereum-logo.png";
import flixbusLogo from "@/images/customer-logos/flixbus-white.svg"; import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
import githubLogo from "@/images/customer-logos/github-logo.png"; import githubLogo from "@/images/customer-logos/github-logo.png";
import siemensLogo from "@/images/customer-logos/siemens.png"; import siemensLogo from "@/images/customer-logos/siemens.png";
import { startProTrialAction } from "@/modules/ee/billing/actions"; import { startScaleTrialAction } from "@/modules/ee/billing/actions";
import { startHobbyAction } from "@/modules/ee/billing/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
interface SelectPlanCardProps { interface SelectPlanCardProps {
@@ -32,25 +31,21 @@ const CUSTOMER_LOGOS = [
export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => { export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => {
const router = useRouter(); const router = useRouter();
const [isStartingTrial, setIsStartingTrial] = useState(false); const [isStartingTrial, setIsStartingTrial] = useState(false);
const [isStartingHobby, setIsStartingHobby] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const TRIAL_FEATURE_KEYS = [ const TRIAL_FEATURE_KEYS = [
t("environments.settings.billing.trial_feature_unlimited_seats"), t("environments.settings.billing.trial_feature_whitelabel"),
t("environments.settings.billing.trial_feature_hide_branding"), t("environments.settings.billing.trial_feature_collaboration"),
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_webhooks"), t("environments.settings.billing.trial_feature_webhooks"),
t("environments.settings.billing.trial_feature_api_access"), 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; ] as const;
const handleStartTrial = async () => { const handleStartTrial = async () => {
setIsStartingTrial(true); setIsStartingTrial(true);
try { try {
const result = await startProTrialAction({ organizationId }); const result = await startScaleTrialAction({ organizationId });
if (result?.data) { if (result?.data) {
router.push(nextUrl); router.push(nextUrl);
} else if (result?.serverError === "trial_already_used") { } else if (result?.serverError === "trial_already_used") {
@@ -66,20 +61,8 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
} }
}; };
const handleContinueHobby = async () => { const handleContinueFree = () => {
setIsStartingHobby(true); router.push(nextUrl);
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);
}
}; };
return ( return (
@@ -112,7 +95,7 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
onClick={handleStartTrial} onClick={handleStartTrial}
className="mt-4 w-full" className="mt-4 w-full"
loading={isStartingTrial} loading={isStartingTrial}
disabled={isStartingTrial || isStartingHobby}> disabled={isStartingTrial}>
{t("common.start_free_trial")} {t("common.start_free_trial")}
</Button> </Button>
</div> </div>
@@ -138,10 +121,9 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
</div> </div>
<button <button
onClick={handleContinueHobby} onClick={handleContinueFree}
disabled={isStartingTrial || isStartingHobby}
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline"> 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> </button>
</div> </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({ expect(result).toEqual({
organizationId: "org_1", organizationId: "org_1",
currentCloudPlan: "pro", currentCloudPlan: "pro",
currentBillingInterval: null,
currentSubscriptionStatus: null, currentSubscriptionStatus: null,
pendingChange: null,
trialDaysRemaining: null,
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"), usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"), usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
billing, billing,
@@ -1,10 +1,6 @@
import "server-only"; import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { import { type TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
type TCloudBillingInterval,
type TOrganizationStripePendingChange,
type TOrganizationStripeSubscriptionStatus,
} from "@formbricks/types/organizations";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing"; import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getOrganizationBillingWithReadThroughSync } from "./organization-billing"; import { getOrganizationBillingWithReadThroughSync } from "./organization-billing";
@@ -13,10 +9,7 @@ export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "u
export type TCloudBillingDisplayContext = { export type TCloudBillingDisplayContext = {
organizationId: string; organizationId: string;
currentCloudPlan: TCloudBillingDisplayPlan; currentCloudPlan: TCloudBillingDisplayPlan;
currentBillingInterval: TCloudBillingInterval | null;
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null; currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
pendingChange: TOrganizationStripePendingChange | null;
trialDaysRemaining: number | null;
usageCycleStart: Date; usageCycleStart: Date;
usageCycleEnd: Date; usageCycleEnd: Date;
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>; billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>;
@@ -34,34 +27,6 @@ const resolveCurrentSubscriptionStatus = (
return billing.stripe?.subscriptionStatus ?? null; 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 ( export const getCloudBillingDisplayContext = async (
organizationId: string organizationId: string
): Promise<TCloudBillingDisplayContext> => { ): Promise<TCloudBillingDisplayContext> => {
@@ -76,10 +41,7 @@ export const getCloudBillingDisplayContext = async (
return { return {
organizationId, organizationId,
currentCloudPlan: resolveCurrentCloudPlan(billing), currentCloudPlan: resolveCurrentCloudPlan(billing),
currentBillingInterval: resolveCurrentBillingInterval(billing),
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing), currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
pendingChange: resolvePendingChange(billing),
trialDaysRemaining: resolveTrialDaysRemaining(billing),
usageCycleStart: usageCycleWindow.start, usageCycleStart: usageCycleWindow.start,
usageCycleEnd: usageCycleWindow.end, usageCycleEnd: usageCycleWindow.end,
billing, 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 { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { env } from "@/lib/env";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service"; import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display"; 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 { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
@@ -21,10 +21,7 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
notFound(); notFound();
} }
const [cloudBillingDisplayContext, billingCatalog] = await Promise.all([ const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
getCloudBillingDisplayContext(organization.id),
getStripeBillingCatalogDisplay(),
]);
const organizationWithSyncedBilling = { const organizationWithSyncedBilling = {
...organization, ...organization,
@@ -56,14 +53,12 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
projectCount={projectCount} projectCount={projectCount}
hasBillingRights={hasBillingRights} hasBillingRights={hasBillingRights}
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan} currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
currentBillingInterval={cloudBillingDisplayContext.currentBillingInterval}
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus} currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
pendingChange={cloudBillingDisplayContext.pendingChange}
usageCycleStart={cloudBillingDisplayContext.usageCycleStart} usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd} usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId} isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
billingCatalog={billingCatalog}
/> />
</PageContentWrapper> </PageContentWrapper>
); );
@@ -2,12 +2,12 @@ import { getServerSession } from "next-auth";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getDisplaysByContactId } from "@/lib/display/service"; import { getDisplaysByContactId } from "@/lib/display/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getResponsesByContactId } from "@/lib/response/service"; import { getResponsesByContactId } from "@/lib/response/service";
import { getSurveys } from "@/lib/survey/service"; import { getSurveys } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -53,7 +53,7 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
} }
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id); const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const locale = user.locale ?? DEFAULT_LOCALE; const locale = await findMatchingLocale();
return ( return (
<ActivityTimeline <ActivityTimeline
@@ -1,6 +1,5 @@
import { getDisplaysByContactId } from "@/lib/display/service"; import { getDisplaysByContactId } from "@/lib/display/service";
import { getResponsesByContactId } from "@/lib/response/service"; import { getResponsesByContactId } from "@/lib/response/service";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes"; import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts"; import { getContact } from "@/modules/ee/contacts/lib/contacts";
@@ -10,7 +9,6 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => { export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate(); const t = await getTranslate();
const locale = await getLocale();
const [contact, attributesWithKeyInfo] = await Promise.all([ const [contact, attributesWithKeyInfo] = await Promise.all([
getContact(contactId), getContact(contactId),
getContactAttributesWithKeyInfo(contactId), getContactAttributesWithKeyInfo(contactId),
@@ -45,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
return <IdBadge id={attr.value} />; return <IdBadge id={attr.value} />;
} }
return formatAttributeValue(attr.value, attr.dataType, locale); return formatAttributeValue(attr.value, attr.dataType);
}; };
return ( return (
@@ -1,12 +1,12 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react"; import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { getSelectionColumn } from "@/modules/ui/components/data-table"; import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text"; import { HighlightedText } from "@/modules/ui/components/highlighted-text";
@@ -61,15 +61,7 @@ export const generateAttributeTableColumns = (
header: t("common.created_at"), header: t("common.created_at"),
cell: ({ row }) => { cell: ({ row }) => {
const createdAt = row.original.createdAt; const createdAt = row.original.createdAt;
return ( return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
<span>
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
);
}, },
}; };
@@ -78,7 +78,7 @@ export const AttributesTable = ({
// Generate columns // Generate columns
const columns = useMemo(() => { const columns = useMemo(() => {
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale); return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale);
}, [searchValue, isReadOnly, isExpanded, locale, t]); }, [searchValue, isReadOnly, isExpanded]);
// Load saved settings from localStorage // Load saved settings from localStorage
useEffect(() => { useEffect(() => {
@@ -2,7 +2,6 @@
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { TUserLocale } from "@formbricks/types/user";
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value"; import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
import { getSelectionColumn } from "@/modules/ui/components/data-table"; import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text"; import { HighlightedText } from "@/modules/ui/components/highlighted-text";
@@ -13,7 +12,6 @@ export const generateContactTableColumns = (
searchValue: string, searchValue: string,
data: TContactTableData[], data: TContactTableData[],
isReadOnly: boolean, isReadOnly: boolean,
locale: TUserLocale,
t: TFunction t: TFunction
): ColumnDef<TContactTableData>[] => { ): ColumnDef<TContactTableData>[] => {
const userColumn: ColumnDef<TContactTableData> = { const userColumn: ColumnDef<TContactTableData> = {
@@ -77,7 +75,7 @@ export const generateContactTableColumns = (
cell: ({ row }: { row: { original: TContactTableData } }) => { cell: ({ row }: { row: { original: TContactTableData } }) => {
const attribute = row.original.attributes.find((a) => a.key === attr.key); const attribute = row.original.attributes.find((a) => a.key === attr.key);
if (!attribute) return null; if (!attribute) return null;
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType, locale); const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
return <HighlightedText value={formattedValue} searchValue={searchValue} />; return <HighlightedText value={formattedValue} searchValue={searchValue} />;
}, },
}; };
@@ -46,7 +46,7 @@ export const ContactsPageLayout = async ({
description={upgradePromptDescription ?? t("environments.contacts.unlock_contacts_description")} description={upgradePromptDescription ?? t("environments.contacts.unlock_contacts_description")}
buttons={[ 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 href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing` ? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license", : "https://formbricks.com/upgrade-self-hosting-license",
@@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({
label: t("common.contacts"), label: t("common.contacts"),
href: `/environments/${environmentId}/contacts`, href: `/environments/${environmentId}/contacts`,
}, },
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
{ {
id: "segments", id: "segments",
label: t("common.segments"), label: t("common.segments"),
href: `/environments/${environmentId}/segments`, href: `/environments/${environmentId}/segments`,
}, },
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
]; ];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />; return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -17,7 +17,6 @@ import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@ta
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { deleteContactAction } from "@/modules/ee/contacts/actions"; import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -66,15 +65,14 @@ export const ContactsTable = ({
const [isExpanded, setIsExpanded] = useState<boolean | null>(null); const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
const router = useRouter(); const router = useRouter();
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [parent] = useAutoAnimate(); const [parent] = useAutoAnimate();
// Generate columns // Generate columns
const columns = useMemo(() => { const columns = useMemo(() => {
return generateContactTableColumns(searchValue, data, isReadOnly, locale, t); return generateContactTableColumns(searchValue, data, isReadOnly, t);
}, [searchValue, data, isReadOnly, locale, t]); }, [searchValue, data, isReadOnly]);
// Load saved settings from localStorage // Load saved settings from localStorage
useEffect(() => { useEffect(() => {
@@ -1,5 +1,4 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { formatDateForDisplay } from "@/lib/utils/datetime";
/** /**
* Formats an attribute value for display based on its data type. * Formats an attribute value for display based on its data type.
@@ -28,11 +27,12 @@ export const formatAttributeValue = (
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return String(value); return String(value);
} }
return formatDateForDisplay(date, locale, { // Use Intl.DateTimeFormat for locale-aware date formatting
return new Intl.DateTimeFormat(locale, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); }).format(date);
} catch { } catch {
// If date parsing fails, return the raw value // If date parsing fails, return the raw value
return String(value); return String(value);
@@ -97,13 +97,14 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
); );
const ZUpdateSegmentAction = z.object({ const ZUpdateSegmentAction = z.object({
environmentId: ZId,
segmentId: ZId, segmentId: ZId,
data: ZSegmentUpdateInput, data: ZSegmentUpdateInput,
}); });
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action( export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId); const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId, organizationId,
@@ -2,7 +2,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment"; import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime"; import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
@@ -11,8 +11,7 @@ interface SegmentActivityTabProps {
} }
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => { export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { activeSurveys, inactiveSurveys } = currentSegment; const { activeSurveys, inactiveSurveys } = currentSegment;
@@ -44,13 +43,13 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<div> <div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label> <Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700"> <p className="text-xs text-slate-700">
{formatDateTimeForDisplay(currentSegment.createdAt, locale)} {convertDateTimeStringShort(currentSegment.createdAt?.toString())}
</p> </p>
</div>{" "} </div>{" "}
<div> <div>
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label> <Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
<p className="text-xs text-slate-700"> <p className="text-xs text-slate-700">
{formatDateTimeForDisplay(currentSegment.updatedAt, locale)} {convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
</p> </p>
</div> </div>
<div> <div>
@@ -75,6 +75,7 @@ export function SegmentSettings({
try { try {
setIsUpdatingSegment(true); setIsUpdatingSegment(true);
const data = await updateSegmentAction({ const data = await updateSegmentAction({
environmentId,
segmentId: segment.id, segmentId: segment.id,
data: { data: {
title: segment.title, title: segment.title,
@@ -133,10 +134,6 @@ export function SegmentSettings({
return true; return true;
} }
if (segment.filters.length === 0) {
return true;
}
// parse the filters to check if they are valid // parse the filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters); const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) { if (!parsedFilters.success) {
@@ -1,16 +1,12 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { UsersIcon } from "lucide-react"; import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment"; import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
export const generateSegmentTableColumns = ( export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
t: TFunction,
locale: string
): ColumnDef<TSegmentWithSurveyNames>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = { const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
id: "title", id: "title",
accessorKey: "title", accessorKey: "title",
@@ -37,7 +33,11 @@ export const generateSegmentTableColumns = (
accessorKey: "updatedAt", accessorKey: "updatedAt",
header: t("common.updated_at"), header: t("common.updated_at"),
cell: ({ row }) => { cell: ({ row }) => {
return <span className="text-sm text-slate-900">{timeSinceDate(row.original.updatedAt, locale)}</span>; return (
<span className="text-sm text-slate-900">
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
</span>
);
}, },
}; };
@@ -47,13 +47,7 @@ export const generateSegmentTableColumns = (
header: t("common.created_at"), header: t("common.created_at"),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<span className="text-sm text-slate-900"> <span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
{formatDateForDisplay(row.original.createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
); );
}, },
}; };
@@ -1,12 +1,10 @@
"use client"; "use client";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react"; import { UsersIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment"; import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { EditSegmentModal } from "./edit-segment-modal"; import { EditSegmentModal } from "./edit-segment-modal";
type TSegmentTableDataRowProps = { type TSegmentTableDataRowProps = {
@@ -26,8 +24,6 @@ export const SegmentTableDataRow = ({
}: TSegmentTableDataRowProps) => { }: TSegmentTableDataRowProps) => {
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment; const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false); const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
const { i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
return ( return (
<> <>
@@ -50,16 +46,14 @@ export const SegmentTableDataRow = ({
<div className="ph-no-capture text-slate-900">{surveys?.length}</div> <div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div> </div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block"> <div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(updatedAt, locale)}</div> <div className="ph-no-capture text-slate-900">
{formatDistanceToNow(updatedAt, {
addSuffix: true,
}).replace("about", "")}
</div>
</div> </div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block"> <div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900"> <div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
</div> </div>
</button> </button>
@@ -22,13 +22,12 @@ export function SegmentTable({
isContactsEnabled, isContactsEnabled,
isReadOnly, isReadOnly,
}: SegmentTableUpdatedProps) { }: SegmentTableUpdatedProps) {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null); const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const columns = useMemo(() => { const columns = useMemo(() => {
return generateSegmentTableColumns(t, locale); return generateSegmentTableColumns(t);
}, [locale, t]); }, []);
const table = useReactTable({ const table = useReactTable({
data: segments, data: segments,
@@ -124,7 +124,7 @@ export function TargetingCard({
}; };
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => { 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; return updatedSegment?.data as TSegment;
}; };
@@ -136,7 +136,7 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => { const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try { try {
if (!segment) throw new Error(t("environments.segments.invalid_segment")); 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) { if (result?.serverError) {
toast.error(getFormattedErrorMessage(result)); toast.error(getFormattedErrorMessage(result));
return; return;
@@ -1,73 +0,0 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
const validFilters = [
{
id: createId(),
connector: null,
resource: {
id: createId(),
root: {
type: "attribute" as const,
contactAttributeKey: "email",
},
value: "user@example.com",
qualifier: {
operator: "equals" as const,
},
},
},
];
describe("segment schema validation", () => {
test("keeps base segment filters compatible with empty arrays", () => {
const result = ZSegmentFilters.safeParse([]);
expect(result.success).toBe(true);
});
test("requires at least one filter when creating a segment", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: [],
surveyId: "surveyId",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment creation with a valid filter", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: validFilters,
surveyId: "surveyId",
});
expect(result.success).toBe(true);
});
test("requires at least one filter when updating a segment", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment updates with a valid filter", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: validFilters,
});
expect(result.success).toBe(true);
});
});
+3 -7
View File
@@ -75,13 +75,9 @@ export const recheckLicenseAction = authenticatedActionClient
try { try {
freshLicense = await fetchLicenseFresh(); freshLicense = await fetchLicenseFresh();
} catch (error) { } catch (error) {
// 400 = invalid license key, 403 = license bound to another instance. // 400 = invalid license key — return directly so the UI shows the correct message
// Return directly so the UI shows the correct message. if (error instanceof LicenseApiError && error.status === 400) {
if (error instanceof LicenseApiError && (error.status === 400 || error.status === 403)) { return { active: false, status: "invalid_license" as const };
return {
active: false,
status: error.status === 400 ? ("invalid_license" as const) : ("instance_mismatch" as const),
};
} }
throw error; throw error;
} }
@@ -462,37 +462,6 @@ describe("License Core Logic", () => {
}); });
}); });
test("should return instance_mismatch when API returns 403", async () => {
vi.resetModules();
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
mockCache.get.mockResolvedValue({ ok: true, data: null });
fetch.mockResolvedValueOnce({ ok: false, status: 403 } as any);
const license = await getEnterpriseLicense();
expect(license).toEqual({
active: false,
features: expect.objectContaining({ projects: 3 }),
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "instance_mismatch" as const,
});
});
test("should skip polling and fetch directly when Redis is unavailable (tryLock error)", async () => { test("should skip polling and fetch directly when Redis is unavailable (tryLock error)", async () => {
vi.resetModules(); vi.resetModules();
vi.doMock("@/lib/env", () => ({ vi.doMock("@/lib/env", () => ({
@@ -14,7 +14,7 @@ import { getInstanceId } from "@/lib/instance";
import { import {
TEnterpriseLicenseDetails, TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures, TEnterpriseLicenseFeatures,
TLicenseStatus, TEnterpriseLicenseStatusReturn,
} from "@/modules/ee/license-check/types/enterprise-license"; } from "@/modules/ee/license-check/types/enterprise-license";
// Configuration // Configuration
@@ -52,7 +52,7 @@ type TEnterpriseLicenseResult = {
lastChecked: Date; lastChecked: Date;
isPendingDowngrade: boolean; isPendingDowngrade: boolean;
fallbackLevel: FallbackLevel; fallbackLevel: FallbackLevel;
status: TLicenseStatus; status: TEnterpriseLicenseStatusReturn;
}; };
type TPreviousResult = { type TPreviousResult = {
@@ -407,9 +407,8 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
return fetchLicenseFromServerInternal(retryCount + 1); return fetchLicenseFromServerInternal(retryCount + 1);
} }
// 400 = invalid license key, 403 = license bound to another instance. // 400 = invalid license key — propagate so callers can distinguish from unreachable
// Propagate both so callers can distinguish them from unreachable. if (res.status === 400) {
if (res.status === 400 || res.status === 403) {
throw error; throw error;
} }
@@ -586,7 +585,7 @@ const computeLicenseState = async (
lastChecked: previousResult.lastChecked, lastChecked: previousResult.lastChecked,
isPendingDowngrade: true, isPendingDowngrade: true,
fallbackLevel: "grace" as const, fallbackLevel: "grace" as const,
status: liveLicenseDetails?.status ?? "unreachable", status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
}; };
memoryCache = { data: graceResult, timestamp: Date.now() }; memoryCache = { data: graceResult, timestamp: Date.now() };
return graceResult; return graceResult;
@@ -633,15 +632,14 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
try { try {
liveLicenseDetails = await fetchLicense(); liveLicenseDetails = await fetchLicense();
} catch (error) { } catch (error) {
if (error instanceof LicenseApiError && (error.status === 400 || error.status === 403)) { if (error instanceof LicenseApiError && error.status === 400) {
const status = error.status === 400 ? "invalid_license" : "instance_mismatch";
const invalidResult: TEnterpriseLicenseResult = { const invalidResult: TEnterpriseLicenseResult = {
active: false, active: false,
features: DEFAULT_FEATURES, features: DEFAULT_FEATURES,
lastChecked: new Date(), lastChecked: new Date(),
isPendingDowngrade: false, isPendingDowngrade: false,
fallbackLevel: "default" as const, fallbackLevel: "default" as const,
status, status: "invalid_license" as const,
}; };
memoryCache = { data: invalidResult, timestamp: Date.now() }; memoryCache = { data: invalidResult, timestamp: Date.now() };
return invalidResult; return invalidResult;
@@ -29,10 +29,9 @@ export const ZEnterpriseLicenseDetails = z.object({
export type TEnterpriseLicenseDetails = z.infer<typeof ZEnterpriseLicenseDetails>; export type TEnterpriseLicenseDetails = z.infer<typeof ZEnterpriseLicenseDetails>;
export type TLicenseStatus = export type TEnterpriseLicenseStatusReturn =
| "active" | "active"
| "expired" | "expired"
| "instance_mismatch"
| "unreachable" | "unreachable"
| "invalid_license" | "invalid_license"
| "no-license"; | "no-license";

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