mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-05 19:30:48 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0992c182a3 |
+2
-1
@@ -150,6 +150,7 @@ NOTION_OAUTH_CLIENT_ID=
|
||||
NOTION_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# Stripe Billing Variables
|
||||
STRIPE_PRICING_TABLE_ID=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
@@ -231,4 +232,4 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
|
||||
@@ -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`.
|
||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||
|
||||
## Date and Time Rendering
|
||||
|
||||
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
||||
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
||||
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
||||
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
||||
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
||||
|
||||
## Database & Prisma Performance
|
||||
|
||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.17",
|
||||
"@storybook/addon-links": "10.2.17",
|
||||
"@storybook/addon-onboarding": "10.2.17",
|
||||
"@storybook/react-vite": "10.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@storybook/addon-a11y": "10.2.15",
|
||||
"@storybook/addon-links": "10.2.15",
|
||||
"@storybook/addon-onboarding": "10.2.15",
|
||||
"@storybook/react-vite": "10.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"eslint-plugin-storybook": "10.2.14",
|
||||
"storybook": "10.2.15",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
"@storybook/addon-docs": "10.2.15"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-6
@@ -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
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
# Runtime migrations import uuid v7 from the database package, so copy the
|
||||
# database package's resolved install instead of the repo-root hoisted version.
|
||||
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/uuid ./node_modules/uuid
|
||||
RUN chmod -R 755 ./node_modules/uuid
|
||||
|
||||
COPY --from=installer /app/node_modules/@noble/hashes ./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/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,
|
||||
name: team.name,
|
||||
}));
|
||||
|
||||
return projectTeams;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
-14
@@ -1,12 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||
|
||||
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
|
||||
interface PlanPageProps {
|
||||
params: Promise<{
|
||||
organizationId: string;
|
||||
@@ -26,16 +22,6 @@ const Page = async (props: PlanPageProps) => {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
// Users with an existing paid/trial subscription should not be shown the trial page.
|
||||
// Redirect them directly to the next onboarding step.
|
||||
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
|
||||
const currentPlan = billing?.stripe?.plan;
|
||||
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
|
||||
|
||||
if (hasExistingSubscription) {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -168,20 +167,6 @@ export const MainNavigation = ({
|
||||
if (isOwnerOrManager) loadReleases();
|
||||
}, [isOwnerOrManager]);
|
||||
|
||||
const trialDaysRemaining = useMemo(() => {
|
||||
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
||||
const trialEnd = organization.billing.stripe.trialEnd;
|
||||
if (!trialEnd) return null;
|
||||
const ts = new Date(trialEnd).getTime();
|
||||
if (!Number.isFinite(ts)) return null;
|
||||
const msPerDay = 86_400_000;
|
||||
return Math.ceil((ts - Date.now()) / msPerDay);
|
||||
}, [
|
||||
isFormbricksCloud,
|
||||
organization.billing?.stripe?.subscriptionStatus,
|
||||
organization.billing?.stripe?.trialEnd,
|
||||
]);
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
return (
|
||||
@@ -256,13 +241,6 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Trial Days Remaining */}
|
||||
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
||||
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -60,7 +60,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
buttons={[
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD
|
||||
? t("common.upgrade_plan")
|
||||
? t("common.start_free_trial")
|
||||
: t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
|
||||
-146
@@ -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>
|
||||
);
|
||||
};
|
||||
+7
-29
@@ -6,23 +6,22 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { SettingsCard } from "../../../components/SettingsCard";
|
||||
|
||||
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
|
||||
|
||||
interface EnterpriseLicenseStatusProps {
|
||||
status: TLicenseStatus;
|
||||
lastChecked: Date;
|
||||
status: LicenseStatus;
|
||||
gracePeriodEnd?: Date;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const getBadgeConfig = (
|
||||
status: TLicenseStatus,
|
||||
status: LicenseStatus,
|
||||
t: TFunction
|
||||
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
||||
switch (status) {
|
||||
@@ -30,11 +29,6 @@ const getBadgeConfig = (
|
||||
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
||||
case "expired":
|
||||
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
||||
case "instance_mismatch":
|
||||
return {
|
||||
type: "error",
|
||||
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
|
||||
};
|
||||
case "unreachable":
|
||||
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
||||
case "invalid_license":
|
||||
@@ -46,12 +40,10 @@ const getBadgeConfig = (
|
||||
|
||||
export const EnterpriseLicenseStatus = ({
|
||||
status,
|
||||
lastChecked,
|
||||
gracePeriodEnd,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isRechecking, setIsRechecking] = useState(false);
|
||||
|
||||
@@ -67,8 +59,6 @@ export const EnterpriseLicenseStatus = ({
|
||||
if (result?.data) {
|
||||
if (result.data.status === "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") {
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
||||
} else {
|
||||
@@ -96,12 +86,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -127,7 +112,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
||||
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -143,13 +128,6 @@ export const EnterpriseLicenseStatus = ({
|
||||
</AlertDescription>
|
||||
</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">
|
||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
|
||||
+9
-14
@@ -10,7 +10,6 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
@@ -94,19 +93,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
/>
|
||||
</PageHeader>
|
||||
{hasLicense ? (
|
||||
<>
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status}
|
||||
lastChecked={licenseState.lastChecked}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||
</>
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
|
||||
+2
-2
@@ -96,8 +96,8 @@ export const ResponseTable = ({
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
|
||||
+3
-10
@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -35,7 +34,6 @@ const getElementColumnsData = (
|
||||
element: TSurveyElement,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||
@@ -169,7 +167,6 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -221,7 +218,6 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
isReadOnly: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementColumns = elements.flatMap((element) =>
|
||||
getElementColumnsData(element, survey, isExpanded, locale, t)
|
||||
);
|
||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
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>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+5
-8
@@ -1,17 +1,13 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
RESPONSES_PER_PAGE,
|
||||
} from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -27,12 +23,13 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
|
||||
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),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(organization.id),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
@@ -89,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
|
||||
+4
-6
@@ -64,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
|
||||
+7
-3
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
@@ -32,9 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
};
|
||||
|
||||
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 (
|
||||
|
||||
+2
-1
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { project } = useEnvironment();
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
|
||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
+8
-6
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
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 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),
|
||||
getIntegrations(params.environmentId),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
if (airtableIntegration?.config.key) {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
}
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
+7
-6
@@ -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 { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
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 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),
|
||||
getIntegrations(params.environmentId),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
||||
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
||||
);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
+5
-6
@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
NOTION_OAUTH_CLIENT_SECRET,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/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 { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
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),
|
||||
getIntegrationByType(params.environmentId, "notion"),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
let databasesArray: TIntegrationNotionDatabase[] = [];
|
||||
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
||||
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
||||
}
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
notionIntegration={notionIntegration as TIntegrationNotion}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
databasesArray={databasesArray}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
+7
-6
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||
import { 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 { getUserLocale } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
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 { 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),
|
||||
getIntegrationByType(params.environmentId, "slack"),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -50,7 +50,6 @@ vi.mock("@/lib/env", () => ({
|
||||
RECAPTCHA_SITE_KEY: "site-key",
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -139,7 +138,6 @@ describe("sendTelemetryEvents", () => {
|
||||
expect(payload.userCount).toBe(5);
|
||||
expect(payload.integrations.notion).toBe(true);
|
||||
expect(payload.sso.github).toBe(true);
|
||||
expect(payload.sso.saml).toBe(true);
|
||||
|
||||
// Check cache update (no TTL parameter)
|
||||
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
||||
|
||||
@@ -212,7 +212,6 @@ const sendTelemetry = async (lastSent: number) => {
|
||||
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
||||
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
||||
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
||||
saml: !!env.SAML_DATABASE_URL || ssoProviders.some((p) => p.provider === "saml"),
|
||||
};
|
||||
|
||||
// Construct telemetry payload with usage statistics and configuration.
|
||||
|
||||
@@ -190,7 +190,7 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
|
||||
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
|
||||
if (featureCheckResult) {
|
||||
return {
|
||||
response: featureCheckResult,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCreateInputWithEnvironmentId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { checkFeaturePermissions } from "./utils";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -26,14 +24,6 @@ vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
|
||||
getSurveyFollowUpsPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/permission", () => ({
|
||||
getExternalUrlsPermission: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
|
||||
}));
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org",
|
||||
name: "Test Organization",
|
||||
@@ -108,13 +98,6 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
|
||||
};
|
||||
|
||||
describe("checkFeaturePermissions", () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("should return null if no restricted features are used", async () => {
|
||||
const surveyData = { ...baseSurveyData };
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
@@ -214,315 +197,4 @@ describe("checkFeaturePermissions", () => {
|
||||
);
|
||||
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
|
||||
});
|
||||
|
||||
// External URLs - ending card button link tests
|
||||
test("should return forbiddenResponse when adding new ending with buttonLink without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://example.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external button links."
|
||||
);
|
||||
});
|
||||
|
||||
test("should return forbiddenResponse when changing ending buttonLink without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://new-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://old-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
|
||||
test("should allow keeping existing ending buttonLink without permission (grandfathering)", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://existing-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://existing-url.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should allow ending buttonLink when permission is granted", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
endings: [
|
||||
{
|
||||
id: "ending1",
|
||||
type: "endScreen" as const,
|
||||
headline: { default: "Thanks" },
|
||||
subheader: { default: "" },
|
||||
buttonLink: "https://example.com",
|
||||
buttonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// External URLs - CTA external button tests
|
||||
test("should return forbiddenResponse when adding CTA with external button without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
|
||||
);
|
||||
});
|
||||
|
||||
test("should return forbiddenResponse when changing CTA external button URL without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://new-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://old-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
|
||||
test("should allow keeping existing CTA external button without permission (grandfathering)", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://existing-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://existing-url.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should allow CTA external button when permission is granted", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return forbiddenResponse when switching CTA from internal to external without permission", async () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
||||
const surveyData = {
|
||||
...baseSurveyData,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const oldSurvey = {
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "cta1",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
required: false,
|
||||
buttonExternal: false,
|
||||
buttonUrl: "",
|
||||
ctaButtonLabel: { default: "Click" },
|
||||
},
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
},
|
||||
],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
|
||||
export const checkFeaturePermissions = async (
|
||||
surveyData: TSurveyCreateInputWithEnvironmentId,
|
||||
organization: TOrganization,
|
||||
oldSurvey?: TSurvey
|
||||
organization: TOrganization
|
||||
): Promise<Response | null> => {
|
||||
if (surveyData.recaptcha?.enabled) {
|
||||
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id);
|
||||
@@ -25,46 +22,5 @@ export const checkFeaturePermissions = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isExternalUrlsAllowed = await getExternalUrlsPermission(organization.id);
|
||||
if (!isExternalUrlsAllowed) {
|
||||
// Check ending cards for new/changed button links
|
||||
if (surveyData.endings) {
|
||||
for (const newEnding of surveyData.endings) {
|
||||
const oldEnding = oldSurvey?.endings.find((e) => e.id === newEnding.id);
|
||||
|
||||
if (newEnding.type === "endScreen" && newEnding.buttonLink) {
|
||||
if (!oldEnding || oldEnding.type !== "endScreen" || oldEnding.buttonLink !== newEnding.buttonLink) {
|
||||
return responses.forbiddenResponse(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external button links."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check CTA elements for new/changed external button URLs
|
||||
if (surveyData.blocks) {
|
||||
const newElements = getElementsFromBlocks(surveyData.blocks);
|
||||
const oldElements = oldSurvey?.blocks ? getElementsFromBlocks(oldSurvey.blocks) : [];
|
||||
|
||||
for (const newElement of newElements) {
|
||||
const oldElement = oldElements.find((e) => e.id === newElement.id);
|
||||
|
||||
if (newElement.type === "cta" && newElement.buttonExternal) {
|
||||
if (
|
||||
!oldElement ||
|
||||
oldElement.type !== "cta" ||
|
||||
!oldElement.buttonExternal ||
|
||||
oldElement.buttonUrl !== newElement.buttonUrl
|
||||
) {
|
||||
return responses.forbiddenResponse(
|
||||
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { getIsActiveCustomerAction } from "./actions";
|
||||
|
||||
interface ChatwootWidgetProps {
|
||||
chatwootBaseUrl: string;
|
||||
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
|
||||
|
||||
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
|
||||
|
||||
interface ChatwootInstance {
|
||||
setUser: (
|
||||
userId: string,
|
||||
userInfo: {
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
) => void;
|
||||
setCustomAttributes: (attributes: Record<string, unknown>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const ChatwootWidget = ({
|
||||
userEmail,
|
||||
userName,
|
||||
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
|
||||
chatwootBaseUrl,
|
||||
}: ChatwootWidgetProps) => {
|
||||
const userSetRef = useRef(false);
|
||||
const customerStatusSetRef = useRef(false);
|
||||
|
||||
const getChatwoot = useCallback((): ChatwootInstance | null => {
|
||||
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
|
||||
}, []);
|
||||
|
||||
const setUserInfo = useCallback(() => {
|
||||
const $chatwoot = 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) {
|
||||
$chatwoot.setUser(userId, {
|
||||
email: userEmail,
|
||||
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
|
||||
});
|
||||
userSetRef.current = true;
|
||||
}
|
||||
}, [userId, userEmail, userName, getChatwoot]);
|
||||
|
||||
const setCustomerStatus = useCallback(async () => {
|
||||
if (customerStatusSetRef.current) return;
|
||||
const $chatwoot = getChatwoot();
|
||||
if (!$chatwoot) return;
|
||||
|
||||
const response = await getIsActiveCustomerAction();
|
||||
if (response?.data !== undefined) {
|
||||
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
|
||||
}
|
||||
customerStatusSetRef.current = true;
|
||||
}, [getChatwoot]);
|
||||
}, [userId, userEmail, userName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatwootWebsiteToken) return;
|
||||
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
|
||||
const handleChatwootReady = () => setUserInfo();
|
||||
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
|
||||
|
||||
const handleChatwootOpen = () => setCustomerStatus();
|
||||
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
|
||||
|
||||
// Check if Chatwoot is already ready
|
||||
if (getChatwoot()) {
|
||||
if (
|
||||
(
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot
|
||||
) {
|
||||
setUserInfo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
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) {
|
||||
$chatwoot.reset();
|
||||
}
|
||||
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
|
||||
scriptElement?.remove();
|
||||
|
||||
userSetRef.current = false;
|
||||
customerStatusSetRef.current = false;
|
||||
};
|
||||
}, [
|
||||
chatwootBaseUrl,
|
||||
chatwootWebsiteToken,
|
||||
userId,
|
||||
userEmail,
|
||||
userName,
|
||||
setUserInfo,
|
||||
setCustomerStatus,
|
||||
getChatwoot,
|
||||
]);
|
||||
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const ZCreateOrganizationAction = z.object({
|
||||
@@ -36,16 +33,6 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
// Stripe setup must run AFTER membership is created so the owner email is available
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
ensureCloudStripeSetupForOrganization(newOrganization.id).catch((error) => {
|
||||
logger.error(
|
||||
{ error, organizationId: newOrganization.id },
|
||||
"Stripe setup failed after organization creation"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||
|
||||
|
||||
+19
-87
@@ -267,7 +267,6 @@ checksums:
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
|
||||
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
|
||||
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
|
||||
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
|
||||
@@ -313,7 +312,6 @@ checksums:
|
||||
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
|
||||
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
|
||||
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
|
||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
|
||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||
@@ -374,7 +372,7 @@ checksums:
|
||||
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
|
||||
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
|
||||
common/sort_by: 8adf3dbc5668379558957662f0c43563
|
||||
common/start_free_trial: e346e4ed7d138dcc873db187922369da
|
||||
common/start_free_trial: 4fab76a3fc5d5c94e3248cd279cfdd14
|
||||
common/status: 4e1fcce15854d824919b4a582c697c90
|
||||
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
|
||||
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
|
||||
@@ -409,9 +407,6 @@ checksums:
|
||||
common/title: 344e64395eaff6822a57d18623853e1a
|
||||
common/top_left: aa61bb29b56df3e046b6d68d89ee8986
|
||||
common/top_right: 241f95c923846911aaf13af6109333e5
|
||||
common/trial_days_remaining: 914ff3132895e410bf0f862433ccb49e
|
||||
common/trial_expired: ca9f0532ac40ca427ca1ba4c86454e07
|
||||
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
@@ -419,7 +414,6 @@ checksums:
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||
common/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
common/upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
|
||||
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
|
||||
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
|
||||
@@ -919,80 +913,44 @@ checksums:
|
||||
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
|
||||
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
|
||||
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
|
||||
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
|
||||
environments/settings/billing/add_payment_method_to_upgrade_tooltip: 977005ad38bfe0800a78c21edcd16e4d
|
||||
environments/settings/billing/billing_interval_toggle: 62c76eb73507108fc6aefdf1ab86cc38
|
||||
environments/settings/billing/current_plan_badge: 27f172f76ac28e72cb062f80002b0ad5
|
||||
environments/settings/billing/current_plan_cta: 53ac259fd40a361274861ee7c7498424
|
||||
environments/settings/billing/custom_plan_description: 53faa38123cc74e5adc7e59630641d66
|
||||
environments/settings/billing/custom_plan_title: f3b71be0d1cd4f81a177ada040119f30
|
||||
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
|
||||
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
|
||||
environments/settings/billing/keep_current_plan: 57ac15ffa2c29ac364dd405669eeb7f6
|
||||
environments/settings/billing/manage_billing_details: 40448f0b5ed4b3bb1d864ba6e1bb6a3b
|
||||
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
|
||||
environments/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
|
||||
environments/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
|
||||
environments/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
|
||||
environments/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
|
||||
environments/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
|
||||
environments/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
|
||||
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
|
||||
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
|
||||
environments/settings/billing/plan_change_applied: d1e04599487247dd0e21a7d99785dc7a
|
||||
environments/settings/billing/plan_change_scheduled: 16455d4aa9a152b156ee434d8c7e34d4
|
||||
environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
|
||||
environments/settings/billing/plan_custom: b7b89901f46267f532600a23cfc54ae2
|
||||
environments/settings/billing/plan_feature_everything_in_hobby: 5417a498136fa29988c8215291e3fd8b
|
||||
environments/settings/billing/plan_feature_everything_in_pro: 3f5129ff1f01eed4f051a8790ed62997
|
||||
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
|
||||
environments/settings/billing/plan_hobby_description: 1fa1cf69b42ec82727aebc5ef1ec24a2
|
||||
environments/settings/billing/plan_hobby_feature_responses: d1e6c1d83f5e57cbae2a09e6a818a25d
|
||||
environments/settings/billing/plan_hobby_feature_workspaces: 02a34669419ed7f30f728980f54d42ef
|
||||
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
|
||||
environments/settings/billing/plan_pro_description: 748c848ea0d8cf81a66704762edcd6f4
|
||||
environments/settings/billing/plan_pro_feature_responses: e16ffe385051a16dba76538c13d97a5f
|
||||
environments/settings/billing/plan_pro_feature_workspaces: 819874022b491209ca7f0f1ab1e3daea
|
||||
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
|
||||
environments/settings/billing/plan_scale_description: ef5c66e0b52686f56319e31388bd8409
|
||||
environments/settings/billing/plan_scale_feature_responses: 0b74bf8d089c738ebb7f0867bdd7d7f1
|
||||
environments/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef
|
||||
environments/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599
|
||||
environments/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d
|
||||
environments/settings/billing/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
|
||||
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
|
||||
environments/settings/billing/retry_setup: bef560e42fa8798271fea150476791e0
|
||||
environments/settings/billing/scale_banner_description: 79a9734c77ab0336d5d2fadb5f2151be
|
||||
environments/settings/billing/scale_banner_title: a2a78f57ebcbf444ad881ece234b8f45
|
||||
environments/settings/billing/scale_feature_api: 67231215e5452944b86edc2bc47d2a16
|
||||
environments/settings/billing/scale_feature_quota: 31fb6b5e846dd44de140a69fd3e4c067
|
||||
environments/settings/billing/scale_feature_spam: 8a8229b6ac3f3e0427fd347cb667ce11
|
||||
environments/settings/billing/scale_feature_teams: f6e8428f6cdb227176a5fa8c5c95c976
|
||||
environments/settings/billing/select_plan_header_subtitle: 8de6b4e3ce5726829829bd46582f343a
|
||||
environments/settings/billing/select_plan_header_title: b15a9d86b819a7fae8e956a50572184c
|
||||
environments/settings/billing/select_plan_header_title: d851e9fa093ddb248924cf99e1d79b4e
|
||||
environments/settings/billing/status_trialing: 4fd32760caf3bd7169935b0a6d2b5b67
|
||||
environments/settings/billing/stay_on_hobby_plan: 966ab0c752a79f00ef10d6a5ed1d8cad
|
||||
environments/settings/billing/stripe_setup_incomplete: fa6d6e295dd14b73c17ac8678205109b
|
||||
environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284
|
||||
environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343
|
||||
environments/settings/billing/subscription_description: b03618508e576666198d4adf3c2cb9a9
|
||||
environments/settings/billing/switch_at_period_end: 9c91b2287886e077a0571efab8908623
|
||||
environments/settings/billing/switch_plan_now: dad56622a1916fe5d1a2bda5b0393194
|
||||
environments/settings/billing/this_includes: 127e0fe104f47886b54106a057a6b26f
|
||||
environments/settings/billing/trial_alert_description: aba3076cc6814cc6128d425d3d1957e8
|
||||
environments/settings/billing/trial_already_used: 5433347ff7647fe0aba0fe91a44560ba
|
||||
environments/settings/billing/trial_feature_api_access: 8c6d03728c3d9470616eb5cee5f9f65d
|
||||
environments/settings/billing/trial_feature_attribute_segmentation: 90087da973ae48e32ec6d863516fc8c9
|
||||
environments/settings/billing/trial_feature_contact_segment_management: 27f17a039ebed6413811ab3a461db2f4
|
||||
environments/settings/billing/trial_feature_email_followups: 0cc02dc14aa28ce94ca6153c306924e5
|
||||
environments/settings/billing/trial_feature_hide_branding: b8dbcb24e50e0eb4aeb0c97891cac61d
|
||||
environments/settings/billing/trial_feature_mobile_sdks: 0963480a27df49657c1b7507adec9a06
|
||||
environments/settings/billing/trial_feature_respondent_identification: a82e24ab4c27c5e485326678d9b7bd79
|
||||
environments/settings/billing/trial_feature_unlimited_seats: a3257d5b6a23bfbc4b7fd1108087a823
|
||||
environments/settings/billing/trial_feature_webhooks: 5ead39fba97fbd37835a476ee67fdd94
|
||||
environments/settings/billing/trial_feature_api_access: d7aabb2de18beb5bd30c274cd768a2a9
|
||||
environments/settings/billing/trial_feature_collaboration: a43509fffe319e14d69a981ef2791517
|
||||
environments/settings/billing/trial_feature_email_followups: add368efdd84c5aef8886f369d54cbed
|
||||
environments/settings/billing/trial_feature_quotas: 3a67818b3901bdaa72abc62db72ab170
|
||||
environments/settings/billing/trial_feature_webhooks: 8d7f034e006b2fe0eb8fa9b8f1abef51
|
||||
environments/settings/billing/trial_feature_whitelabel: 624a7aeca6a0fa65935c63fd7a8e9638
|
||||
environments/settings/billing/trial_no_credit_card: 01c70aa6e1001815a9a11951394923ca
|
||||
environments/settings/billing/trial_payment_method_added_description: 872a5c557f56bafc9b7ec4895f9c33e8
|
||||
environments/settings/billing/trial_title: f2c3791c1fb2970617ec0f2d243a931b
|
||||
environments/settings/billing/trial_title: 23d0d2cbe306ae0f784b8289bf66a2c7
|
||||
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
|
||||
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
|
||||
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
|
||||
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
|
||||
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
|
||||
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
|
||||
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
|
||||
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
|
||||
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
|
||||
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
|
||||
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
|
||||
@@ -1014,32 +972,11 @@ checksums:
|
||||
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
|
||||
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
|
||||
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
|
||||
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
|
||||
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
|
||||
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
|
||||
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
|
||||
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
|
||||
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
|
||||
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
|
||||
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
||||
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
|
||||
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
|
||||
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
|
||||
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
|
||||
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
|
||||
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
|
||||
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
|
||||
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
|
||||
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
|
||||
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
|
||||
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
|
||||
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
|
||||
environments/settings/enterprise/license_status_active: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
environments/settings/enterprise/license_status_description: 828e4527f606471cd8cf58b55ff824f6
|
||||
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_unreachable: 202b110dab099f1167b13c326349e570
|
||||
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/recheck_license: b913b64f89df184b5059710f4a0b26fa
|
||||
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_success: 700ddd805be904a415f614de3df1da78
|
||||
environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175
|
||||
@@ -1380,7 +1316,6 @@ checksums:
|
||||
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
|
||||
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
||||
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
|
||||
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
|
||||
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
|
||||
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
|
||||
@@ -1638,7 +1573,6 @@ checksums:
|
||||
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
|
||||
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
|
||||
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
|
||||
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
|
||||
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
|
||||
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
|
||||
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
|
||||
@@ -1648,12 +1582,10 @@ checksums:
|
||||
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
|
||||
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
|
||||
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
|
||||
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
|
||||
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
|
||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
|
||||
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||
@@ -2922,7 +2854,7 @@ checksums:
|
||||
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
||||
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
||||
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940
|
||||
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
|
||||
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
|
||||
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
||||
@@ -3175,7 +3107,7 @@ checksums:
|
||||
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
|
||||
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
|
||||
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
|
||||
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
|
||||
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
|
||||
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
|
||||
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
|
||||
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("Crypto Utils", () => {
|
||||
// But both should verify correctly
|
||||
expect(await verifySecret(secret, hash1)).toBe(true);
|
||||
expect(await verifySecret(secret, hash2)).toBe(true);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
test("should use custom cost factor", async () => {
|
||||
const secret = "test-secret-123";
|
||||
|
||||
@@ -85,6 +85,7 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
STRIPE_PRICING_TABLE_ID: z.string().optional(),
|
||||
PUBLIC_URL: z
|
||||
.url()
|
||||
.refine(
|
||||
@@ -202,6 +203,7 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
|
||||
@@ -308,7 +308,12 @@ export const deleteOrganization = async (organizationId: string) => {
|
||||
|
||||
const stripeCustomerId = deletedOrganization.billing?.stripeCustomerId;
|
||||
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
|
||||
await cleanupStripeCustomer(stripeCustomerId);
|
||||
cleanupStripeCustomer(stripeCustomerId).catch((error) => {
|
||||
logger.error(
|
||||
{ error, organizationId, stripeCustomerId },
|
||||
"Failed to clean up Stripe customer after organization deletion"
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -18,18 +18,6 @@ describe("Time Utilities", () => {
|
||||
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", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(convertDateTimeStringShort("")).toBe("");
|
||||
});
|
||||
@@ -101,18 +75,6 @@ describe("Time Utilities", () => {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
|
||||
});
|
||||
|
||||
test("should format time since in Brazilian Portuguese", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
|
||||
});
|
||||
|
||||
test("should format time since in European Portuguese", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSinceDate", () => {
|
||||
@@ -121,12 +83,6 @@ describe("Time Utilities", () => {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
|
||||
});
|
||||
|
||||
test("should format time since from Date object in the provided locale", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
@@ -134,18 +90,6 @@ describe("Time Utilities", () => {
|
||||
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
|
||||
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", () => {
|
||||
|
||||
+36
-26
@@ -1,11 +1,8 @@
|
||||
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 { TUserLocale } from "@formbricks/types/user";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "./utils/datetime";
|
||||
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
|
||||
export const convertDateString = (dateString: string | null, locale: string = DEFAULT_LOCALE) => {
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
if (dateString === null) return null;
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
@@ -15,25 +12,41 @@ export const convertDateString = (dateString: string | null, locale: string = DE
|
||||
if (isNaN(date.getTime())) {
|
||||
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) {
|
||||
return dateString;
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return formatDateTimeForDisplay(date, locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
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) {
|
||||
return dateString;
|
||||
}
|
||||
@@ -48,12 +61,12 @@ export const convertDateTimeStringShort = (dateString: string, locale: string =
|
||||
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);
|
||||
return intlFormat(
|
||||
date,
|
||||
@@ -63,12 +76,12 @@ export const convertTimeString = (dateString: string, locale: string = DEFAULT_L
|
||||
second: "2-digit",
|
||||
},
|
||||
{
|
||||
locale,
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getLocaleForTimeSince = (locale: TUserLocale | string) => {
|
||||
const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
switch (locale) {
|
||||
case "de-DE":
|
||||
return de;
|
||||
@@ -98,12 +111,10 @@ const getLocaleForTimeSince = (locale: TUserLocale | string) => {
|
||||
return zhCN;
|
||||
case "zh-Hant-TW":
|
||||
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);
|
||||
return formatDistance(date, new Date(), {
|
||||
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(), {
|
||||
addSuffix: true,
|
||||
locale: getLocaleForTimeSince(locale),
|
||||
});
|
||||
};
|
||||
|
||||
export const formatDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => {
|
||||
return formatDateForDisplay(date, locale, {
|
||||
export const formatDate = (date: Date) => {
|
||||
return intlFormat(date, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}, {});
|
||||
};
|
||||
@@ -1,12 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
diffInDays,
|
||||
formatDateForDisplay,
|
||||
formatDateTimeForDisplay,
|
||||
formatDateWithOrdinal,
|
||||
getFormattedDateTimeString,
|
||||
isValidDateString,
|
||||
} from "./datetime";
|
||||
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
|
||||
|
||||
describe("datetime utils", () => {
|
||||
test("diffInDays calculates the difference in days between two dates", () => {
|
||||
@@ -15,45 +8,13 @@ describe("datetime utils", () => {
|
||||
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
|
||||
// Using noon ensures the date won't change in most timezones
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
|
||||
|
||||
expect(formatDateWithOrdinal(date)).toBe(
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("formatDateForDisplay uses the provided locale", () => {
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
|
||||
|
||||
expect(formatDateForDisplay(date, "de-DE")).toBe(
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("formatDateTimeForDisplay uses the provided locale", () => {
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
|
||||
|
||||
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
|
||||
new Intl.DateTimeFormat("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date)
|
||||
);
|
||||
// Test the function
|
||||
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
|
||||
});
|
||||
|
||||
test("isValidDateString validates correct date strings", () => {
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
|
||||
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
const relevantDigits = day < 30 ? day % 20 : day % 30;
|
||||
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
|
||||
};
|
||||
|
||||
// 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));
|
||||
};
|
||||
|
||||
export const formatDateForDisplay = (
|
||||
date: Date,
|
||||
locale: string = DEFAULT_LOCALE,
|
||||
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS
|
||||
): string => {
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
};
|
||||
|
||||
export const formatDateTimeForDisplay = (
|
||||
date: Date,
|
||||
locale: string = DEFAULT_LOCALE,
|
||||
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
|
||||
): string => {
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
};
|
||||
|
||||
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
|
||||
return formatDateForDisplay(date, locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
|
||||
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
|
||||
const day = date.getDate();
|
||||
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
|
||||
const year = date.getFullYear();
|
||||
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
||||
};
|
||||
|
||||
export const 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)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value)
|
||||
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1")
|
||||
: value;
|
||||
|
||||
const date = new Date(normalizedValue);
|
||||
return !Number.isNaN(date.getTime());
|
||||
const date = new Date(value);
|
||||
return date;
|
||||
};
|
||||
|
||||
export const getFormattedDateTimeString = (date: Date): string => {
|
||||
|
||||
@@ -32,17 +32,16 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/date-display", () => ({
|
||||
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => {
|
||||
if (value === "2023-01-01") {
|
||||
return `formatted-${locale}-${format ?? "iso"}`;
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
isValidDateString: vi.fn((value) => {
|
||||
try {
|
||||
return !isNaN(new Date(value as string).getTime());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === "01-02-2023" && format === "M-d-y") {
|
||||
return `legacy-${locale}-${format}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
formatDateWithOrdinal: vi.fn(() => {
|
||||
return "January 1st, 2023";
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -478,20 +477,7 @@ describe("recall utility functions", () => {
|
||||
};
|
||||
|
||||
const result = parseRecallInfo(text, responseData);
|
||||
expect(result).toBe("You joined on formatted-en-US-iso");
|
||||
});
|
||||
|
||||
test("formats legacy date values using the provided locale and stored format", () => {
|
||||
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
|
||||
const responseData: TResponseData = {
|
||||
joinDate: "01-02-2023",
|
||||
};
|
||||
|
||||
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
|
||||
joinDate: "M-d-y",
|
||||
});
|
||||
|
||||
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
|
||||
expect(result).toBe("You joined on January 1st, 2023");
|
||||
});
|
||||
|
||||
test("formats array values as comma-separated list", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
|
||||
export interface fallbacks {
|
||||
[id: string]: string;
|
||||
@@ -224,9 +224,7 @@ export const parseRecallInfo = (
|
||||
text: string,
|
||||
responseData?: TResponseData,
|
||||
variables?: TResponseVariables,
|
||||
withSlash: boolean = false,
|
||||
locale: string = "en-US",
|
||||
dateFormats?: TSurveyDateFormatMap
|
||||
withSlash: boolean = false
|
||||
) => {
|
||||
let modifiedText = text;
|
||||
const questionIds = responseData ? Object.keys(responseData) : [];
|
||||
@@ -256,14 +254,12 @@ export const parseRecallInfo = (
|
||||
value = responseData[recallItemId];
|
||||
|
||||
// Apply formatting for special value types
|
||||
if (typeof value === "string") {
|
||||
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
|
||||
|
||||
if (formattedDate) {
|
||||
value = formattedDate;
|
||||
if (value) {
|
||||
if (isValidDateString(value as string)) {
|
||||
value = formatDateWithOrdinal(new Date(value as string));
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-87
@@ -294,7 +294,6 @@
|
||||
"new": "Neu",
|
||||
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
|
||||
"next": "Weiter",
|
||||
"no_actions_found": "Keine Aktionen gefunden",
|
||||
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
||||
"no_code": "No Code",
|
||||
"no_files_uploaded": "Keine Dateien hochgeladen",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
|
||||
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
|
||||
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
|
||||
"powered_by_formbricks": "Bereitgestellt von Formbricks",
|
||||
"preview": "Vorschau",
|
||||
"preview_survey": "Umfragevorschau",
|
||||
"privacy": "Datenschutz",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.",
|
||||
"sort_by": "Sortieren nach",
|
||||
"start_free_trial": "Kostenlose Testversion starten",
|
||||
"start_free_trial": "Kostenlos starten",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Titel",
|
||||
"top_left": "Oben links",
|
||||
"top_right": "Oben rechts",
|
||||
"trial_days_remaining": "Noch {count} Tage in deiner Testphase",
|
||||
"trial_expired": "Deine Testphase ist abgelaufen",
|
||||
"trial_one_day_remaining": "Noch 1 Tag in deiner Testphase",
|
||||
"try_again": "Versuch's nochmal",
|
||||
"type": "Typ",
|
||||
"unknown_survey": "Unbekannte Umfrage",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Aktualisierung",
|
||||
"updated": "Aktualisiert",
|
||||
"updated_at": "Aktualisiert am",
|
||||
"upgrade_plan": "Plan upgraden",
|
||||
"upload": "Hochladen",
|
||||
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Zahlungsmethode hinzufügen",
|
||||
"add_payment_method_to_upgrade_tooltip": "Bitte füge oben eine Zahlungsmethode hinzu, um auf einen kostenpflichtigen Plan zu upgraden",
|
||||
"billing_interval_toggle": "Abrechnungsintervall",
|
||||
"current_plan_badge": "Aktuell",
|
||||
"current_plan_cta": "Aktueller Tarif",
|
||||
"custom_plan_description": "Deine Organisation nutzt ein individuelles Abrechnungsmodell. Du kannst trotzdem zu einem der Standardtarife unten wechseln.",
|
||||
"custom_plan_title": "Individueller Tarif",
|
||||
"cancelling": "Wird storniert",
|
||||
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"keep_current_plan": "Aktuellen Tarif beibehalten",
|
||||
"manage_billing_details": "Kartendaten & Rechnungen verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"most_popular": "Am beliebtesten",
|
||||
"pending_change_removed": "Geplante Tarifänderung entfernt.",
|
||||
"pending_plan_badge": "Geplant",
|
||||
"pending_plan_change_description": "Dein Tarif wechselt am {{date}} zu {{plan}}.",
|
||||
"pending_plan_change_title": "Geplante Tarifänderung",
|
||||
"pending_plan_cta": "Geplant",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_change_applied": "Tarif erfolgreich aktualisiert.",
|
||||
"plan_change_scheduled": "Tarifänderung erfolgreich geplant.",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Alles aus Hobby",
|
||||
"plan_feature_everything_in_pro": "Alles aus Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Für Einzelpersonen und kleine Teams, die mit Formbricks Cloud starten.",
|
||||
"plan_hobby_feature_responses": "250 Antworten / Monat",
|
||||
"plan_hobby_feature_workspaces": "1 Arbeitsbereich",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Für wachsende Teams, die höhere Limits, Automatisierungen und dynamische Überschreitungen benötigen.",
|
||||
"plan_pro_feature_responses": "2.000 Antworten / Monat (dynamische Überschreitung)",
|
||||
"plan_pro_feature_workspaces": "3 Arbeitsbereiche",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Für größere Teams, die mehr Kapazität, stärkere Governance und höheres Antwortvolumen benötigen.",
|
||||
"plan_scale_feature_responses": "5.000 Antworten / Monat (dynamische Mehrnutzung)",
|
||||
"plan_scale_feature_workspaces": "5 Arbeitsbereiche",
|
||||
"plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle dann direkt in Formbricks den Plan.",
|
||||
"plan_selection_title": "Wähle deinen Plan",
|
||||
"plan_unknown": "Unbekannt",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"retry_setup": "Erneut einrichten",
|
||||
"scale_banner_description": "Schalte höhere Limits, Teamzusammenarbeit und erweiterte Sicherheitsfunktionen mit dem Scale-Tarif frei.",
|
||||
"scale_banner_title": "Bereit für den nächsten Schritt?",
|
||||
"scale_feature_api": "Vollständiger API-Zugang",
|
||||
"scale_feature_quota": "Quotenverwaltung",
|
||||
"scale_feature_spam": "Spamschutz",
|
||||
"scale_feature_teams": "Teams & Zugriffsrollen",
|
||||
"select_plan_header_subtitle": "Keine Kreditkarte erforderlich, keine versteckten Bedingungen.",
|
||||
"select_plan_header_title": "Nahtlos integrierte Umfragen, 100% deine Marke.",
|
||||
"select_plan_header_title": "Versende noch heute professionelle Umfragen ohne Branding!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Ich möchte beim Hobby-Plan bleiben",
|
||||
"stripe_setup_incomplete": "Abrechnungseinrichtung unvollständig",
|
||||
"stripe_setup_incomplete_description": "Die Abrechnungseinrichtung war nicht erfolgreich. Bitte versuche es erneut, um Dein Abo zu aktivieren.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Verwalte Dein Abonnement und behalte Deine Nutzung im Blick",
|
||||
"switch_at_period_end": "Am Ende der Periode wechseln",
|
||||
"switch_plan_now": "Plan jetzt wechseln",
|
||||
"this_includes": "Das beinhaltet",
|
||||
"trial_alert_description": "Füge eine Zahlungsmethode hinzu, um weiterhin Zugriff auf alle Funktionen zu behalten.",
|
||||
"trial_already_used": "Für diese E-Mail-Adresse wurde bereits eine kostenlose Testversion genutzt. Bitte upgraden Sie stattdessen auf einen kostenpflichtigen Plan.",
|
||||
"trial_feature_api_access": "API-Zugriff",
|
||||
"trial_feature_attribute_segmentation": "Attributbasierte Segmentierung",
|
||||
"trial_feature_contact_segment_management": "Kontakt- & Segmentverwaltung",
|
||||
"trial_feature_email_followups": "E-Mail-Nachfassaktionen",
|
||||
"trial_feature_hide_branding": "Formbricks-Branding ausblenden",
|
||||
"trial_feature_mobile_sdks": "iOS & Android SDKs",
|
||||
"trial_feature_respondent_identification": "Befragten-Identifikation",
|
||||
"trial_feature_unlimited_seats": "Unbegrenzte Benutzerplätze",
|
||||
"trial_feature_webhooks": "Individuelle Webhooks",
|
||||
"trial_feature_api_access": "Vollen API-Zugriff erhalten",
|
||||
"trial_feature_collaboration": "Alle Team- und Kollaborationsfunktionen",
|
||||
"trial_feature_email_followups": "E-Mail-Nachfassaktionen einrichten",
|
||||
"trial_feature_quotas": "Kontingente verwalten",
|
||||
"trial_feature_webhooks": "Benutzerdefinierte Webhooks einrichten",
|
||||
"trial_feature_whitelabel": "Vollständig white-labeled Umfragen",
|
||||
"trial_no_credit_card": "14 Tage Testversion, keine Kreditkarte erforderlich",
|
||||
"trial_payment_method_added_description": "Alles bereit! Dein Pro-Tarif läuft nach Ende der Testphase automatisch weiter.",
|
||||
"trial_title": "Hol dir Formbricks Pro kostenlos!",
|
||||
"trial_title": "Pro-Funktionen kostenlos testen!",
|
||||
"unlimited_responses": "Unbegrenzte Antworten",
|
||||
"unlimited_workspaces": "Unbegrenzte Projekte",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_now": "Jetzt upgraden",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "verwendet",
|
||||
"yearly": "Jährlich",
|
||||
"yearly_checkout_unavailable": "Die jährliche Abrechnung ist noch nicht verfügbar. Füge zuerst eine Zahlungsmethode bei einem monatlichen Plan hinzu oder kontaktiere den Support.",
|
||||
"your_plan": "Dein Tarif"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Unternehmensfunktionen",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
|
||||
"license_feature_access_control": "Zugriffskontrolle (RBAC)",
|
||||
"license_feature_audit_logs": "Audit-Protokolle",
|
||||
"license_feature_contacts": "Kontakte & Segmente",
|
||||
"license_feature_projects": "Arbeitsbereiche",
|
||||
"license_feature_quotas": "Kontingente",
|
||||
"license_feature_remove_branding": "Branding entfernen",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Spam-Schutz",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
|
||||
"license_feature_whitelabel": "White-Label-E-Mails",
|
||||
"license_features_table_access": "Zugriff",
|
||||
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
|
||||
"license_features_table_disabled": "Deaktiviert",
|
||||
"license_features_table_enabled": "Aktiviert",
|
||||
"license_features_table_feature": "Funktion",
|
||||
"license_features_table_title": "Lizenzierte Funktionen",
|
||||
"license_features_table_unlimited": "Unbegrenzt",
|
||||
"license_features_table_value": "Wert",
|
||||
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
|
||||
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
|
||||
"license_status": "Lizenzstatus",
|
||||
"license_status_active": "Aktiv",
|
||||
"license_status_description": "Status deiner Enterprise-Lizenz.",
|
||||
"license_status_expired": "Abgelaufen",
|
||||
"license_status_instance_mismatch": "An andere Instanz gebunden",
|
||||
"license_status_invalid": "Ungültige Lizenz",
|
||||
"license_status_unreachable": "Nicht erreichbar",
|
||||
"license_unreachable_grace_period": "Der Lizenzserver ist nicht erreichbar. Deine Enterprise-Funktionen bleiben während einer 3-tägigen Kulanzfrist bis zum {gracePeriodEnd} aktiv.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Fragen? Bitte melde Dich bei",
|
||||
"recheck_license": "Lizenz erneut prüfen",
|
||||
"recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.",
|
||||
"recheck_license_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_success": "Lizenzprüfung erfolgreich",
|
||||
"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",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
|
||||
"everyone": "Jeder",
|
||||
"expand_preview": "Vorschau erweitern",
|
||||
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
@@ -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_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
|
||||
"spam_protection_threshold_heading": "Antwortschwelle",
|
||||
"shrink_preview": "Vorschau verkleinern",
|
||||
"star": "Stern",
|
||||
"starts_with": "Fängt an mit",
|
||||
"state": "Bundesland",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
|
||||
"subheading": "Zwischenüberschrift",
|
||||
"subtract": "Subtrahieren -",
|
||||
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
|
||||
"survey_completed_heading": "Umfrage abgeschlossen",
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
"survey_placement": "Platzierung der Umfrage",
|
||||
"survey_preview": "Umfragevorschau 👀",
|
||||
"survey_styling": "Umfrage Styling",
|
||||
"survey_trigger": "Auslöser der Umfrage",
|
||||
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nein, danke!",
|
||||
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
|
||||
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
|
||||
"preview_survey_question_open_text_headline": "Mö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_subheader": "Dein Feedback hilft uns, besser zu werden.",
|
||||
"preview_survey_welcome_card_headline": "Willkommen!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
|
||||
"coming_soon_title": "Wir sind fast da!",
|
||||
"follow_up_label": "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?",
|
||||
"generate_button": "Workflow generieren",
|
||||
"heading": "Welchen Workflow möchtest du erstellen?",
|
||||
|
||||
+19
-87
@@ -294,7 +294,6 @@
|
||||
"new": "New",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
"next": "Next",
|
||||
"no_actions_found": "No actions found",
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_code": "No code",
|
||||
"no_files_uploaded": "No files were uploaded",
|
||||
@@ -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": "Powered by Formbricks",
|
||||
"preview": "Preview",
|
||||
"preview_survey": "Preview Survey",
|
||||
"privacy": "Privacy Policy",
|
||||
@@ -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",
|
||||
"start_free_trial": "Start Free Trial",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Title",
|
||||
"top_left": "Top Left",
|
||||
"top_right": "Top Right",
|
||||
"trial_days_remaining": "{count} days left in your trial",
|
||||
"trial_expired": "Your trial has expired",
|
||||
"trial_one_day_remaining": "1 day left in your trial",
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Unknown survey",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
"updated_at": "Updated at",
|
||||
"upgrade_plan": "Upgrade plan",
|
||||
"upload": "Upload",
|
||||
"upload_failed": "Upload failed. Please try again.",
|
||||
"upload_input_description": "Click or drag to upload files.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Add payment method",
|
||||
"add_payment_method_to_upgrade_tooltip": "Please add a payment method above to upgrade to a paid plan",
|
||||
"billing_interval_toggle": "Billing interval",
|
||||
"current_plan_badge": "Current",
|
||||
"current_plan_cta": "Current plan",
|
||||
"custom_plan_description": "Your organization is on a custom billing setup. You can still switch to one of the standard plans below.",
|
||||
"custom_plan_title": "Custom plan",
|
||||
"cancelling": "Cancelling",
|
||||
"failed_to_start_trial": "Failed to start trial. Please try again.",
|
||||
"keep_current_plan": "Keep current plan",
|
||||
"manage_billing_details": "Manage card details & invoices",
|
||||
"monthly": "Monthly",
|
||||
"most_popular": "Most popular",
|
||||
"pending_change_removed": "Scheduled plan change removed.",
|
||||
"pending_plan_badge": "Scheduled",
|
||||
"pending_plan_change_description": "Your plan will switch to {{plan}} on {{date}}.",
|
||||
"pending_plan_change_title": "Scheduled plan change",
|
||||
"pending_plan_cta": "Scheduled",
|
||||
"per_month": "per month",
|
||||
"per_year": "per year",
|
||||
"plan_change_applied": "Plan updated successfully.",
|
||||
"plan_change_scheduled": "Plan change scheduled successfully.",
|
||||
"manage_subscription": "Manage subscription",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Everything in Hobby",
|
||||
"plan_feature_everything_in_pro": "Everything in Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "For individuals and small teams getting started with Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 responses / month",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "For growing teams that need higher limits, automations, and dynamic overages.",
|
||||
"plan_pro_feature_responses": "2,000 responses / month (dynamic overage)",
|
||||
"plan_pro_feature_workspaces": "3 workspaces",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "For larger teams that need more capacity, stronger governance, and higher response volume.",
|
||||
"plan_scale_feature_responses": "5,000 responses / month (dynamic overage)",
|
||||
"plan_scale_feature_workspaces": "5 workspaces",
|
||||
"plan_selection_description": "Compare Hobby, Pro, and Scale, then switch plans directly from Formbricks.",
|
||||
"plan_selection_title": "Choose your plan",
|
||||
"plan_unknown": "Unknown",
|
||||
"remove_branding": "Remove Branding",
|
||||
"retry_setup": "Retry setup",
|
||||
"scale_banner_description": "Unlock higher limits, team collaboration, and advanced security features with the Scale plan.",
|
||||
"scale_banner_title": "Ready to scale up?",
|
||||
"scale_feature_api": "Full API Access",
|
||||
"scale_feature_quota": "Quota Management",
|
||||
"scale_feature_spam": "Spam Protection",
|
||||
"scale_feature_teams": "Teams & Access Roles",
|
||||
"select_plan_header_subtitle": "No credit card required, no strings attached.",
|
||||
"select_plan_header_title": "Seamlessly integrated surveys, 100% your brand.",
|
||||
"select_plan_header_title": "Ship professional, unbranded surveys today!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "I want to stay on the Hobby plan",
|
||||
"stripe_setup_incomplete": "Billing setup incomplete",
|
||||
"stripe_setup_incomplete_description": "Billing setup did not complete successfully. Please retry to activate your subscription.",
|
||||
"subscription": "Subscription",
|
||||
"subscription_description": "Manage your subscription plan and monitor your usage",
|
||||
"switch_at_period_end": "Switch at period end",
|
||||
"switch_plan_now": "Switch plan now",
|
||||
"this_includes": "This includes",
|
||||
"trial_alert_description": "Add a payment method to keep access to all features.",
|
||||
"trial_already_used": "A free trial has already been used for this email address. Please upgrade to a paid plan instead.",
|
||||
"trial_feature_api_access": "API Access",
|
||||
"trial_feature_attribute_segmentation": "Attribute-based Segmentation",
|
||||
"trial_feature_contact_segment_management": "Contact & Segment Management",
|
||||
"trial_feature_email_followups": "Email Follow-ups",
|
||||
"trial_feature_hide_branding": "Hide Formbricks Branding",
|
||||
"trial_feature_mobile_sdks": "iOS & Android SDKs",
|
||||
"trial_feature_respondent_identification": "Respondent Identification",
|
||||
"trial_feature_unlimited_seats": "Unlimited Seats",
|
||||
"trial_feature_webhooks": "Custom Webhooks",
|
||||
"trial_feature_api_access": "Get full API access",
|
||||
"trial_feature_collaboration": "All team & collaboration features",
|
||||
"trial_feature_email_followups": "Setup email follow-ups",
|
||||
"trial_feature_quotas": "Manage quotas",
|
||||
"trial_feature_webhooks": "Setup custom webhooks",
|
||||
"trial_feature_whitelabel": "Fully white-labeled surveys",
|
||||
"trial_no_credit_card": "14 days trial, no credit card required",
|
||||
"trial_payment_method_added_description": "You're all set! Your Pro plan will continue automatically after the trial ends.",
|
||||
"trial_title": "Get Formbricks Pro for free!",
|
||||
"trial_title": "Try Pro features for free!",
|
||||
"unlimited_responses": "Unlimited Responses",
|
||||
"unlimited_workspaces": "Unlimited Workspaces",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_now": "Upgrade now",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "used",
|
||||
"yearly": "Yearly",
|
||||
"yearly_checkout_unavailable": "Yearly checkout is not available yet. Add a payment method on a monthly plan first or contact support.",
|
||||
"your_plan": "Your plan"
|
||||
},
|
||||
"domain": {
|
||||
@@ -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": "Access control (RBAC)",
|
||||
"license_feature_audit_logs": "Audit logs",
|
||||
"license_feature_contacts": "Contacts & Segments",
|
||||
"license_feature_projects": "Workspaces",
|
||||
"license_feature_quotas": "Quotas",
|
||||
"license_feature_remove_branding": "Remove branding",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Spam protection",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Two-factor authentication",
|
||||
"license_feature_whitelabel": "White-label emails",
|
||||
"license_features_table_access": "Access",
|
||||
"license_features_table_description": "Enterprise features and limits currently available to this instance.",
|
||||
"license_features_table_disabled": "Disabled",
|
||||
"license_features_table_enabled": "Enabled",
|
||||
"license_features_table_feature": "Feature",
|
||||
"license_features_table_title": "Licensed Features",
|
||||
"license_features_table_unlimited": "Unlimited",
|
||||
"license_features_table_value": "Value",
|
||||
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
|
||||
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
|
||||
"license_status": "License Status",
|
||||
"license_status_active": "Active",
|
||||
"license_status_description": "Status of your enterprise license.",
|
||||
"license_status_expired": "Expired",
|
||||
"license_status_instance_mismatch": "Bound to Another Instance",
|
||||
"license_status_invalid": "Invalid License",
|
||||
"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}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Questions? Please reach out to",
|
||||
"recheck_license": "Recheck license",
|
||||
"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_success": "License check successful",
|
||||
"recheck_license_unreachable": "License server is unreachable. Please try again later.",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
|
||||
"everyone": "Everyone",
|
||||
"expand_preview": "Expand Preview",
|
||||
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
|
||||
"fallback_missing": "Fallback missing",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
@@ -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_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
|
||||
"spam_protection_threshold_heading": "Response threshold",
|
||||
"shrink_preview": "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": "Add a heading to the custom survey closed message.",
|
||||
"survey_completed_heading": "Survey Completed",
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
"survey_placement": "Survey Placement",
|
||||
"survey_preview": "Survey Preview 👀",
|
||||
"survey_styling": "Survey styling",
|
||||
"survey_trigger": "Survey Trigger",
|
||||
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "No, thank you!",
|
||||
"preview_survey_question_2_headline": "Want to stay in the loop?",
|
||||
"preview_survey_question_2_subheader": "This is an example description.",
|
||||
"preview_survey_question_open_text_headline": "Anything else you 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_subheader": "Your feedback helps us improve.",
|
||||
"preview_survey_welcome_card_headline": "Welcome!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
|
||||
"coming_soon_title": "We are almost there!",
|
||||
"follow_up_label": "Is there anything else you 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?",
|
||||
"generate_button": "Generate workflow",
|
||||
"heading": "What workflow do you want to create?",
|
||||
|
||||
+16
-84
@@ -294,7 +294,6 @@
|
||||
"new": "Nuevo",
|
||||
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
|
||||
"next": "Siguiente",
|
||||
"no_actions_found": "No se encontraron acciones",
|
||||
"no_background_image_found": "No se encontró imagen de fondo.",
|
||||
"no_code": "Sin código",
|
||||
"no_files_uploaded": "No se subieron archivos",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
|
||||
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
|
||||
"powered_by_formbricks": "Desarrollado por Formbricks",
|
||||
"preview": "Vista previa",
|
||||
"preview_survey": "Vista previa de la encuesta",
|
||||
"privacy": "Política de privacidad",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Título",
|
||||
"top_left": "Superior izquierda",
|
||||
"top_right": "Superior derecha",
|
||||
"trial_days_remaining": "{count} días restantes en tu prueba",
|
||||
"trial_expired": "Tu prueba ha expirado",
|
||||
"trial_one_day_remaining": "1 día restante en tu prueba",
|
||||
"try_again": "Intentar de nuevo",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Encuesta desconocida",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Actualizar",
|
||||
"updated": "Actualizado",
|
||||
"updated_at": "Actualizado el",
|
||||
"upgrade_plan": "Mejorar plan",
|
||||
"upload": "Subir",
|
||||
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
|
||||
"upload_input_description": "Haz clic o arrastra para subir archivos.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Gestiona las claves API para acceder a las APIs de gestión de Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Añadir método de pago",
|
||||
"add_payment_method_to_upgrade_tooltip": "Por favor, añade un método de pago arriba para mejorar a un plan de pago",
|
||||
"billing_interval_toggle": "Intervalo de facturación",
|
||||
"current_plan_badge": "Actual",
|
||||
"current_plan_cta": "Plan actual",
|
||||
"custom_plan_description": "Tu organización tiene una configuración de facturación personalizada. Aún puedes cambiar a uno de los planes estándar a continuación.",
|
||||
"custom_plan_title": "Plan personalizado",
|
||||
"cancelling": "Cancelando",
|
||||
"failed_to_start_trial": "No se pudo iniciar la prueba. Por favor, inténtalo de nuevo.",
|
||||
"keep_current_plan": "Mantener plan actual",
|
||||
"manage_billing_details": "Gestionar datos de tarjeta y facturas",
|
||||
"monthly": "Mensual",
|
||||
"most_popular": "Más popular",
|
||||
"pending_change_removed": "Cambio de plan programado eliminado.",
|
||||
"pending_plan_badge": "Programado",
|
||||
"pending_plan_change_description": "Tu plan cambiará a {{plan}} el {{date}}.",
|
||||
"pending_plan_change_title": "Cambio de plan programado",
|
||||
"pending_plan_cta": "Programado",
|
||||
"per_month": "por mes",
|
||||
"per_year": "por año",
|
||||
"plan_change_applied": "Plan actualizado correctamente.",
|
||||
"plan_change_scheduled": "Cambio de plan programado correctamente.",
|
||||
"manage_subscription": "Gestionar suscripción",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Todo lo de Hobby",
|
||||
"plan_feature_everything_in_pro": "Todo lo de Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Para individuos y equipos pequeños que comienzan con Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 respuestas / mes",
|
||||
"plan_hobby_feature_workspaces": "1 espacio de trabajo",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Para equipos en crecimiento que necesitan límites más altos, automatizaciones y excesos dinámicos.",
|
||||
"plan_pro_feature_responses": "2.000 respuestas / mes (uso excedente dinámico)",
|
||||
"plan_pro_feature_workspaces": "3 espacios de trabajo",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Para equipos más grandes que necesitan mayor capacidad, gobernanza más sólida y mayor volumen de respuestas.",
|
||||
"plan_scale_feature_responses": "5.000 respuestas/mes (excedente dinámico)",
|
||||
"plan_scale_feature_workspaces": "5 espacios de trabajo",
|
||||
"plan_selection_description": "Compara Hobby, Pro y Scale, y cambia de plan directamente desde Formbricks.",
|
||||
"plan_selection_title": "Elige tu plan",
|
||||
"plan_unknown": "Desconocido",
|
||||
"remove_branding": "Eliminar marca",
|
||||
"retry_setup": "Reintentar configuración",
|
||||
"scale_banner_description": "Desbloquea límites superiores, colaboración en equipo y funciones de seguridad avanzadas con el plan Scale.",
|
||||
"scale_banner_title": "¿Listo para crecer?",
|
||||
"scale_feature_api": "Acceso completo a la API",
|
||||
"scale_feature_quota": "Gestión de cuota",
|
||||
"scale_feature_spam": "Protección contra spam",
|
||||
"scale_feature_teams": "Equipos y roles de acceso",
|
||||
"select_plan_header_subtitle": "Sin tarjeta de crédito, sin compromisos.",
|
||||
"select_plan_header_title": "Encuestas perfectamente integradas, 100% tu marca.",
|
||||
"select_plan_header_title": "¡Lanza encuestas profesionales sin marca hoy mismo!",
|
||||
"status_trialing": "Prueba",
|
||||
"stay_on_hobby_plan": "Quiero quedarme en el plan Hobby",
|
||||
"stripe_setup_incomplete": "Configuración de facturación incompleta",
|
||||
"stripe_setup_incomplete_description": "La configuración de facturación no se completó correctamente. Por favor, vuelve a intentarlo para activar tu suscripción.",
|
||||
"subscription": "Suscripción",
|
||||
"subscription_description": "Gestiona tu plan de suscripción y monitorea tu uso",
|
||||
"switch_at_period_end": "Cambiar al final del período",
|
||||
"switch_plan_now": "Cambiar de plan ahora",
|
||||
"this_includes": "Esto incluye",
|
||||
"trial_alert_description": "Añade un método de pago para mantener el acceso a todas las funciones.",
|
||||
"trial_already_used": "Ya se ha utilizado una prueba gratuita para esta dirección de correo electrónico. Por favor, actualiza a un plan de pago.",
|
||||
"trial_feature_api_access": "Acceso a la API",
|
||||
"trial_feature_attribute_segmentation": "Segmentación basada en atributos",
|
||||
"trial_feature_contact_segment_management": "Gestión de contactos y segmentos",
|
||||
"trial_feature_email_followups": "Seguimientos por correo electrónico",
|
||||
"trial_feature_hide_branding": "Ocultar la marca Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs para iOS y Android",
|
||||
"trial_feature_respondent_identification": "Identificación de encuestados",
|
||||
"trial_feature_unlimited_seats": "Asientos ilimitados",
|
||||
"trial_feature_webhooks": "Webhooks personalizados",
|
||||
"trial_feature_api_access": "Acceso completo a la API",
|
||||
"trial_feature_collaboration": "Todas las funciones de equipo y colaboración",
|
||||
"trial_feature_email_followups": "Configurar seguimientos por correo electrónico",
|
||||
"trial_feature_quotas": "Gestionar cuotas",
|
||||
"trial_feature_webhooks": "Configurar webhooks personalizados",
|
||||
"trial_feature_whitelabel": "Encuestas totalmente personalizadas",
|
||||
"trial_no_credit_card": "Prueba de 14 días, sin tarjeta de crédito",
|
||||
"trial_payment_method_added_description": "¡Todo listo! Tu plan Pro continuará automáticamente cuando termine el periodo de prueba.",
|
||||
"trial_title": "¡Consigue Formbricks Pro gratis!",
|
||||
"trial_title": "¡Prueba las funciones Pro gratis!",
|
||||
"unlimited_responses": "Respuestas ilimitadas",
|
||||
"unlimited_workspaces": "Proyectos ilimitados",
|
||||
"upgrade": "Actualizar",
|
||||
"upgrade_now": "Actualizar ahora",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "usados",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "El pago anual aún no está disponible. Primero añade un método de pago en un plan mensual o contacta con soporte.",
|
||||
"your_plan": "Tu plan"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Características empresariales",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
|
||||
"license_feature_access_control": "Control de acceso (RBAC)",
|
||||
"license_feature_audit_logs": "Registros de auditoría",
|
||||
"license_feature_contacts": "Contactos y segmentos",
|
||||
"license_feature_projects": "Espacios de trabajo",
|
||||
"license_feature_quotas": "Cuotas",
|
||||
"license_feature_remove_branding": "Eliminar marca",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Protección contra spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autenticación de dos factores",
|
||||
"license_feature_whitelabel": "Correos sin marca",
|
||||
"license_features_table_access": "Acceso",
|
||||
"license_features_table_description": "Funciones y límites empresariales disponibles actualmente para esta instancia.",
|
||||
"license_features_table_disabled": "Desactivado",
|
||||
"license_features_table_enabled": "Activado",
|
||||
"license_features_table_feature": "Función",
|
||||
"license_features_table_title": "Funciones con licencia",
|
||||
"license_features_table_unlimited": "Ilimitado",
|
||||
"license_features_table_value": "Valor",
|
||||
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
|
||||
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
|
||||
"license_status": "Estado de la licencia",
|
||||
"license_status_active": "Activa",
|
||||
"license_status_description": "Estado de tu licencia enterprise.",
|
||||
"license_status_expired": "Caducada",
|
||||
"license_status_instance_mismatch": "Vinculada a Otra Instancia",
|
||||
"license_status_invalid": "Licencia no válida",
|
||||
"license_status_unreachable": "Inaccesible",
|
||||
"license_unreachable_grace_period": "No se puede acceder al servidor de licencias. Tus funciones empresariales permanecen activas durante un período de gracia de 3 días que finaliza el {gracePeriodEnd}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "¿Preguntas? Por favor, contacta con",
|
||||
"recheck_license": "Volver a comprobar licencia",
|
||||
"recheck_license_failed": "Error al comprobar la licencia. Es posible que el servidor de licencias no esté disponible.",
|
||||
"recheck_license_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_success": "Comprobación de licencia correcta",
|
||||
"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",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
|
||||
"everyone": "Todos",
|
||||
"expand_preview": "Expandir vista previa",
|
||||
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
|
||||
"fallback_missing": "Falta respaldo",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
|
||||
@@ -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_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",
|
||||
"shrink_preview": "Contraer vista previa",
|
||||
"star": "Estrella",
|
||||
"starts_with": "Comienza con",
|
||||
"state": "Estado",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Restar -",
|
||||
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
|
||||
"survey_completed_heading": "Encuesta completada",
|
||||
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
|
||||
"survey_display_settings": "Ajustes de visualización de la encuesta",
|
||||
"survey_placement": "Ubicación de la encuesta",
|
||||
"survey_preview": "Vista previa de la encuesta 👀",
|
||||
"survey_styling": "Estilo del formulario",
|
||||
"survey_trigger": "Activador de la encuesta",
|
||||
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
|
||||
|
||||
+19
-87
@@ -294,7 +294,6 @@
|
||||
"new": "Nouveau",
|
||||
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
|
||||
"next": "Suivant",
|
||||
"no_actions_found": "Aucune action trouvée",
|
||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||
"no_code": "Sans code",
|
||||
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
|
||||
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
|
||||
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
|
||||
"powered_by_formbricks": "Propulsé par Formbricks",
|
||||
"preview": "Aperçu",
|
||||
"preview_survey": "Aperçu de l'enquête",
|
||||
"privacy": "Politique de confidentialité",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "Quelque chose s'est mal passé.",
|
||||
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"sort_by": "Trier par",
|
||||
"start_free_trial": "Commencer l'essai gratuit",
|
||||
"start_free_trial": "Essayer gratuitement",
|
||||
"status": "Statut",
|
||||
"step_by_step_manual": "Manuel étape par étape",
|
||||
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Titre",
|
||||
"top_left": "En haut à gauche",
|
||||
"top_right": "En haut à droite",
|
||||
"trial_days_remaining": "{count} jours restants dans votre période d'essai",
|
||||
"trial_expired": "Votre période d'essai a expiré",
|
||||
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
|
||||
"try_again": "Réessayer",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Enquête inconnue",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Mise à jour",
|
||||
"updated": "Mise à jour",
|
||||
"updated_at": "Mis à jour à",
|
||||
"upgrade_plan": "Améliorer le forfait",
|
||||
"upload": "Télécharger",
|
||||
"upload_failed": "Échec du téléchargement. Veuillez réessayer.",
|
||||
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks."
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Ajouter un moyen de paiement",
|
||||
"add_payment_method_to_upgrade_tooltip": "Veuillez ajouter un moyen de paiement ci-dessus pour passer à un forfait payant",
|
||||
"billing_interval_toggle": "Intervalle de facturation",
|
||||
"current_plan_badge": "Actuel",
|
||||
"current_plan_cta": "Formule actuelle",
|
||||
"custom_plan_description": "Votre organisation dispose d'une configuration de facturation personnalisée. Tu peux toujours basculer vers l'une des formules standard ci-dessous.",
|
||||
"custom_plan_title": "Formule personnalisée",
|
||||
"cancelling": "Annulation en cours",
|
||||
"failed_to_start_trial": "Échec du démarrage de l'essai. Réessaye.",
|
||||
"keep_current_plan": "Conserver la formule actuelle",
|
||||
"manage_billing_details": "Gérer les détails de la carte et les factures",
|
||||
"monthly": "Mensuel",
|
||||
"most_popular": "Le plus populaire",
|
||||
"pending_change_removed": "Changement de formule programmé supprimé.",
|
||||
"pending_plan_badge": "Programmé",
|
||||
"pending_plan_change_description": "Ta formule passera à {{plan}} le {{date}}.",
|
||||
"pending_plan_change_title": "Changement de formule programmé",
|
||||
"pending_plan_cta": "Programmé",
|
||||
"per_month": "par mois",
|
||||
"per_year": "par an",
|
||||
"plan_change_applied": "Formule mise à jour avec succès.",
|
||||
"plan_change_scheduled": "Changement de formule programmé avec succès.",
|
||||
"manage_subscription": "Gérer l'abonnement",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tout ce qui est inclus dans Hobby",
|
||||
"plan_feature_everything_in_pro": "Tout ce qui est inclus dans Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Pour les particuliers et les petites équipes qui débutent avec Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 réponses / mois",
|
||||
"plan_hobby_feature_workspaces": "1 espace de travail",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Pour les équipes en croissance qui ont besoin de limites plus élevées, d'automatisations et de dépassements dynamiques.",
|
||||
"plan_pro_feature_responses": "2 000 réponses / mois (dépassement dynamique)",
|
||||
"plan_pro_feature_workspaces": "3 espaces de travail",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Pour les grandes équipes qui ont besoin de plus de capacité, d'une meilleure gouvernance et d'un volume de réponses plus élevé.",
|
||||
"plan_scale_feature_responses": "5 000 réponses / mois (dépassement dynamique)",
|
||||
"plan_scale_feature_workspaces": "5 espaces de travail",
|
||||
"plan_selection_description": "Compare les formules Hobby, Pro et Scale, puis change de formule directement depuis Formbricks.",
|
||||
"plan_selection_title": "Choisis ta formule",
|
||||
"plan_unknown": "Inconnu",
|
||||
"remove_branding": "Suppression du logo",
|
||||
"retry_setup": "Réessayer la configuration",
|
||||
"scale_banner_description": "Débloque des limites plus élevées, la collaboration en équipe et des fonctionnalités de sécurité avancées avec l’offre 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 d’accès",
|
||||
"select_plan_header_subtitle": "Aucune carte bancaire requise, aucun engagement.",
|
||||
"select_plan_header_title": "Sondages parfaitement intégrés, 100 % à ton image.",
|
||||
"select_plan_header_title": "Envoyez des sondages professionnels et personnalisés dès aujourd'hui !",
|
||||
"status_trialing": "Essai",
|
||||
"stay_on_hobby_plan": "Je veux rester sur le plan Hobby",
|
||||
"stripe_setup_incomplete": "Configuration de la facturation incomplète",
|
||||
"stripe_setup_incomplete_description": "La configuration de la facturation n’a pas abouti. Merci de réessayer pour activer ton abonnement.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Gère ton abonnement et surveille ta consommation",
|
||||
"switch_at_period_end": "Changer à la fin de la période",
|
||||
"switch_plan_now": "Changer de formule maintenant",
|
||||
"this_includes": "Cela inclut",
|
||||
"trial_alert_description": "Ajoute un moyen de paiement pour conserver l'accès à toutes les fonctionnalités.",
|
||||
"trial_already_used": "Un essai gratuit a déjà été utilisé pour cette adresse e-mail. Passe plutôt à un plan payant.",
|
||||
"trial_feature_api_access": "Accès API",
|
||||
"trial_feature_attribute_segmentation": "Segmentation basée sur les attributs",
|
||||
"trial_feature_contact_segment_management": "Gestion des contacts et segments",
|
||||
"trial_feature_email_followups": "Relances par e-mail",
|
||||
"trial_feature_hide_branding": "Masquer l'image de marque Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs iOS et Android",
|
||||
"trial_feature_respondent_identification": "Identification des répondants",
|
||||
"trial_feature_unlimited_seats": "Places illimitées",
|
||||
"trial_feature_webhooks": "Webhooks personnalisés",
|
||||
"trial_feature_api_access": "Accès complet à l'API",
|
||||
"trial_feature_collaboration": "Toutes les fonctionnalités d'équipe et de collaboration",
|
||||
"trial_feature_email_followups": "Configure des relances par e-mail",
|
||||
"trial_feature_quotas": "Gère les quotas",
|
||||
"trial_feature_webhooks": "Configure des webhooks personnalisés",
|
||||
"trial_feature_whitelabel": "Enquêtes entièrement en marque blanche",
|
||||
"trial_no_credit_card": "Essai de 14 jours, aucune carte bancaire requise",
|
||||
"trial_payment_method_added_description": "Tout est prêt ! Votre abonnement Pro se poursuivra automatiquement après la fin de la période d'essai.",
|
||||
"trial_title": "Obtenez Formbricks Pro gratuitement !",
|
||||
"trial_title": "Essaie les fonctionnalités Pro gratuitement !",
|
||||
"unlimited_responses": "Réponses illimitées",
|
||||
"unlimited_workspaces": "Projets illimités",
|
||||
"upgrade": "Mise à niveau",
|
||||
"upgrade_now": "Passer à la formule supérieure maintenant",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilisé(s)",
|
||||
"yearly": "Annuel",
|
||||
"yearly_checkout_unavailable": "Le paiement annuel n'est pas encore disponible. Ajoute d'abord un moyen de paiement sur un forfait mensuel ou contacte le support.",
|
||||
"your_plan": "Ton offre"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Fonctionnalités d'entreprise",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
|
||||
"license_feature_access_control": "Contrôle d'accès (RBAC)",
|
||||
"license_feature_audit_logs": "Journaux d'audit",
|
||||
"license_feature_contacts": "Contacts et segments",
|
||||
"license_feature_projects": "Espaces de travail",
|
||||
"license_feature_quotas": "Quotas",
|
||||
"license_feature_remove_branding": "Retirer l'image de marque",
|
||||
"license_feature_saml": "SSO SAML",
|
||||
"license_feature_spam_protection": "Protection anti-spam",
|
||||
"license_feature_sso": "SSO OIDC",
|
||||
"license_feature_two_factor_auth": "Authentification à deux facteurs",
|
||||
"license_feature_whitelabel": "E-mails en marque blanche",
|
||||
"license_features_table_access": "Accès",
|
||||
"license_features_table_description": "Fonctionnalités Enterprise et limites actuellement disponibles pour cette instance.",
|
||||
"license_features_table_disabled": "Désactivé",
|
||||
"license_features_table_enabled": "Activé",
|
||||
"license_features_table_feature": "Fonctionnalité",
|
||||
"license_features_table_title": "Fonctionnalités sous licence",
|
||||
"license_features_table_unlimited": "Illimité",
|
||||
"license_features_table_value": "Valeur",
|
||||
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
|
||||
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
|
||||
"license_status": "Statut de la licence",
|
||||
"license_status_active": "Active",
|
||||
"license_status_description": "Statut de votre licence entreprise.",
|
||||
"license_status_expired": "Expirée",
|
||||
"license_status_instance_mismatch": "Liée à une autre instance",
|
||||
"license_status_invalid": "Licence invalide",
|
||||
"license_status_unreachable": "Inaccessible",
|
||||
"license_unreachable_grace_period": "Le serveur de licence est injoignable. Vos fonctionnalités entreprise restent actives pendant une période de grâce de 3 jours se terminant le {gracePeriodEnd}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Des questions ? Veuillez contacter",
|
||||
"recheck_license": "Revérifier la licence",
|
||||
"recheck_license_failed": "La vérification de la licence a échoué. Le serveur de licences est peut-être inaccessible.",
|
||||
"recheck_license_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_success": "Vérification de la licence réussie",
|
||||
"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",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses ; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
|
||||
"everyone": "Tout le monde",
|
||||
"expand_preview": "Agrandir l'aperçu",
|
||||
"external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher l’hameçonnage.",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
@@ -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_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",
|
||||
"shrink_preview": "Réduire l'aperçu",
|
||||
"star": "Étoile",
|
||||
"starts_with": "Commence par",
|
||||
"state": "État",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
|
||||
"subheading": "Sous-titre",
|
||||
"subtract": "Soustraire -",
|
||||
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
|
||||
"survey_completed_heading": "Enquête terminée",
|
||||
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
|
||||
"survey_display_settings": "Paramètres d'affichage de l'enquête",
|
||||
"survey_placement": "Placement de l'enquête",
|
||||
"survey_preview": "Aperçu du sondage 👀",
|
||||
"survey_styling": "Style de formulaire",
|
||||
"survey_trigger": "Déclencheur d'enquête",
|
||||
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Non, merci !",
|
||||
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
|
||||
"preview_survey_question_2_subheader": "Ceci est un exemple de description.",
|
||||
"preview_survey_question_open_text_headline": "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_subheader": "Vos commentaires nous aident à nous améliorer.",
|
||||
"preview_survey_welcome_card_headline": "Bienvenue !",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous ! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
|
||||
"coming_soon_title": "Nous y sommes presque !",
|
||||
"follow_up_label": "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 ?",
|
||||
"generate_button": "Générer le workflow",
|
||||
"heading": "Quel workflow souhaitez-vous créer ?",
|
||||
|
||||
+59
-127
@@ -175,7 +175,7 @@
|
||||
"copy_code": "Kód másolása",
|
||||
"copy_link": "Hivatkozás másolása",
|
||||
"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_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}}",
|
||||
@@ -294,7 +294,6 @@
|
||||
"new": "Új",
|
||||
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
|
||||
"next": "Következő",
|
||||
"no_actions_found": "Nem találhatók műveletek",
|
||||
"no_background_image_found": "Nem található háttérkép.",
|
||||
"no_code": "Kód nélkül",
|
||||
"no_files_uploaded": "Nem lettek fájlok feltöltve",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet",
|
||||
"please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót",
|
||||
"please_upgrade_your_plan": "Váltson magasabb csomagra",
|
||||
"powered_by_formbricks": "A gépházban: Formbricks",
|
||||
"preview": "Előnézet",
|
||||
"preview_survey": "Kérdőív előnézete",
|
||||
"privacy": "Adatvédelmi irányelvek",
|
||||
@@ -362,7 +360,7 @@
|
||||
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
|
||||
"replace": "Csere",
|
||||
"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",
|
||||
"response": "Válasz",
|
||||
"response_id": "Válaszazonosító",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "Valami probléma történt",
|
||||
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
|
||||
"sort_by": "Rendezési sorrend",
|
||||
"start_free_trial": "Ingyenes próbaidőszak indítása",
|
||||
"start_free_trial": "Ingyenes próba indítása",
|
||||
"status": "Állapot",
|
||||
"step_by_step_manual": "Lépésenkénti kézikönyv",
|
||||
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Cím",
|
||||
"top_left": "Balra fent",
|
||||
"top_right": "Jobbra fent",
|
||||
"trial_days_remaining": "{count} nap van hátra a próbaidőszakából",
|
||||
"trial_expired": "A próbaidőszaka lejárt",
|
||||
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
|
||||
"try_again": "Próbálja újra",
|
||||
"type": "Típus",
|
||||
"unknown_survey": "Ismeretlen kérdőív",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Frissítés",
|
||||
"updated": "Frissítve",
|
||||
"updated_at": "Frissítve",
|
||||
"upgrade_plan": "Magasabb csomagra váltás",
|
||||
"upload": "Feltöltés",
|
||||
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.",
|
||||
"upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
|
||||
@@ -539,7 +533,7 @@
|
||||
"survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése",
|
||||
"text_variable": "Szöveg változó",
|
||||
"verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:",
|
||||
"verification_email_heading": "Már majdnem kész vagyunk!",
|
||||
"verification_email_heading": "Már majdnem megvagyunk!",
|
||||
"verification_email_hey": "Helló 👋",
|
||||
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
|
||||
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
|
||||
@@ -607,15 +601,15 @@
|
||||
"test_match": "Illeszkedés tesztelése",
|
||||
"test_your_url": "URL tesztelése",
|
||||
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.",
|
||||
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet 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_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
|
||||
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
|
||||
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
|
||||
"time_in_seconds": "Idő másodpercben",
|
||||
"time_in_seconds_placeholder": "például 10",
|
||||
"time_in_seconds_placeholder": "pl. 10",
|
||||
"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_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",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "API-kulcsok kezelése a Formbricks kezelő API-jaihoz való hozzáféréshez"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Fizetési mód hozzáadása",
|
||||
"add_payment_method_to_upgrade_tooltip": "Adjon hozzá fizetési módot fent, hogy fizetős csomagra váltson",
|
||||
"billing_interval_toggle": "Számlázási időköz",
|
||||
"current_plan_badge": "Jelenlegi",
|
||||
"current_plan_cta": "Jelenlegi csomag",
|
||||
"custom_plan_description": "A szervezete egyéni számlázási beállítással rendelkezik. Ugyanakkor áttérhet az alábbi szabványos csomagok egyikére.",
|
||||
"custom_plan_title": "Egyéni csomag",
|
||||
"failed_to_start_trial": "Nem sikerült a próbaidőszak indítása. Próbálja meg újra.",
|
||||
"keep_current_plan": "Jelenlegi csomag megtartása",
|
||||
"manage_billing_details": "Kártyarészletek és számlák kezelése",
|
||||
"monthly": "Havi",
|
||||
"most_popular": "Legnépszerűbb",
|
||||
"pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
|
||||
"pending_plan_badge": "Ütemezett",
|
||||
"pending_plan_change_description": "A csomagja {{plan}} csomagra fog váltani ekkor: {{date}}.",
|
||||
"pending_plan_change_title": "Ütemezett csomagváltoztatás",
|
||||
"pending_plan_cta": "Ütemezett",
|
||||
"per_month": "havonta",
|
||||
"per_year": "évente",
|
||||
"plan_change_applied": "A csomag sikeresen frissítve.",
|
||||
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
|
||||
"plan_custom": "Egyéni",
|
||||
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
|
||||
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
|
||||
"plan_hobby": "Hobbi",
|
||||
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
|
||||
"plan_hobby_feature_responses": "250 válasz/hónap",
|
||||
"plan_hobby_feature_workspaces": "1 munkaterület",
|
||||
"cancelling": "Lemondás folyamatban",
|
||||
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
|
||||
"manage_subscription": "Előfizetés kezelése",
|
||||
"plan_custom": "Custom",
|
||||
"plan_hobby": "Hobby",
|
||||
"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_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_scale": "Scale",
|
||||
"plan_unknown": "Ismeretlen",
|
||||
"remove_branding": "Márkajel eltávolítása",
|
||||
"retry_setup": "Beállítás újrapróbálása",
|
||||
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
|
||||
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
|
||||
"status_trialing": "Próbaidőszak",
|
||||
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
|
||||
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
|
||||
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
|
||||
"retry_setup": "Újrapróbálkozás a beállítással",
|
||||
"scale_banner_description": "Nagyobb limitek, csapatmunka és fejlett biztonsági funkciók a Scale csomaggal.",
|
||||
"scale_banner_title": "Készen áll a növekedésre?",
|
||||
"scale_feature_api": "Teljes API hozzáférés",
|
||||
"scale_feature_quota": "Keretkezelés",
|
||||
"scale_feature_spam": "Spamvédelem",
|
||||
"scale_feature_teams": "Csapatok és hozzáférési szerepkörök",
|
||||
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
|
||||
"select_plan_header_title": "Küldjön professzionális, márkajelzés nélküli felméréseket még ma!",
|
||||
"status_trialing": "Próbaverzió",
|
||||
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
|
||||
"stripe_setup_incomplete": "Számlázás beállítása nem teljes",
|
||||
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.",
|
||||
"subscription": "Előfizetés",
|
||||
"subscription_description": "Az előfizetési csomag kezelése és a használat felügyelete",
|
||||
"switch_at_period_end": "Váltás az időszak végén",
|
||||
"switch_plan_now": "Csomag váltása most",
|
||||
"this_includes": "Ezeket tartalmazza",
|
||||
"trial_alert_description": "Fizetési mód hozzáadása az összes funkcióhoz való hozzáférés megtartásához.",
|
||||
"trial_already_used": "Ehhez az e-mail-címhez már használatban van egy ingyenes próbaidőszak. Váltson inkább fizetős csomagra.",
|
||||
"trial_feature_api_access": "API-hozzáférés",
|
||||
"trial_feature_attribute_segmentation": "Attribútumalapú szakaszolás",
|
||||
"trial_feature_contact_segment_management": "Partner- és szakaszkezelés",
|
||||
"trial_feature_email_followups": "E-mailes utókövetések",
|
||||
"trial_feature_hide_branding": "Formbricks márkajel elrejtése",
|
||||
"trial_feature_mobile_sdks": "iOS és Android SDK-k",
|
||||
"trial_feature_respondent_identification": "Válaszadó-azonosítás",
|
||||
"trial_feature_unlimited_seats": "Korlátlan számú hely",
|
||||
"trial_feature_webhooks": "Egyéni webhorgok",
|
||||
"trial_no_credit_card": "14 napos próbaidőszak, nincs szükség hitelkártyára",
|
||||
"trial_payment_method_added_description": "Mindent beállított! A Pro csomagja a próbaidőszak vége után automatikusan folytatódik.",
|
||||
"trial_title": "Szerezze meg a Formbricks Pro csomagot ingyen!",
|
||||
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot",
|
||||
"trial_already_used": "Ehhez az e-mail címhez már igénybe vettek ingyenes próbaidőszakot. Kérjük, válasszon helyette fizetős csomagot.",
|
||||
"trial_feature_api_access": "Teljes API-hozzáférés megszerzése",
|
||||
"trial_feature_collaboration": "Minden csapat- és együttműködési funkció",
|
||||
"trial_feature_email_followups": "E-mail követések beállítása",
|
||||
"trial_feature_quotas": "Kvóták kezelése",
|
||||
"trial_feature_webhooks": "Egyéni webhookok beállítása",
|
||||
"trial_feature_whitelabel": "Teljesen fehércímkés felmérések",
|
||||
"trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül",
|
||||
"trial_title": "Próbálja ki a Pro funkciókat ingyen!",
|
||||
"unlimited_responses": "Korlátlan válaszok",
|
||||
"unlimited_workspaces": "Korlátlan munkaterület",
|
||||
"upgrade": "Frissítés",
|
||||
"upgrade_now": "Frissítés most",
|
||||
"usage_cycle": "Használati ciklus",
|
||||
"used": "használva",
|
||||
"yearly": "Évente",
|
||||
"yearly_checkout_unavailable": "Az éves fizetési lehetőség még nem érhető el. Először adjon hozzá fizetési módot egy havi csomaghoz, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "felhasználva",
|
||||
"your_plan": "Az Ön csomagja"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,48 +1031,26 @@
|
||||
"enterprise_features": "Vállalati funkciók",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
|
||||
"license_feature_access_control": "Hozzáférés-vezérlés (RBAC)",
|
||||
"license_feature_audit_logs": "Auditálási naplók",
|
||||
"license_feature_contacts": "Partnerek és szakaszok",
|
||||
"license_feature_projects": "Munkaterületek",
|
||||
"license_feature_quotas": "Kvóták",
|
||||
"license_feature_remove_branding": "Márkajel eltávolítása",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Szemét elleni védekezés",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
|
||||
"license_feature_whitelabel": "Fehér címkés e-mailek",
|
||||
"license_features_table_access": "Hozzáférés",
|
||||
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
|
||||
"license_features_table_disabled": "Letiltva",
|
||||
"license_features_table_enabled": "Engedélyezve",
|
||||
"license_features_table_feature": "Funkció",
|
||||
"license_features_table_title": "Licencelt funkciók",
|
||||
"license_features_table_unlimited": "Korlátlan",
|
||||
"license_features_table_value": "Érték",
|
||||
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks-példányhoz van kötve. Ha ezt a telepítést újraépítették vagy áthelyezték, akkor kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi példányhoz való kötést.",
|
||||
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
|
||||
"license_status": "Licencállapot",
|
||||
"license_status_active": "Aktív",
|
||||
"license_status_description": "A vállalati licenc állapota.",
|
||||
"license_status_expired": "Lejárt",
|
||||
"license_status_instance_mismatch": "Másik példányhoz kötve",
|
||||
"license_status_invalid": "Érvénytelen licenc",
|
||||
"license_status_unreachable": "Nem érhető el",
|
||||
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
|
||||
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos 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 :)",
|
||||
"on_request": "Kérésre",
|
||||
"organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)",
|
||||
"questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:",
|
||||
"recheck_license": "Licenc újraellenőrzése",
|
||||
"recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.",
|
||||
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks-példányhoz van kötve. Kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi kötést.",
|
||||
"recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.",
|
||||
"recheck_license_success": "A licencellenőrzés sikeres",
|
||||
"recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.",
|
||||
"rechecking": "Újraellenőrzés…",
|
||||
"request_30_day_trial_license": "30 napos próbaidőszaki licenc kérése",
|
||||
"request_30_day_trial_license": "30 napos ingyenes licenc kérése",
|
||||
"saml_sso": "SAML SSO",
|
||||
"service_level_agreement": "Szolgáltatási megállapodás",
|
||||
"soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés",
|
||||
@@ -1451,22 +1387,21 @@
|
||||
"error_saving_changes": "Hiba a változtatások mentésekor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).",
|
||||
"everyone": "Mindenki",
|
||||
"expand_preview": "Előnézet kinyitása",
|
||||
"external_urls_paywall_tooltip": "Váltson a magasabb fizetős csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
|
||||
"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.",
|
||||
"fallback_missing": "Tartalék hiányzik",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában",
|
||||
"field_name_eg_score_price": "Mező neve, például pontszám, ár",
|
||||
"first_name": "Keresztnév",
|
||||
"five_points_recommended": "5 pont (ajánlott)",
|
||||
"follow_ups": "Utókövetések",
|
||||
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt az utókövetést?",
|
||||
"follow_ups_delete_modal_title": "Törli az utókövetést?",
|
||||
"follow_ups": "Követések",
|
||||
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt a követést?",
|
||||
"follow_ups_delete_modal_title": "Törli a követést?",
|
||||
"follow_ups_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_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_empty_heading": "Automatikus követések küldése",
|
||||
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van a követésekben. A törlése eltávolítja az összes követésből. Biztosan törölni szeretné?",
|
||||
"follow_ups_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_variables": "Változó értékeinek felvétele",
|
||||
"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_label": "Címzett",
|
||||
"follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt",
|
||||
"follow_ups_modal_create_heading": "Új utókövetés létrehozása",
|
||||
"follow_ups_modal_created_successfull_toast": "Az utókövetés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_modal_edit_heading": "Az utókövetés szerkesztése",
|
||||
"follow_ups_modal_edit_no_id": "Nincs kérdőív-utókövetési azonosító megadva, nem lehet frissíteni a kérdőív utókövetését",
|
||||
"follow_ups_modal_name_label": "Utókövetés neve",
|
||||
"follow_ups_modal_name_placeholder": "Az utókövetés elnevezése",
|
||||
"follow_ups_modal_create_heading": "Új követés létrehozása",
|
||||
"follow_ups_modal_created_successfull_toast": "A követés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_modal_edit_heading": "A követés szerkesztése",
|
||||
"follow_ups_modal_edit_no_id": "Nincs kérdőívkövetési azonosító megadva, nem lehet frissíteni a kérdőívkövetést",
|
||||
"follow_ups_modal_name_label": "Követés neve",
|
||||
"follow_ups_modal_name_placeholder": "A követés elnevezése",
|
||||
"follow_ups_modal_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_type_ending": "A válaszadó egy adott befejezést lát",
|
||||
"follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ",
|
||||
"follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát",
|
||||
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
|
||||
"follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_new": "Új utókövetés",
|
||||
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_new": "Új követés",
|
||||
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
|
||||
"four_points": "4 pont",
|
||||
"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_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",
|
||||
"shrink_preview": "Előnézet összecsukása",
|
||||
"star": "Csillag",
|
||||
"starts_with": "Ezzel kezdődik",
|
||||
"state": "Állapot",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
|
||||
"subheading": "Alcím",
|
||||
"subtract": "Kivonás -",
|
||||
"survey_closed_message_heading_required": "Címsor hozzáadása az egyéni kérdőív záró üzenetéhez.",
|
||||
"survey_completed_heading": "A kérdőív kitöltve",
|
||||
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
|
||||
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
|
||||
"survey_placement": "Kérdőív elhelyezése",
|
||||
"survey_preview": "Kérdőív előnézete 👀",
|
||||
"survey_styling": "Kérdőív stílusának beállítása",
|
||||
"survey_trigger": "Kérdőív aktiválója",
|
||||
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
|
||||
@@ -2789,8 +2721,8 @@
|
||||
"evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…",
|
||||
"evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?",
|
||||
"evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…",
|
||||
"fake_door_follow_up_description": "Utókövetés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
|
||||
"fake_door_follow_up_name": "„Fake door” utókövetés",
|
||||
"fake_door_follow_up_description": "Követés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
|
||||
"fake_door_follow_up_name": "„Fake door” követés",
|
||||
"fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?",
|
||||
"fake_door_follow_up_question_1_lower_label": "Nem fontos",
|
||||
"fake_door_follow_up_question_1_upper_label": "Nagyon fontos",
|
||||
@@ -2799,7 +2731,7 @@
|
||||
"fake_door_follow_up_question_2_choice_3": "3. szempont",
|
||||
"fake_door_follow_up_question_2_choice_4": "4. szempont",
|
||||
"fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?",
|
||||
"feature_chaser_description": "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_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?",
|
||||
"feature_chaser_question_1_lower_label": "Nem fontos",
|
||||
|
||||
+16
-84
@@ -294,7 +294,6 @@
|
||||
"new": "新規",
|
||||
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
|
||||
"next": "次へ",
|
||||
"no_actions_found": "アクションが見つかりません",
|
||||
"no_background_image_found": "背景画像が見つかりません。",
|
||||
"no_code": "ノーコード",
|
||||
"no_files_uploaded": "ファイルがアップロードされていません",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
|
||||
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
|
||||
"please_upgrade_your_plan": "プランをアップグレードしてください",
|
||||
"powered_by_formbricks": "Powered by Formbricks",
|
||||
"preview": "プレビュー",
|
||||
"preview_survey": "フォームをプレビュー",
|
||||
"privacy": "プライバシーポリシー",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "タイトル",
|
||||
"top_left": "左上",
|
||||
"top_right": "右上",
|
||||
"trial_days_remaining": "トライアル期間の残り{count}日",
|
||||
"trial_expired": "トライアル期間が終了しました",
|
||||
"trial_one_day_remaining": "トライアル期間の残り1日",
|
||||
"try_again": "もう一度お試しください",
|
||||
"type": "種類",
|
||||
"unknown_survey": "不明なフォーム",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "更新",
|
||||
"updated": "更新済み",
|
||||
"updated_at": "更新日時",
|
||||
"upgrade_plan": "プランをアップグレード",
|
||||
"upload": "アップロード",
|
||||
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
|
||||
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Formbricks管理APIにアクセスするためのAPIキーを管理します"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "支払い方法を追加",
|
||||
"add_payment_method_to_upgrade_tooltip": "有料プランにアップグレードするには、上記で支払い方法を追加してください",
|
||||
"billing_interval_toggle": "請求間隔",
|
||||
"current_plan_badge": "現在のプラン",
|
||||
"current_plan_cta": "現在のプラン",
|
||||
"custom_plan_description": "あなたの組織はカスタム請求設定を利用しています。以下の標準プランに切り替えることもできます。",
|
||||
"custom_plan_title": "カスタムプラン",
|
||||
"cancelling": "キャンセル中",
|
||||
"failed_to_start_trial": "トライアルの開始に失敗しました。もう一度お試しください。",
|
||||
"keep_current_plan": "現在のプランを継続",
|
||||
"manage_billing_details": "カード情報と請求書を管理",
|
||||
"monthly": "月払い",
|
||||
"most_popular": "人気",
|
||||
"pending_change_removed": "予定されていたプラン変更を取り消しました。",
|
||||
"pending_plan_badge": "変更予定",
|
||||
"pending_plan_change_description": "{{date}}に{{plan}}へ切り替わります。",
|
||||
"pending_plan_change_title": "プラン変更の予定",
|
||||
"pending_plan_cta": "変更予定",
|
||||
"per_month": "/月",
|
||||
"per_year": "/年",
|
||||
"plan_change_applied": "プランを更新しました。",
|
||||
"plan_change_scheduled": "プラン変更を予約しました。",
|
||||
"manage_subscription": "サブスクリプションを管理",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Hobbyプランの全機能",
|
||||
"plan_feature_everything_in_pro": "Proプランの全機能",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Formbricks Cloudを始める個人や小規模チーム向けのプランです。",
|
||||
"plan_hobby_feature_responses": "月250回の回答",
|
||||
"plan_hobby_feature_workspaces": "1ワークスペース",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "より高い制限、自動化、動的なオーバーエージが必要な成長中のチーム向け。",
|
||||
"plan_pro_feature_responses": "月2,000回の回答(超過分は従量制)",
|
||||
"plan_pro_feature_workspaces": "3つのワークスペース",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "より多くの容量、強力なガバナンス、高いレスポンス量が必要な大規模チーム向け。",
|
||||
"plan_scale_feature_responses": "月間5,000レスポンス(動的な超過課金)",
|
||||
"plan_scale_feature_workspaces": "5つのワークスペース",
|
||||
"plan_selection_description": "Hobby、Pro、Scaleプランを比較して、Formbricksから直接プランを切り替えられます。",
|
||||
"plan_selection_title": "プランを選択",
|
||||
"plan_unknown": "不明",
|
||||
"remove_branding": "ブランディングを削除",
|
||||
"retry_setup": "セットアップを再試行",
|
||||
"scale_banner_description": "Scaleプランで、上限の引き上げ、チームでのコラボレーション、高度なセキュリティ機能を利用しましょう。",
|
||||
"scale_banner_title": "スケールアップの準備はできていますか?",
|
||||
"scale_feature_api": "APIフルアクセス",
|
||||
"scale_feature_quota": "クォータ管理",
|
||||
"scale_feature_spam": "スパム防止機能",
|
||||
"scale_feature_teams": "チーム&アクセス権限管理",
|
||||
"select_plan_header_subtitle": "クレジットカード不要、縛りなし。",
|
||||
"select_plan_header_title": "シームレスに統合されたアンケート、100%あなたのブランド。",
|
||||
"select_plan_header_title": "今すぐプロフェッショナルなブランドフリーのアンケートを配信しよう!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Hobbyプランを継続する",
|
||||
"stripe_setup_incomplete": "請求情報の設定が未完了",
|
||||
"stripe_setup_incomplete_description": "請求情報の設定が正常に完了しませんでした。もう一度やり直してサブスクリプションを有効化してください。",
|
||||
"subscription": "サブスクリプション",
|
||||
"subscription_description": "サブスクリプションプランの管理や利用状況の確認はこちら",
|
||||
"switch_at_period_end": "期間終了時に切り替え",
|
||||
"switch_plan_now": "今すぐプランを切り替え",
|
||||
"this_includes": "これには以下が含まれます",
|
||||
"trial_alert_description": "すべての機能へのアクセスを維持するには、支払い方法を追加してください。",
|
||||
"trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。",
|
||||
"trial_feature_api_access": "APIアクセス",
|
||||
"trial_feature_attribute_segmentation": "属性ベースのセグメンテーション",
|
||||
"trial_feature_contact_segment_management": "連絡先とセグメントの管理",
|
||||
"trial_feature_email_followups": "メールフォローアップ",
|
||||
"trial_feature_hide_branding": "Formbricksブランディングを非表示",
|
||||
"trial_feature_mobile_sdks": "iOS & Android SDK",
|
||||
"trial_feature_respondent_identification": "回答者の識別",
|
||||
"trial_feature_unlimited_seats": "無制限のシート数",
|
||||
"trial_feature_webhooks": "カスタムWebhook",
|
||||
"trial_feature_api_access": "フルAPIアクセスを利用",
|
||||
"trial_feature_collaboration": "すべてのチーム・コラボレーション機能",
|
||||
"trial_feature_email_followups": "メールフォローアップの設定",
|
||||
"trial_feature_quotas": "クォータの管理",
|
||||
"trial_feature_webhooks": "カスタムWebhookの設定",
|
||||
"trial_feature_whitelabel": "完全ホワイトラベル対応のアンケート",
|
||||
"trial_no_credit_card": "14日間トライアル、クレジットカード不要",
|
||||
"trial_payment_method_added_description": "準備完了です!トライアル終了後、Proプランが自動的に継続されます。",
|
||||
"trial_title": "Formbricks Proを無料で入手しよう!",
|
||||
"trial_title": "Pro機能を無料でお試し!",
|
||||
"unlimited_responses": "無制限の回答",
|
||||
"unlimited_workspaces": "無制限ワークスペース",
|
||||
"upgrade": "アップグレード",
|
||||
"upgrade_now": "今すぐアップグレード",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "使用済み",
|
||||
"yearly": "年間",
|
||||
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
|
||||
"your_plan": "ご利用プラン"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "エンタープライズ機能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
|
||||
"license_feature_access_control": "アクセス制御(RBAC)",
|
||||
"license_feature_audit_logs": "監査ログ",
|
||||
"license_feature_contacts": "連絡先とセグメント",
|
||||
"license_feature_projects": "ワークスペース",
|
||||
"license_feature_quotas": "クォータ",
|
||||
"license_feature_remove_branding": "ブランディングの削除",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "スパム保護",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "二要素認証",
|
||||
"license_feature_whitelabel": "ホワイトラベルメール",
|
||||
"license_features_table_access": "アクセス",
|
||||
"license_features_table_description": "このインスタンスで現在利用可能なエンタープライズ機能と制限。",
|
||||
"license_features_table_disabled": "無効",
|
||||
"license_features_table_enabled": "有効",
|
||||
"license_features_table_feature": "機能",
|
||||
"license_features_table_title": "ライセンス機能",
|
||||
"license_features_table_unlimited": "無制限",
|
||||
"license_features_table_value": "値",
|
||||
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
|
||||
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
|
||||
"license_status": "ライセンスステータス",
|
||||
"license_status_active": "有効",
|
||||
"license_status_description": "エンタープライズライセンスのステータス。",
|
||||
"license_status_expired": "期限切れ",
|
||||
"license_status_instance_mismatch": "別のインスタンスに紐付け済み",
|
||||
"license_status_invalid": "無効なライセンス",
|
||||
"license_status_unreachable": "接続不可",
|
||||
"license_unreachable_grace_period": "ライセンスサーバーに接続できません。エンタープライズ機能は{gracePeriodEnd}までの3日間の猶予期間中は引き続き利用できます。",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "質問はありますか?こちらまでお問い合わせください",
|
||||
"recheck_license": "ライセンスを再確認",
|
||||
"recheck_license_failed": "ライセンスの確認に失敗しました。ライセンスサーバーに接続できない可能性があります。",
|
||||
"recheck_license_instance_mismatch": "このライセンスは別のFormbricksインスタンスに紐付けられています。Formbricksサポートに連絡して、以前の紐付けを解除してもらってください。",
|
||||
"recheck_license_invalid": "ライセンスキーが無効です。ENTERPRISE_LICENSE_KEYを確認してください。",
|
||||
"recheck_license_success": "ライセンスの確認に成功しました",
|
||||
"recheck_license_unreachable": "ライセンスサーバーに接続できません。後ほど再度お試しください。",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "変更の保存中にエラーが発生しました",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
|
||||
"everyone": "全員",
|
||||
"expand_preview": "プレビューを展開",
|
||||
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。",
|
||||
"fallback_missing": "フォールバックがありません",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
@@ -1711,7 +1646,6 @@
|
||||
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
|
||||
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
|
||||
"spam_protection_threshold_heading": "回答のしきい値",
|
||||
"shrink_preview": "プレビューを縮小",
|
||||
"star": "星",
|
||||
"starts_with": "で始まる",
|
||||
"state": "都道府県",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
|
||||
"subheading": "サブ見出し",
|
||||
"subtract": "減算 -",
|
||||
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
|
||||
"survey_completed_heading": "フォームが完了しました",
|
||||
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
|
||||
"survey_display_settings": "フォーム表示設定",
|
||||
"survey_placement": "フォームの配置",
|
||||
"survey_preview": "アンケートプレビュー 👀",
|
||||
"survey_styling": "フォームのスタイル",
|
||||
"survey_trigger": "フォームのトリガー",
|
||||
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
|
||||
|
||||
+19
-87
@@ -294,7 +294,6 @@
|
||||
"new": "Nieuw",
|
||||
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
|
||||
"next": "Volgende",
|
||||
"no_actions_found": "Geen acties gevonden",
|
||||
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
|
||||
"no_code": "Geen code",
|
||||
"no_files_uploaded": "Er zijn geen bestanden geüpload",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
|
||||
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
|
||||
"please_upgrade_your_plan": "Upgrade je abonnement",
|
||||
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
|
||||
"preview": "Voorbeeld",
|
||||
"preview_survey": "Voorbeeld van enquête",
|
||||
"privacy": "Privacybeleid",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "Er is iets misgegaan",
|
||||
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"sort_by": "Sorteer op",
|
||||
"start_free_trial": "Start gratis proefperiode",
|
||||
"start_free_trial": "Gratis proefperiode starten",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Stap voor stap handleiding",
|
||||
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Titel",
|
||||
"top_left": "Linksboven",
|
||||
"top_right": "Rechtsboven",
|
||||
"trial_days_remaining": "{count} dagen over in je proefperiode",
|
||||
"trial_expired": "Je proefperiode is verlopen",
|
||||
"trial_one_day_remaining": "1 dag over in je proefperiode",
|
||||
"try_again": "Probeer het opnieuw",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Onbekende enquête",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Update",
|
||||
"updated": "Bijgewerkt",
|
||||
"updated_at": "Bijgewerkt op",
|
||||
"upgrade_plan": "Abonnement upgraden",
|
||||
"upload": "Uploaden",
|
||||
"upload_failed": "Upload mislukt. Probeer het opnieuw.",
|
||||
"upload_input_description": "Klik of sleep om bestanden te uploaden.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Beheer API-sleutels om toegang te krijgen tot Formbricks-beheer-API's"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Betaalmethode toevoegen",
|
||||
"add_payment_method_to_upgrade_tooltip": "Voeg hierboven een betaalmethode toe om te upgraden naar een betaald abonnement",
|
||||
"billing_interval_toggle": "Factureringsinterval",
|
||||
"current_plan_badge": "Huidig",
|
||||
"current_plan_cta": "Huidig abonnement",
|
||||
"custom_plan_description": "Je organisatie heeft een aangepaste factureringsopzet. Je kunt nog steeds overstappen naar een van de standaard abonnementen hieronder.",
|
||||
"custom_plan_title": "Aangepast abonnement",
|
||||
"cancelling": "Bezig met annuleren",
|
||||
"failed_to_start_trial": "Proefperiode starten mislukt. Probeer het opnieuw.",
|
||||
"keep_current_plan": "Huidig abonnement behouden",
|
||||
"manage_billing_details": "Kaartgegevens en facturen beheren",
|
||||
"monthly": "Maandelijks",
|
||||
"most_popular": "Meest populair",
|
||||
"pending_change_removed": "Geplande abonnementswijziging verwijderd.",
|
||||
"pending_plan_badge": "Gepland",
|
||||
"pending_plan_change_description": "Je abonnement wordt op {{date}} omgezet naar {{plan}}.",
|
||||
"pending_plan_change_title": "Geplande abonnementswijziging",
|
||||
"pending_plan_cta": "Gepland",
|
||||
"per_month": "per maand",
|
||||
"per_year": "per jaar",
|
||||
"plan_change_applied": "Abonnement succesvol bijgewerkt.",
|
||||
"plan_change_scheduled": "Abonnementswijziging succesvol ingepland.",
|
||||
"manage_subscription": "Abonnement beheren",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Alles in Hobby",
|
||||
"plan_feature_everything_in_pro": "Alles in Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Voor individuen en kleine teams die aan de slag gaan met Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 reacties / maand",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Voor groeiende teams die hogere limieten, automatiseringen en dynamische overschrijdingen nodig hebben.",
|
||||
"plan_pro_feature_responses": "2.000 reacties / maand (dynamische overschrijding)",
|
||||
"plan_pro_feature_workspaces": "3 werkruimtes",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Voor grotere teams die meer capaciteit, beter bestuur en een hoger responsvolume nodig hebben.",
|
||||
"plan_scale_feature_responses": "5.000 reacties / maand (dynamische overbrugging)",
|
||||
"plan_scale_feature_workspaces": "5 werkruimtes",
|
||||
"plan_selection_description": "Vergelijk Hobby, Pro en Scale, en schakel direct vanuit Formbricks tussen abonnementen.",
|
||||
"plan_selection_title": "Kies je abonnement",
|
||||
"plan_unknown": "Onbekend",
|
||||
"remove_branding": "Branding verwijderen",
|
||||
"retry_setup": "Opnieuw proberen",
|
||||
"scale_banner_description": "Ontgrendel hogere limieten, team samenwerking, en geavanceerde beveiligingsfuncties met het Scale-abonnement.",
|
||||
"scale_banner_title": "Klaar om op te schalen?",
|
||||
"scale_feature_api": "Volledige API-toegang",
|
||||
"scale_feature_quota": "Quotabeheer",
|
||||
"scale_feature_spam": "Spam-beveiliging",
|
||||
"scale_feature_teams": "Teams & toegangsrollen",
|
||||
"select_plan_header_subtitle": "Geen creditcard vereist, geen verplichtingen.",
|
||||
"select_plan_header_title": "Naadloos geïntegreerde enquêtes, 100% jouw merk.",
|
||||
"select_plan_header_title": "Verstuur vandaag nog professionele, ongemerkte enquêtes!",
|
||||
"status_trialing": "Proefperiode",
|
||||
"stay_on_hobby_plan": "Ik wil op het Hobby-abonnement blijven",
|
||||
"stripe_setup_incomplete": "Facturatie-instelling niet voltooid",
|
||||
"stripe_setup_incomplete_description": "Het instellen van de facturatie is niet gelukt. Probeer het opnieuw om je abonnement te activeren.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Beheer je abonnement en houd je gebruik bij",
|
||||
"switch_at_period_end": "Schakel aan het einde van de periode",
|
||||
"switch_plan_now": "Schakel nu van abonnement",
|
||||
"this_includes": "Dit omvat",
|
||||
"trial_alert_description": "Voeg een betaalmethode toe om toegang te houden tot alle functies.",
|
||||
"trial_already_used": "Er is al een gratis proefperiode gebruikt voor dit e-mailadres. Upgrade in plaats daarvan naar een betaald abonnement.",
|
||||
"trial_feature_api_access": "API-toegang",
|
||||
"trial_feature_attribute_segmentation": "Segmentatie op basis van attributen",
|
||||
"trial_feature_contact_segment_management": "Contact- en segmentbeheer",
|
||||
"trial_feature_email_followups": "E-mail follow-ups",
|
||||
"trial_feature_hide_branding": "Verberg Formbricks-branding",
|
||||
"trial_feature_mobile_sdks": "iOS- en Android-SDK's",
|
||||
"trial_feature_respondent_identification": "Identificatie van respondenten",
|
||||
"trial_feature_unlimited_seats": "Onbeperkt aantal gebruikers",
|
||||
"trial_feature_webhooks": "Aangepaste webhooks",
|
||||
"trial_feature_api_access": "Krijg volledige API-toegang",
|
||||
"trial_feature_collaboration": "Alle team- en samenwerkingsfuncties",
|
||||
"trial_feature_email_followups": "E-mail follow-ups instellen",
|
||||
"trial_feature_quotas": "Quota's beheren",
|
||||
"trial_feature_webhooks": "Aangepaste webhooks instellen",
|
||||
"trial_feature_whitelabel": "Volledig white-label enquêtes",
|
||||
"trial_no_credit_card": "14 dagen proefperiode, geen creditcard vereist",
|
||||
"trial_payment_method_added_description": "Je bent helemaal klaar! Je Pro-abonnement wordt automatisch voortgezet na afloop van de proefperiode.",
|
||||
"trial_title": "Krijg Formbricks Pro gratis!",
|
||||
"trial_title": "Probeer Pro-functies gratis!",
|
||||
"unlimited_responses": "Onbeperkte reacties",
|
||||
"unlimited_workspaces": "Onbeperkt werkruimtes",
|
||||
"upgrade": "Upgraden",
|
||||
"upgrade_now": "Nu upgraden",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "gebruikt",
|
||||
"yearly": "Jaarlijks",
|
||||
"yearly_checkout_unavailable": "Jaarlijkse checkout is nog niet beschikbaar. Voeg eerst een betaalmethode toe bij een maandelijks abonnement of neem contact op met support.",
|
||||
"your_plan": "Jouw abonnement"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Enterprise-functies",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
|
||||
"license_feature_access_control": "Toegangscontrole (RBAC)",
|
||||
"license_feature_audit_logs": "Auditlogboeken",
|
||||
"license_feature_contacts": "Contacten & Segmenten",
|
||||
"license_feature_projects": "Werkruimtes",
|
||||
"license_feature_quotas": "Quota's",
|
||||
"license_feature_remove_branding": "Branding verwijderen",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Spambescherming",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Tweefactorauthenticatie",
|
||||
"license_feature_whitelabel": "Whitelabel-e-mails",
|
||||
"license_features_table_access": "Toegang",
|
||||
"license_features_table_description": "Enterprise-functies en limieten die momenteel beschikbaar zijn voor deze instantie.",
|
||||
"license_features_table_disabled": "Uitgeschakeld",
|
||||
"license_features_table_enabled": "Ingeschakeld",
|
||||
"license_features_table_feature": "Functie",
|
||||
"license_features_table_title": "Gelicentieerde Functies",
|
||||
"license_features_table_unlimited": "Onbeperkt",
|
||||
"license_features_table_value": "Waarde",
|
||||
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
|
||||
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
|
||||
"license_status": "Licentiestatus",
|
||||
"license_status_active": "Actief",
|
||||
"license_status_description": "Status van je enterprise-licentie.",
|
||||
"license_status_expired": "Verlopen",
|
||||
"license_status_instance_mismatch": "Gekoppeld aan Andere Instantie",
|
||||
"license_status_invalid": "Ongeldige licentie",
|
||||
"license_status_unreachable": "Niet bereikbaar",
|
||||
"license_unreachable_grace_period": "Licentieserver is niet bereikbaar. Je enterprise functies blijven actief tijdens een respijtperiode van 3 dagen die eindigt op {gracePeriodEnd}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Vragen? Neem contact op met",
|
||||
"recheck_license": "Licentie opnieuw controleren",
|
||||
"recheck_license_failed": "Licentiecontrole mislukt. De licentieserver is mogelijk niet bereikbaar.",
|
||||
"recheck_license_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_success": "Licentiecontrole geslaagd",
|
||||
"recheck_license_unreachable": "Licentieserver is niet bereikbaar. Probeer het later opnieuw.",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "Fout bij het opslaan van wijzigingen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
|
||||
"everyone": "Iedereen",
|
||||
"expand_preview": "Voorbeeld uitvouwen",
|
||||
"external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.",
|
||||
"fallback_missing": "Terugval ontbreekt",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
|
||||
@@ -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_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
|
||||
"spam_protection_threshold_heading": "Reactiedrempel",
|
||||
"shrink_preview": "Voorbeeld invouwen",
|
||||
"star": "Ster",
|
||||
"starts_with": "Begint met",
|
||||
"state": "Staat",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
|
||||
"subheading": "Ondertitel",
|
||||
"subtract": "Aftrekken -",
|
||||
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
|
||||
"survey_completed_heading": "Enquête voltooid",
|
||||
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
|
||||
"survey_display_settings": "Enquêteweergave-instellingen",
|
||||
"survey_placement": "Enquête plaatsing",
|
||||
"survey_preview": "Enquêtevoorbeeld 👀",
|
||||
"survey_styling": "Vorm styling",
|
||||
"survey_trigger": "Enquêtetrigger",
|
||||
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nee, dank je!",
|
||||
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
|
||||
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
|
||||
"preview_survey_question_open_text_headline": "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_subheader": "Je feedback helpt ons verbeteren.",
|
||||
"preview_survey_welcome_card_headline": "Welkom!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
|
||||
"coming_soon_title": "We zijn er bijna!",
|
||||
"follow_up_label": "Is er nog iets dat 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?",
|
||||
"generate_button": "Genereer workflow",
|
||||
"heading": "Welke workflow wil je maken?",
|
||||
|
||||
+19
-87
@@ -294,7 +294,6 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
|
||||
"next": "Próximo",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||
"please_upgrade_your_plan": "Por favor, atualize seu plano",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"preview": "Prévia",
|
||||
"preview_survey": "Prévia da Pesquisa",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "Algo deu errado",
|
||||
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
|
||||
"sort_by": "Ordenar por",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Título",
|
||||
"top_left": "Canto superior esquerdo",
|
||||
"top_right": "Canto Superior Direito",
|
||||
"trial_days_remaining": "{count} dias restantes no seu período de teste",
|
||||
"trial_expired": "Seu período de teste expirou",
|
||||
"trial_one_day_remaining": "1 dia restante no seu período de teste",
|
||||
"try_again": "Tenta de novo",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Pesquisa desconhecida",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "atualizar",
|
||||
"updated": "atualizado",
|
||||
"updated_at": "Atualizado em",
|
||||
"upgrade_plan": "Fazer upgrade do plano",
|
||||
"upload": "Enviar",
|
||||
"upload_failed": "Falha no upload. Tente novamente.",
|
||||
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adicionar forma de pagamento",
|
||||
"add_payment_method_to_upgrade_tooltip": "Por favor, adicione uma forma de pagamento acima para fazer upgrade para um plano pago",
|
||||
"billing_interval_toggle": "Intervalo de cobrança",
|
||||
"current_plan_badge": "Atual",
|
||||
"current_plan_cta": "Plano atual",
|
||||
"custom_plan_description": "Sua organização está em uma configuração de cobrança personalizada. Você ainda pode mudar para um dos planos padrão abaixo.",
|
||||
"custom_plan_title": "Plano personalizado",
|
||||
"cancelling": "Cancelando",
|
||||
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tente novamente.",
|
||||
"keep_current_plan": "Manter plano atual",
|
||||
"manage_billing_details": "Gerenciar detalhes do cartão e faturas",
|
||||
"monthly": "Mensal",
|
||||
"most_popular": "Mais popular",
|
||||
"pending_change_removed": "Mudança de plano agendada removida.",
|
||||
"pending_plan_badge": "Agendado",
|
||||
"pending_plan_change_description": "Seu plano mudará para {{plan}} em {{date}}.",
|
||||
"pending_plan_change_title": "Mudança de plano agendada",
|
||||
"pending_plan_cta": "Agendado",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_change_applied": "Plano atualizado com sucesso.",
|
||||
"plan_change_scheduled": "Mudança de plano agendada com sucesso.",
|
||||
"manage_subscription": "Gerenciar assinatura",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tudo do Hobby",
|
||||
"plan_feature_everything_in_pro": "Tudo do Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Para indivíduos e pequenas equipes começando com o Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 respostas / mês",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Para equipes em crescimento que precisam de limites maiores, automações e excedentes dinâmicos.",
|
||||
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_pro_feature_workspaces": "3 espaços de trabalho",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Para equipes maiores que precisam de mais capacidade, governança mais forte e maior volume de respostas.",
|
||||
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_scale_feature_workspaces": "5 espaços de trabalho",
|
||||
"plan_selection_description": "Compare os planos Hobby, Pro e Scale e mude de plano diretamente no Formbricks.",
|
||||
"plan_selection_title": "Escolha seu plano",
|
||||
"plan_unknown": "desconhecido",
|
||||
"remove_branding": "Remover Marca",
|
||||
"retry_setup": "Tentar novamente",
|
||||
"scale_banner_description": "Desbloqueie limites maiores, colaboração em equipe e recursos avançados de segurança com o plano Scale.",
|
||||
"scale_banner_title": "Pronto para expandir?",
|
||||
"scale_feature_api": "Acesso completo à API",
|
||||
"scale_feature_quota": "Gestão de cota",
|
||||
"scale_feature_spam": "Proteção contra spam",
|
||||
"scale_feature_teams": "Equipes e papéis de acesso",
|
||||
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
|
||||
"select_plan_header_title": "Pesquisas perfeitamente integradas, 100% sua marca.",
|
||||
"select_plan_header_title": "Envie pesquisas profissionais e sem marca hoje mesmo!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Quero continuar no plano Hobby",
|
||||
"stripe_setup_incomplete": "Configuração de cobrança incompleta",
|
||||
"stripe_setup_incomplete_description": "A configuração de cobrança não foi concluída com sucesso. Tente novamente para ativar sua assinatura.",
|
||||
"subscription": "Assinatura",
|
||||
"subscription_description": "Gerencie seu plano de assinatura e acompanhe seu uso",
|
||||
"switch_at_period_end": "Mudar no final do período",
|
||||
"switch_plan_now": "Mudar de plano agora",
|
||||
"this_includes": "Isso inclui",
|
||||
"trial_alert_description": "Adicione uma forma de pagamento para manter o acesso a todos os recursos.",
|
||||
"trial_already_used": "Um período de teste gratuito já foi usado para este endereço de e-mail. Por favor, faça upgrade para um plano pago.",
|
||||
"trial_feature_api_access": "Acesso à API",
|
||||
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
|
||||
"trial_feature_contact_segment_management": "Gerenciamento de Contatos e Segmentos",
|
||||
"trial_feature_email_followups": "Follow-ups por E-mail",
|
||||
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
|
||||
"trial_feature_respondent_identification": "Identificação de Respondentes",
|
||||
"trial_feature_unlimited_seats": "Assentos Ilimitados",
|
||||
"trial_feature_webhooks": "Webhooks Personalizados",
|
||||
"trial_feature_api_access": "Obtenha acesso completo à API",
|
||||
"trial_feature_collaboration": "Todos os recursos de equipe e colaboração",
|
||||
"trial_feature_email_followups": "Configure acompanhamentos por e-mail",
|
||||
"trial_feature_quotas": "Gerencie cotas",
|
||||
"trial_feature_webhooks": "Configure webhooks personalizados",
|
||||
"trial_feature_whitelabel": "Pesquisas totalmente personalizadas",
|
||||
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
|
||||
"trial_payment_method_added_description": "Tudo pronto! Seu plano Pro continuará automaticamente após o término do período de teste.",
|
||||
"trial_title": "Ganhe o Formbricks Pro gratuitamente!",
|
||||
"trial_title": "Experimente os recursos Pro gratuitamente!",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"upgrade_now": "Fazer upgrade agora",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "usado",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "O checkout anual ainda não está disponível. Adicione um método de pagamento em um plano mensal primeiro ou entre em contato com o suporte.",
|
||||
"your_plan": "Seu plano"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Recursos Empresariais",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
|
||||
"license_feature_access_control": "Controle de acesso (RBAC)",
|
||||
"license_feature_audit_logs": "Logs de auditoria",
|
||||
"license_feature_contacts": "Contatos e Segmentos",
|
||||
"license_feature_projects": "Workspaces",
|
||||
"license_feature_quotas": "Cotas",
|
||||
"license_feature_remove_branding": "Remover identidade visual",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Proteção contra spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autenticação de dois fatores",
|
||||
"license_feature_whitelabel": "E-mails white-label",
|
||||
"license_features_table_access": "Acesso",
|
||||
"license_features_table_description": "Recursos empresariais e limites disponíveis atualmente para esta instância.",
|
||||
"license_features_table_disabled": "Desabilitado",
|
||||
"license_features_table_enabled": "Habilitado",
|
||||
"license_features_table_feature": "Recurso",
|
||||
"license_features_table_title": "Recursos Licenciados",
|
||||
"license_features_table_unlimited": "Ilimitado",
|
||||
"license_features_table_value": "Valor",
|
||||
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
|
||||
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
|
||||
"license_status": "Status da licença",
|
||||
"license_status_active": "Ativa",
|
||||
"license_status_description": "Status da sua licença enterprise.",
|
||||
"license_status_expired": "Expirada",
|
||||
"license_status_instance_mismatch": "Vinculada a Outra Instância",
|
||||
"license_status_invalid": "Licença inválida",
|
||||
"license_status_unreachable": "Inacessível",
|
||||
"license_unreachable_grace_period": "O servidor de licenças não pode ser alcançado. Seus recursos empresariais permanecem ativos durante um período de carência de 3 dias que termina em {gracePeriodEnd}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Perguntas? Entre em contato com",
|
||||
"recheck_license": "Verificar licença novamente",
|
||||
"recheck_license_failed": "Falha na verificação da licença. O servidor de licenças pode estar inacessível.",
|
||||
"recheck_license_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_success": "Verificação da licença bem-sucedida",
|
||||
"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",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
|
||||
"everyone": "Todo mundo",
|
||||
"expand_preview": "Expandir prévia",
|
||||
"external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
@@ -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_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
|
||||
"spam_protection_threshold_heading": "Limite de resposta",
|
||||
"shrink_preview": "Recolher prévia",
|
||||
"star": "Estrela",
|
||||
"starts_with": "Começa com",
|
||||
"state": "Estado",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
|
||||
"survey_completed_heading": "Pesquisa Concluída",
|
||||
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
|
||||
"survey_display_settings": "Configurações de Exibição da Pesquisa",
|
||||
"survey_placement": "Posicionamento da Pesquisa",
|
||||
"survey_preview": "Prévia da pesquisa 👀",
|
||||
"survey_styling": "Estilização de Formulários",
|
||||
"survey_trigger": "Gatilho de Pesquisa",
|
||||
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
|
||||
"preview_survey_question_2_headline": "Quer ficar por dentro?",
|
||||
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
|
||||
"preview_survey_question_open_text_headline": "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_subheader": "Seu feedback nos ajuda a melhorar.",
|
||||
"preview_survey_welcome_card_headline": "Bem-vindo!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
|
||||
"coming_soon_title": "Estamos quase lá!",
|
||||
"follow_up_label": "Há algo mais que você gostaria de 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?",
|
||||
"generate_button": "Gerar fluxo de trabalho",
|
||||
"heading": "Qual fluxo de trabalho você quer criar?",
|
||||
|
||||
+18
-86
@@ -294,7 +294,6 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
|
||||
"next": "Seguinte",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"preview": "Pré-visualização",
|
||||
"preview_survey": "Pré-visualização do inquérito",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "Algo correu mal",
|
||||
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
|
||||
"sort_by": "Ordem",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "Estado",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Título",
|
||||
"top_left": "Superior Esquerdo",
|
||||
"top_right": "Superior Direito",
|
||||
"trial_days_remaining": "{count} dias restantes no teu período de teste",
|
||||
"trial_expired": "O teu período de teste expirou",
|
||||
"trial_one_day_remaining": "1 dia restante no teu período de teste",
|
||||
"try_again": "Tente novamente",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Inquérito desconhecido",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Atualizar",
|
||||
"updated": "Atualizado",
|
||||
"updated_at": "Atualizado em",
|
||||
"upgrade_plan": "Fazer upgrade do plano",
|
||||
"upload": "Carregar",
|
||||
"upload_failed": "Falha no carregamento. Por favor, tente novamente.",
|
||||
"upload_input_description": "Clique ou arraste para carregar ficheiros.",
|
||||
@@ -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"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adicionar método de pagamento",
|
||||
"add_payment_method_to_upgrade_tooltip": "Por favor, adiciona um método de pagamento acima para fazeres upgrade para um plano pago",
|
||||
"billing_interval_toggle": "Intervalo de faturação",
|
||||
"current_plan_badge": "Atual",
|
||||
"current_plan_cta": "Plano atual",
|
||||
"custom_plan_description": "A tua organização tem uma configuração de faturação personalizada. Podes mudar para um dos planos padrão abaixo.",
|
||||
"custom_plan_title": "Plano personalizado",
|
||||
"cancelling": "A cancelar",
|
||||
"failed_to_start_trial": "Falha ao iniciar o período de teste. Por favor, tenta novamente.",
|
||||
"keep_current_plan": "Manter plano atual",
|
||||
"manage_billing_details": "Gerir detalhes do cartão e faturas",
|
||||
"monthly": "Mensal",
|
||||
"most_popular": "Mais popular",
|
||||
"pending_change_removed": "Alteração de plano agendada removida.",
|
||||
"pending_plan_badge": "Agendado",
|
||||
"pending_plan_change_description": "O teu plano mudará para {{plan}} em {{date}}.",
|
||||
"pending_plan_change_title": "Alteração de plano agendada",
|
||||
"pending_plan_cta": "Agendado",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_change_applied": "Plano atualizado com sucesso.",
|
||||
"plan_change_scheduled": "Alteração de plano agendada com sucesso.",
|
||||
"manage_subscription": "Gerir subscrição",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tudo no Hobby",
|
||||
"plan_feature_everything_in_pro": "Tudo no Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Para indivíduos e pequenas equipas que estão a começar com o Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 respostas / mês",
|
||||
"plan_hobby_feature_workspaces": "1 workspace",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Para equipas em crescimento que precisam de limites mais elevados, automatizações e excedentes dinâmicos.",
|
||||
"plan_pro_feature_responses": "2.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_pro_feature_workspaces": "3 áreas de trabalho",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Para equipas maiores que precisam de mais capacidade, maior controlo e um volume de respostas mais elevado.",
|
||||
"plan_scale_feature_responses": "5.000 respostas / mês (excedente dinâmico)",
|
||||
"plan_scale_feature_workspaces": "5 áreas de trabalho",
|
||||
"plan_selection_description": "Compara Hobby, Pro e Scale, e depois muda de plano diretamente no Formbricks.",
|
||||
"plan_selection_title": "Escolhe o teu plano",
|
||||
"plan_unknown": "Desconhecido",
|
||||
"remove_branding": "Possibilidade de remover o logo",
|
||||
"retry_setup": "Tentar novamente configurar",
|
||||
"scale_banner_description": "Desbloqueia limites mais elevados, colaboração em equipa e funcionalidades avançadas de segurança com o plano Scale.",
|
||||
"scale_banner_title": "Preparado para aumentar a escala?",
|
||||
"scale_feature_api": "Acesso total à API",
|
||||
"scale_feature_quota": "Gestão de quotas",
|
||||
"scale_feature_spam": "Proteção contra spam",
|
||||
"scale_feature_teams": "Equipas e papéis de acesso",
|
||||
"select_plan_header_subtitle": "Não é necessário cartão de crédito, sem compromisso.",
|
||||
"select_plan_header_title": "Inquéritos perfeitamente integrados, 100% da tua marca.",
|
||||
"select_plan_header_title": "Envia inquéritos profissionais sem marca hoje!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Quero manter o plano Hobby",
|
||||
"stripe_setup_incomplete": "Configuração de faturação incompleta",
|
||||
"stripe_setup_incomplete_description": "A configuração de faturação não foi concluída com sucesso. Por favor, tenta novamente para ativar a tua subscrição.",
|
||||
"subscription": "Subscrição",
|
||||
"subscription_description": "Gere o teu plano de subscrição e acompanha a tua utilização",
|
||||
"switch_at_period_end": "Mudar no fim do período",
|
||||
"switch_plan_now": "Mudar de plano agora",
|
||||
"this_includes": "Isto inclui",
|
||||
"trial_alert_description": "Adiciona um método de pagamento para manteres acesso a todas as funcionalidades.",
|
||||
"trial_already_used": "Já foi utilizado um período de teste gratuito para este endereço de email. Por favor, atualiza para um plano pago.",
|
||||
"trial_feature_api_access": "Acesso à API",
|
||||
"trial_feature_attribute_segmentation": "Segmentação Baseada em Atributos",
|
||||
"trial_feature_contact_segment_management": "Gestão de Contactos e Segmentos",
|
||||
"trial_feature_email_followups": "Seguimentos por E-mail",
|
||||
"trial_feature_hide_branding": "Ocultar Marca Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDKs para iOS e Android",
|
||||
"trial_feature_respondent_identification": "Identificação de Inquiridos",
|
||||
"trial_feature_unlimited_seats": "Lugares Ilimitados",
|
||||
"trial_feature_webhooks": "Webhooks Personalizados",
|
||||
"trial_feature_api_access": "Obtém acesso completo à API",
|
||||
"trial_feature_collaboration": "Todas as funcionalidades de equipa e colaboração",
|
||||
"trial_feature_email_followups": "Configura acompanhamentos por email",
|
||||
"trial_feature_quotas": "Gere quotas",
|
||||
"trial_feature_webhooks": "Configura webhooks personalizados",
|
||||
"trial_feature_whitelabel": "Inquéritos totalmente personalizados",
|
||||
"trial_no_credit_card": "14 dias de teste, sem necessidade de cartão de crédito",
|
||||
"trial_payment_method_added_description": "Está tudo pronto! O teu plano Pro continuará automaticamente após o fim do período experimental.",
|
||||
"trial_title": "Obtém o Formbricks Pro gratuitamente!",
|
||||
"trial_title": "Experimenta as funcionalidades Pro gratuitamente!",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"upgrade_now": "Fazer upgrade agora",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilizado",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "O pagamento anual ainda não está disponível. Adiciona primeiro um método de pagamento num plano mensal ou contacta o suporte.",
|
||||
"your_plan": "O teu plano"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Funcionalidades da Empresa",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
|
||||
"license_feature_access_control": "Controlo de acesso (RBAC)",
|
||||
"license_feature_audit_logs": "Registos de auditoria",
|
||||
"license_feature_contacts": "Contactos e Segmentos",
|
||||
"license_feature_projects": "Áreas de trabalho",
|
||||
"license_feature_quotas": "Quotas",
|
||||
"license_feature_remove_branding": "Remover marca",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Proteção contra spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autenticação de dois fatores",
|
||||
"license_feature_whitelabel": "E-mails personalizados",
|
||||
"license_features_table_access": "Acesso",
|
||||
"license_features_table_description": "Funcionalidades e limites empresariais atualmente disponíveis para esta instância.",
|
||||
"license_features_table_disabled": "Desativado",
|
||||
"license_features_table_enabled": "Ativado",
|
||||
"license_features_table_feature": "Funcionalidade",
|
||||
"license_features_table_title": "Funcionalidades Licenciadas",
|
||||
"license_features_table_unlimited": "Ilimitado",
|
||||
"license_features_table_value": "Valor",
|
||||
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
|
||||
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
|
||||
"license_status": "Estado da licença",
|
||||
"license_status_active": "Ativa",
|
||||
"license_status_description": "Estado da sua licença empresarial.",
|
||||
"license_status_expired": "Expirada",
|
||||
"license_status_instance_mismatch": "Associada a Outra Instância",
|
||||
"license_status_invalid": "Licença inválida",
|
||||
"license_status_unreachable": "Inacessível",
|
||||
"license_unreachable_grace_period": "Não é possível contactar o servidor de licenças. As suas funcionalidades empresariais permanecem ativas durante um período de tolerância de 3 dias que termina a {gracePeriodEnd}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Questões? Por favor entre em contacto com",
|
||||
"recheck_license": "Verificar licença novamente",
|
||||
"recheck_license_failed": "A verificação da licença falhou. O servidor de licenças pode estar inacessível.",
|
||||
"recheck_license_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_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.",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
|
||||
"everyone": "Todos",
|
||||
"expand_preview": "Expandir pré-visualização",
|
||||
"external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
@@ -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_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
|
||||
"spam_protection_threshold_heading": "Limite de resposta",
|
||||
"shrink_preview": "Reduzir pré-visualização",
|
||||
"star": "Estrela",
|
||||
"starts_with": "Começa com",
|
||||
"state": "Estado",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Estilo definido para estilos do tema",
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
|
||||
"survey_completed_heading": "Inquérito Concluído",
|
||||
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
|
||||
"survey_display_settings": "Configurações de Exibição do Inquérito",
|
||||
"survey_placement": "Colocação do Inquérito",
|
||||
"survey_preview": "Pré-visualização do questionário 👀",
|
||||
"survey_styling": "Estilo do formulário",
|
||||
"survey_trigger": "Desencadeador de Inquérito",
|
||||
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
|
||||
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
|
||||
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
|
||||
"preview_survey_question_open_text_headline": "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_subheader": "O seu feedback ajuda-nos a melhorar.",
|
||||
"preview_survey_welcome_card_headline": "Bem-vindo!",
|
||||
|
||||
+19
-87
@@ -294,7 +294,6 @@
|
||||
"new": "Nou",
|
||||
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
|
||||
"next": "Următorul",
|
||||
"no_actions_found": "Nu au fost găsite acțiuni",
|
||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||
"no_code": "Fără Cod",
|
||||
"no_files_uploaded": "Nu au fost încărcate fișiere",
|
||||
@@ -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_trigger": "Vă rugăm să selectați cel puțin un declanșator",
|
||||
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
|
||||
"powered_by_formbricks": "Oferit de Formbricks",
|
||||
"preview": "Previzualizare",
|
||||
"preview_survey": "Previzualizare Chestionar",
|
||||
"privacy": "Politica de Confidențialitate",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "Ceva nu a mers bine",
|
||||
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
|
||||
"sort_by": "Sortare după",
|
||||
"start_free_trial": "Începe perioada de probă gratuită",
|
||||
"start_free_trial": "Începe perioada de testare gratuită",
|
||||
"status": "Stare",
|
||||
"step_by_step_manual": "Manual pas cu pas",
|
||||
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Titlu",
|
||||
"top_left": "Stânga Sus",
|
||||
"top_right": "Dreapta Sus",
|
||||
"trial_days_remaining": "{count} zile rămase în perioada ta de probă",
|
||||
"trial_expired": "Perioada ta de probă a expirat",
|
||||
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
|
||||
"try_again": "Încearcă din nou",
|
||||
"type": "Tip",
|
||||
"unknown_survey": "Chestionar necunoscut",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Actualizare",
|
||||
"updated": "Actualizat",
|
||||
"updated_at": "Actualizat la",
|
||||
"upgrade_plan": "Actualizează planul",
|
||||
"upload": "Încărcați",
|
||||
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.",
|
||||
"upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Adaugă o metodă de plată",
|
||||
"add_payment_method_to_upgrade_tooltip": "Te rugăm să adaugi o metodă de plată mai sus pentru a trece la un plan plătit",
|
||||
"billing_interval_toggle": "Interval de facturare",
|
||||
"current_plan_badge": "Curent",
|
||||
"current_plan_cta": "Plan curent",
|
||||
"custom_plan_description": "Organizația ta folosește o configurație de facturare personalizată. Poți totuși să treci la unul dintre planurile standard de mai jos.",
|
||||
"custom_plan_title": "Plan personalizat",
|
||||
"cancelling": "Anulare în curs",
|
||||
"failed_to_start_trial": "Nu am putut porni perioada de probă. Te rugăm să încerci din nou.",
|
||||
"keep_current_plan": "Păstrează planul curent",
|
||||
"manage_billing_details": "Gestionează detaliile cardului și facturile",
|
||||
"monthly": "Lunar",
|
||||
"most_popular": "Cel mai popular",
|
||||
"pending_change_removed": "Schimbarea de plan programată a fost anulată.",
|
||||
"pending_plan_badge": "Programat",
|
||||
"pending_plan_change_description": "Planul tău va trece la {{plan}} pe {{date}}.",
|
||||
"pending_plan_change_title": "Schimbare de plan programată",
|
||||
"pending_plan_cta": "Programat",
|
||||
"per_month": "pe lună",
|
||||
"per_year": "pe an",
|
||||
"plan_change_applied": "Planul a fost actualizat cu succes.",
|
||||
"plan_change_scheduled": "Schimbarea de plan a fost programată cu succes.",
|
||||
"manage_subscription": "Gestionează abonamentul",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Tot ce include Hobby",
|
||||
"plan_feature_everything_in_pro": "Tot ce include Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Pentru persoane individuale și echipe mici care încep să folosească Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 de răspunsuri / lună",
|
||||
"plan_hobby_feature_workspaces": "1 spațiu de lucru",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Pentru echipele în creștere care au nevoie de limite mai mari, automatizări și depășiri dinamice.",
|
||||
"plan_pro_feature_responses": "2.000 de răspunsuri / lună (depășire dinamică)",
|
||||
"plan_pro_feature_workspaces": "3 spații de lucru",
|
||||
"plan_scale": "Scală",
|
||||
"plan_scale_description": "Pentru echipe mai mari care au nevoie de mai multă capacitate, guvernanță mai puternică și volum mai mare de răspunsuri.",
|
||||
"plan_scale_feature_responses": "5.000 răspunsuri / lună (suprataxă dinamică)",
|
||||
"plan_scale_feature_workspaces": "5 spații de lucru",
|
||||
"plan_selection_description": "Compară Hobby, Pro și Scale, apoi schimbă planurile direct din Formbricks.",
|
||||
"plan_selection_title": "Alege-ți planul",
|
||||
"plan_unknown": "Necunoscut",
|
||||
"remove_branding": "Eliminare branding",
|
||||
"retry_setup": "Încearcă din nou configurarea",
|
||||
"scale_banner_description": "Deblochează limite mai mari, colaborare în echipă și funcții avansate de securitate cu pachetul Scale.",
|
||||
"scale_banner_title": "Gata să treci la nivelul următor?",
|
||||
"scale_feature_api": "Acces complet API",
|
||||
"scale_feature_quota": "Gestionare cote",
|
||||
"scale_feature_spam": "Protecție anti-spam",
|
||||
"scale_feature_teams": "Echipe și roluri de acces",
|
||||
"select_plan_header_subtitle": "Nu este necesar card de credit, fără obligații.",
|
||||
"select_plan_header_title": "Sondaje integrate perfect, 100% brandul tău.",
|
||||
"select_plan_header_title": "Lansează chestionare profesionale, fără branding, astăzi!",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Vreau să rămân pe planul Hobby",
|
||||
"stripe_setup_incomplete": "Configurare facturare incompletă",
|
||||
"stripe_setup_incomplete_description": "Configurarea facturării nu a fost finalizată cu succes. Încearcă din nou pentru a activa abonamentul.",
|
||||
"subscription": "Abonament",
|
||||
"subscription_description": "Gestionează-ți abonamentul și monitorizează-ți consumul",
|
||||
"switch_at_period_end": "Schimbă la sfârșitul perioadei",
|
||||
"switch_plan_now": "Schimbă planul acum",
|
||||
"this_includes": "Aceasta include",
|
||||
"trial_alert_description": "Adaugă o metodă de plată pentru a păstra accesul la toate funcționalitățile.",
|
||||
"trial_already_used": "O perioadă de probă gratuită a fost deja utilizată pentru această adresă de email. Te rugăm să treci la un plan plătit în schimb.",
|
||||
"trial_feature_api_access": "Acces API",
|
||||
"trial_feature_attribute_segmentation": "Segmentare bazată pe atribute",
|
||||
"trial_feature_contact_segment_management": "Gestionare contacte și segmente",
|
||||
"trial_feature_email_followups": "Urmăriri prin email",
|
||||
"trial_feature_hide_branding": "Ascunde branding-ul Formbricks",
|
||||
"trial_feature_mobile_sdks": "SDK-uri iOS și Android",
|
||||
"trial_feature_respondent_identification": "Identificarea respondenților",
|
||||
"trial_feature_unlimited_seats": "Locuri nelimitate",
|
||||
"trial_feature_webhooks": "Webhook-uri personalizate",
|
||||
"trial_feature_api_access": "Obține acces complet la API",
|
||||
"trial_feature_collaboration": "Toate funcțiile de echipă și colaborare",
|
||||
"trial_feature_email_followups": "Configurează urmăriri prin email",
|
||||
"trial_feature_quotas": "Gestionează cotele",
|
||||
"trial_feature_webhooks": "Configurează webhook-uri personalizate",
|
||||
"trial_feature_whitelabel": "Chestionare complet personalizate (white-label)",
|
||||
"trial_no_credit_card": "14 zile de probă, fără card necesar",
|
||||
"trial_payment_method_added_description": "Totul este pregătit! Planul tău Pro va continua automat după ce se încheie perioada de probă.",
|
||||
"trial_title": "Obține Formbricks Pro gratuit!",
|
||||
"trial_title": "Încearcă funcțiile Pro gratuit!",
|
||||
"unlimited_responses": "Răspunsuri nelimitate",
|
||||
"unlimited_workspaces": "Workspaces nelimitate",
|
||||
"upgrade": "Actualizare",
|
||||
"upgrade_now": "Actualizează acum",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilizat",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "Plata anuală nu este disponibilă încă. Adaugă mai întâi o metodă de plată pe un abonament lunar sau contactează asistența.",
|
||||
"your_plan": "Planul tău"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Funcții Enterprise",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
|
||||
"license_feature_access_control": "Control acces (RBAC)",
|
||||
"license_feature_audit_logs": "Jurnale de audit",
|
||||
"license_feature_contacts": "Contacte și segmente",
|
||||
"license_feature_projects": "Spații de lucru",
|
||||
"license_feature_quotas": "Cote",
|
||||
"license_feature_remove_branding": "Elimină branding-ul",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Protecție spam",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Autentificare cu doi factori",
|
||||
"license_feature_whitelabel": "E-mailuri white-label",
|
||||
"license_features_table_access": "Acces",
|
||||
"license_features_table_description": "Funcționalități și limite enterprise disponibile în prezent pentru această instanță.",
|
||||
"license_features_table_disabled": "Dezactivat",
|
||||
"license_features_table_enabled": "Activat",
|
||||
"license_features_table_feature": "Funcționalitate",
|
||||
"license_features_table_title": "Funcționalități licențiate",
|
||||
"license_features_table_unlimited": "Nelimitat",
|
||||
"license_features_table_value": "Valoare",
|
||||
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
|
||||
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
|
||||
"license_status": "Stare licență",
|
||||
"license_status_active": "Activă",
|
||||
"license_status_description": "Starea licenței tale enterprise.",
|
||||
"license_status_expired": "Expirată",
|
||||
"license_status_instance_mismatch": "Asociată cu Altă Instanță",
|
||||
"license_status_invalid": "Licență invalidă",
|
||||
"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}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"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_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_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.",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "Eroare la salvarea modificărilor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
|
||||
"everyone": "Toată lumea",
|
||||
"expand_preview": "Extinde previzualizarea",
|
||||
"external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.",
|
||||
"fallback_missing": "Rezerva lipsă",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
@@ -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_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
|
||||
"spam_protection_threshold_heading": "Pragul răspunsurilor",
|
||||
"shrink_preview": "Restrânge previzualizarea",
|
||||
"star": "Stea",
|
||||
"starts_with": "Începe cu",
|
||||
"state": "Stare",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
|
||||
"subheading": "Subtitlu",
|
||||
"subtract": "Scade -",
|
||||
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
|
||||
"survey_completed_heading": "Sondaj Completat",
|
||||
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
|
||||
"survey_display_settings": "Setări de afișare a sondajului",
|
||||
"survey_placement": "Amplasarea sondajului",
|
||||
"survey_preview": "Previzualizare chestionar 👀",
|
||||
"survey_styling": "Stilizare formular",
|
||||
"survey_trigger": "Declanșator sondaj",
|
||||
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
|
||||
"preview_survey_question_2_headline": "Vrei să fii în temă?",
|
||||
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
|
||||
"preview_survey_question_open_text_headline": "Mai 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_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
|
||||
"preview_survey_welcome_card_headline": "Bun venit!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
|
||||
"coming_soon_title": "Suntem aproape gata!",
|
||||
"follow_up_label": "Mai este ceva ce ați dori să adăugați?",
|
||||
"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?",
|
||||
"generate_button": "Generează workflow",
|
||||
"heading": "Ce workflow vrei să creezi?",
|
||||
|
||||
+18
-86
@@ -294,7 +294,6 @@
|
||||
"new": "Новый",
|
||||
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
|
||||
"next": "Далее",
|
||||
"no_actions_found": "Действия не найдены",
|
||||
"no_background_image_found": "Фоновое изображение не найдено.",
|
||||
"no_code": "Нет кода",
|
||||
"no_files_uploaded": "Файлы не были загружены",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
|
||||
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
|
||||
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
|
||||
"powered_by_formbricks": "Работает на Formbricks",
|
||||
"preview": "Предпросмотр",
|
||||
"preview_survey": "Предпросмотр опроса",
|
||||
"privacy": "Политика конфиденциальности",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Заголовок",
|
||||
"top_left": "Вверху слева",
|
||||
"top_right": "Вверху справа",
|
||||
"trial_days_remaining": "{count, plural, one {Остался # день пробного периода} few {Осталось # дня пробного периода} many {Осталось # дней пробного периода} other {Осталось # дней пробного периода}}",
|
||||
"trial_expired": "Пробный период истёк",
|
||||
"trial_one_day_remaining": "Остался 1 день пробного периода",
|
||||
"try_again": "Попробуйте ещё раз",
|
||||
"type": "Тип",
|
||||
"unknown_survey": "Неизвестный опрос",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Обновить",
|
||||
"updated": "Обновлено",
|
||||
"updated_at": "Обновлено",
|
||||
"upgrade_plan": "Перейти на другой тариф",
|
||||
"upload": "Загрузить",
|
||||
"upload_failed": "Не удалось загрузить. Пожалуйста, попробуйте ещё раз.",
|
||||
"upload_input_description": "Кликните или перетащите файлы для загрузки.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Управляйте API-ключами для доступа к управляющим API Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Добавить способ оплаты",
|
||||
"add_payment_method_to_upgrade_tooltip": "Пожалуйста, добавьте способ оплаты выше, чтобы перейти на платный тариф",
|
||||
"billing_interval_toggle": "Интервал выставления счетов",
|
||||
"current_plan_badge": "Текущий",
|
||||
"current_plan_cta": "Текущий тариф",
|
||||
"custom_plan_description": "Ваша организация использует индивидуальные настройки оплаты. Вы все равно можете переключиться на один из стандартных тарифов ниже.",
|
||||
"custom_plan_title": "Индивидуальный тариф",
|
||||
"cancelling": "Отмена",
|
||||
"failed_to_start_trial": "Не удалось запустить пробный период. Попробуйте снова.",
|
||||
"keep_current_plan": "Оставить текущий тариф",
|
||||
"manage_billing_details": "Управление данными карты и счетами",
|
||||
"monthly": "Ежемесячно",
|
||||
"most_popular": "Самый популярный",
|
||||
"pending_change_removed": "Запланированное изменение тарифа отменено.",
|
||||
"pending_plan_badge": "Запланирован",
|
||||
"pending_plan_change_description": "Ваш тариф изменится на {{plan}} {{date}}.",
|
||||
"pending_plan_change_title": "Запланированное изменение тарифа",
|
||||
"pending_plan_cta": "Запланирован",
|
||||
"per_month": "в месяц",
|
||||
"per_year": "в год",
|
||||
"plan_change_applied": "Тариф успешно обновлен.",
|
||||
"plan_change_scheduled": "Изменение тарифа успешно запланировано.",
|
||||
"manage_subscription": "Управление подпиской",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Все возможности Hobby",
|
||||
"plan_feature_everything_in_pro": "Все возможности Pro",
|
||||
"plan_hobby": "Хобби",
|
||||
"plan_hobby_description": "Для частных лиц и небольших команд, начинающих работу с Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 ответов в месяц",
|
||||
"plan_hobby_feature_workspaces": "1 рабочее пространство",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Для растущих команд, которым нужны более высокие лимиты, автоматизация и динамические дополнительные ресурсы.",
|
||||
"plan_pro_feature_responses": "2 000 ответов в месяц (динамическое превышение)",
|
||||
"plan_pro_feature_workspaces": "3 рабочих пространства",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Для крупных команд, которым нужно больше возможностей, строгое управление и больший объем ответов.",
|
||||
"plan_scale_feature_responses": "5 000 ответов / месяц (динамический перерасход)",
|
||||
"plan_scale_feature_workspaces": "5 рабочих пространств",
|
||||
"plan_selection_description": "Сравни планы Hobby, Pro и Scale, а затем переключайся между ними прямо в Formbricks.",
|
||||
"plan_selection_title": "Выбери свой план",
|
||||
"plan_unknown": "Неизвестно",
|
||||
"remove_branding": "Удалить брендинг",
|
||||
"retry_setup": "Повторить настройку",
|
||||
"scale_banner_description": "Откройте новые лимиты, командную работу и расширенные функции безопасности с тарифом Scale.",
|
||||
"scale_banner_title": "Готовы развиваться?",
|
||||
"scale_feature_api": "Полный доступ к API",
|
||||
"scale_feature_quota": "Управление квотами",
|
||||
"scale_feature_spam": "Защита от спама",
|
||||
"scale_feature_teams": "Команды и роли доступа",
|
||||
"select_plan_header_subtitle": "Кредитная карта не требуется, никаких обязательств.",
|
||||
"select_plan_header_title": "Бесшовно интегрированные опросы, 100% ваш бренд.",
|
||||
"select_plan_header_title": "Создавайте профессиональные опросы без брендинга уже сегодня!",
|
||||
"status_trialing": "Пробный",
|
||||
"stay_on_hobby_plan": "Я хочу остаться на тарифе Hobby",
|
||||
"stripe_setup_incomplete": "Настройка оплаты не завершена",
|
||||
"stripe_setup_incomplete_description": "Настройка оплаты не была завершена. Пожалуйста, повторите попытку, чтобы активировать вашу подписку.",
|
||||
"subscription": "Подписка",
|
||||
"subscription_description": "Управляйте своим тарифом и следите за использованием",
|
||||
"switch_at_period_end": "Переключить в конце периода",
|
||||
"switch_plan_now": "Переключить план сейчас",
|
||||
"this_includes": "Это включает",
|
||||
"trial_alert_description": "Добавьте способ оплаты, чтобы сохранить доступ ко всем функциям.",
|
||||
"trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.",
|
||||
"trial_feature_api_access": "Доступ к API",
|
||||
"trial_feature_attribute_segmentation": "Сегментация на основе атрибутов",
|
||||
"trial_feature_contact_segment_management": "Управление контактами и сегментами",
|
||||
"trial_feature_email_followups": "Email-уведомления",
|
||||
"trial_feature_hide_branding": "Скрыть брендинг Formbricks",
|
||||
"trial_feature_mobile_sdks": "iOS и Android SDK",
|
||||
"trial_feature_respondent_identification": "Идентификация респондентов",
|
||||
"trial_feature_unlimited_seats": "Неограниченное количество мест",
|
||||
"trial_feature_webhooks": "Пользовательские вебхуки",
|
||||
"trial_feature_api_access": "Получите полный доступ к API",
|
||||
"trial_feature_collaboration": "Все функции для работы в команде и совместной работы",
|
||||
"trial_feature_email_followups": "Настройте последующие письма",
|
||||
"trial_feature_quotas": "Управляйте квотами",
|
||||
"trial_feature_webhooks": "Настройте собственные вебхуки",
|
||||
"trial_feature_whitelabel": "Полностью персонализированные опросы без брендинга",
|
||||
"trial_no_credit_card": "14 дней пробного периода, кредитная карта не требуется",
|
||||
"trial_payment_method_added_description": "Всё готово! Твой тарифный план Pro продолжится автоматически после окончания пробного периода.",
|
||||
"trial_title": "Получите Formbricks Pro бесплатно!",
|
||||
"trial_title": "Попробуйте Pro функции бесплатно!",
|
||||
"unlimited_responses": "Неограниченное количество ответов",
|
||||
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
|
||||
"upgrade": "Обновить",
|
||||
"upgrade_now": "Обновить сейчас",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "использовано",
|
||||
"yearly": "Годовой",
|
||||
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
|
||||
"your_plan": "Ваш тариф"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Функции для предприятий",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
|
||||
"license_feature_access_control": "Управление доступом (RBAC)",
|
||||
"license_feature_audit_logs": "Журналы аудита",
|
||||
"license_feature_contacts": "Контакты и сегменты",
|
||||
"license_feature_projects": "Рабочие пространства",
|
||||
"license_feature_quotas": "Квоты",
|
||||
"license_feature_remove_branding": "Удаление брендирования",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Защита от спама",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Двухфакторная аутентификация",
|
||||
"license_feature_whitelabel": "Электронные письма без брендирования",
|
||||
"license_features_table_access": "Доступ",
|
||||
"license_features_table_description": "Корпоративные функции и ограничения, доступные для этого экземпляра.",
|
||||
"license_features_table_disabled": "Отключено",
|
||||
"license_features_table_enabled": "Включено",
|
||||
"license_features_table_feature": "Функция",
|
||||
"license_features_table_title": "Лицензированные функции",
|
||||
"license_features_table_unlimited": "Без ограничений",
|
||||
"license_features_table_value": "Значение",
|
||||
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
|
||||
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
|
||||
"license_status": "Статус лицензии",
|
||||
"license_status_active": "Активна",
|
||||
"license_status_description": "Статус вашей корпоративной лицензии.",
|
||||
"license_status_expired": "Срок действия истёк",
|
||||
"license_status_instance_mismatch": "Привязана к другому экземпляру",
|
||||
"license_status_invalid": "Недействительная лицензия",
|
||||
"license_status_unreachable": "Недоступна",
|
||||
"license_unreachable_grace_period": "Не удаётся подключиться к серверу лицензий. Корпоративные функции останутся активными в течение 3-дневного льготного периода, который закончится {gracePeriodEnd}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Вопросы? Свяжитесь с",
|
||||
"recheck_license": "Проверить лицензию ещё раз",
|
||||
"recheck_license_failed": "Не удалось проверить лицензию. Сервер лицензий может быть недоступен.",
|
||||
"recheck_license_instance_mismatch": "Эта лицензия привязана к другому экземпляру Formbricks. Обратитесь в службу поддержки Formbricks для отключения предыдущей привязки.",
|
||||
"recheck_license_invalid": "Ключ лицензии недействителен. Пожалуйста, проверь свою переменную ENTERPRISE_LICENSE_KEY.",
|
||||
"recheck_license_success": "Проверка лицензии прошла успешно",
|
||||
"recheck_license_unreachable": "Сервер лицензий недоступен. Пожалуйста, попробуй позже.",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "Ошибка при сохранении изменений",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).",
|
||||
"everyone": "Все",
|
||||
"expand_preview": "Развернуть предпросмотр",
|
||||
"external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.",
|
||||
"fallback_missing": "Запасное значение отсутствует",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
|
||||
@@ -1711,7 +1646,6 @@
|
||||
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
|
||||
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
|
||||
"spam_protection_threshold_heading": "Порог ответа",
|
||||
"shrink_preview": "Свернуть предпросмотр",
|
||||
"star": "Звезда",
|
||||
"starts_with": "Начинается с",
|
||||
"state": "Состояние",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
|
||||
"subheading": "Подзаголовок",
|
||||
"subtract": "Вычесть -",
|
||||
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
|
||||
"survey_completed_heading": "Опрос завершён",
|
||||
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
|
||||
"survey_display_settings": "Настройки отображения опроса",
|
||||
"survey_placement": "Размещение опроса",
|
||||
"survey_preview": "Предпросмотр опроса 👀",
|
||||
"survey_styling": "Оформление формы",
|
||||
"survey_trigger": "Триггер опроса",
|
||||
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Нет, спасибо!",
|
||||
"preview_survey_question_2_headline": "Хотите быть в курсе событий?",
|
||||
"preview_survey_question_2_subheader": "Это пример описания.",
|
||||
"preview_survey_question_open_text_headline": "Хотите ли вы чем-то ещё поделиться?",
|
||||
"preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?",
|
||||
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
|
||||
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
|
||||
"preview_survey_welcome_card_headline": "Добро пожаловать!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
|
||||
"coming_soon_title": "Мы почти готовы!",
|
||||
"follow_up_label": "Хотите ли вы что-нибудь добавить?",
|
||||
"follow_up_label": "Хочешь что-то ещё добавить?",
|
||||
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
|
||||
"generate_button": "Сгенерировать воркфлоу",
|
||||
"heading": "Какой воркфлоу ты хочешь создать?",
|
||||
|
||||
+18
-86
@@ -294,7 +294,6 @@
|
||||
"new": "Ny",
|
||||
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
|
||||
"next": "Nästa",
|
||||
"no_actions_found": "Inga åtgärder hittades",
|
||||
"no_background_image_found": "Ingen bakgrundsbild hittades.",
|
||||
"no_code": "Ingen kod",
|
||||
"no_files_uploaded": "Inga filer laddades upp",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
|
||||
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
|
||||
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
|
||||
"powered_by_formbricks": "Drivs av Formbricks",
|
||||
"preview": "Förhandsgranska",
|
||||
"preview_survey": "Förhandsgranska enkät",
|
||||
"privacy": "Integritetspolicy",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "Titel",
|
||||
"top_left": "Övre vänster",
|
||||
"top_right": "Övre höger",
|
||||
"trial_days_remaining": "{count} dagar kvar av din provperiod",
|
||||
"trial_expired": "Din provperiod har gått ut",
|
||||
"trial_one_day_remaining": "1 dag kvar av din provperiod",
|
||||
"try_again": "Försök igen",
|
||||
"type": "Typ",
|
||||
"unknown_survey": "Okänd enkät",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "Uppdatera",
|
||||
"updated": "Uppdaterad",
|
||||
"updated_at": "Uppdaterad",
|
||||
"upgrade_plan": "Uppgradera plan",
|
||||
"upload": "Ladda upp",
|
||||
"upload_failed": "Uppladdning misslyckades. Vänligen försök igen.",
|
||||
"upload_input_description": "Klicka eller dra för att ladda upp filer.",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "Hantera API-nycklar för åtkomst till Formbricks hanterings-API:er"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Lägg till betalningsmetod",
|
||||
"add_payment_method_to_upgrade_tooltip": "Lägg till en betalningsmetod ovan för att uppgradera till en betald plan",
|
||||
"billing_interval_toggle": "Faktureringsintervall",
|
||||
"current_plan_badge": "Nuvarande",
|
||||
"current_plan_cta": "Nuvarande abonnemang",
|
||||
"custom_plan_description": "Din organisation har en anpassad faktureringslösning. Du kan fortfarande byta till något av standardabonnemangen nedan.",
|
||||
"custom_plan_title": "Anpassat abonnemang",
|
||||
"cancelling": "Avbryter",
|
||||
"failed_to_start_trial": "Kunde inte starta provperioden. Försök igen.",
|
||||
"keep_current_plan": "Behåll nuvarande abonnemang",
|
||||
"manage_billing_details": "Hantera kortuppgifter & fakturor",
|
||||
"monthly": "Månatlig",
|
||||
"most_popular": "Mest populär",
|
||||
"pending_change_removed": "Schemalagd abonnemangsändring har tagits bort.",
|
||||
"pending_plan_badge": "Schemalagd",
|
||||
"pending_plan_change_description": "Ditt abonnemang kommer att ändras till {{plan}} den {{date}}.",
|
||||
"pending_plan_change_title": "Schemalagd abonnemangsändring",
|
||||
"pending_plan_cta": "Schemalagd",
|
||||
"per_month": "per månad",
|
||||
"per_year": "per år",
|
||||
"plan_change_applied": "Abonnemanget har uppdaterats.",
|
||||
"plan_change_scheduled": "Abonnemangsändring har schemalagts.",
|
||||
"manage_subscription": "Hantera prenumeration",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Allt i Hobby",
|
||||
"plan_feature_everything_in_pro": "Allt i Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "För privatpersoner och små team som kommer igång med Formbricks Cloud.",
|
||||
"plan_hobby_feature_responses": "250 svar / månad",
|
||||
"plan_hobby_feature_workspaces": "1 arbetsyta",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "För växande team som behöver högre gränser, automationer och dynamiska överskott.",
|
||||
"plan_pro_feature_responses": "2 000 svar / månad (dynamisk överförbrukning)",
|
||||
"plan_pro_feature_workspaces": "3 arbetsytor",
|
||||
"plan_scale": "Skala",
|
||||
"plan_scale_description": "För större team som behöver mer kapacitet, starkare styrning och högre svarsvolym.",
|
||||
"plan_scale_feature_responses": "5 000 svar / månad (dynamisk överförbrukning)",
|
||||
"plan_scale_feature_workspaces": "5 arbetsytor",
|
||||
"plan_selection_description": "Jämför Hobby, Pro och Scale och byt sedan plan direkt från Formbricks.",
|
||||
"plan_selection_title": "Välj din plan",
|
||||
"plan_unknown": "Okänd",
|
||||
"remove_branding": "Ta bort varumärke",
|
||||
"retry_setup": "Försök igen med inställningen",
|
||||
"scale_banner_description": "Lås upp högre gränser, samarbete i team och avancerade säkerhetsfunktioner med Scale-planen.",
|
||||
"scale_banner_title": "Redo att växla upp?",
|
||||
"scale_feature_api": "Full API-åtkomst",
|
||||
"scale_feature_quota": "Kvothantering",
|
||||
"scale_feature_spam": "Spamskydd",
|
||||
"scale_feature_teams": "Team & åtkomstroller",
|
||||
"select_plan_header_subtitle": "Inget kreditkort krävs, inga villkor.",
|
||||
"select_plan_header_title": "Sömlöst integrerade undersökningar, 100% ditt varumärke.",
|
||||
"select_plan_header_title": "Skicka professionella undersökningar utan varumärke idag!",
|
||||
"status_trialing": "Testperiod",
|
||||
"stay_on_hobby_plan": "Jag vill behålla Hobby-planen",
|
||||
"stripe_setup_incomplete": "Faktureringsinställningar ofullständiga",
|
||||
"stripe_setup_incomplete_description": "Faktureringsinställningen slutfördes inte riktigt. Försök igen för att aktivera ditt abonnemang.",
|
||||
"subscription": "Abonnemang",
|
||||
"subscription_description": "Hantera din abonnemangsplan och följ din användning",
|
||||
"switch_at_period_end": "Byt vid periodens slut",
|
||||
"switch_plan_now": "Byt plan nu",
|
||||
"this_includes": "Detta inkluderar",
|
||||
"trial_alert_description": "Lägg till en betalningsmetod för att behålla tillgång till alla funktioner.",
|
||||
"trial_already_used": "En gratis provperiod har redan använts för denna e-postadress. Uppgradera till en betald plan istället.",
|
||||
"trial_feature_api_access": "API-åtkomst",
|
||||
"trial_feature_attribute_segmentation": "Attributbaserad segmentering",
|
||||
"trial_feature_contact_segment_management": "Kontakt- och segmenthantering",
|
||||
"trial_feature_email_followups": "E-postuppföljningar",
|
||||
"trial_feature_hide_branding": "Dölj Formbricks-branding",
|
||||
"trial_feature_mobile_sdks": "iOS- och Android-SDK:er",
|
||||
"trial_feature_respondent_identification": "Respondentidentifiering",
|
||||
"trial_feature_unlimited_seats": "Obegränsade platser",
|
||||
"trial_feature_webhooks": "Anpassade webhooks",
|
||||
"trial_feature_api_access": "Få full API-åtkomst",
|
||||
"trial_feature_collaboration": "Alla team- och samarbetsfunktioner",
|
||||
"trial_feature_email_followups": "Konfigurera uppföljningsmejl",
|
||||
"trial_feature_quotas": "Hantera kvoter",
|
||||
"trial_feature_webhooks": "Konfigurera anpassade webhooks",
|
||||
"trial_feature_whitelabel": "Helt white-label-anpassade enkäter",
|
||||
"trial_no_credit_card": "14 dagars provperiod, inget kreditkort krävs",
|
||||
"trial_payment_method_added_description": "Du är redo! Din Pro-plan kommer att fortsätta automatiskt efter att provperioden slutar.",
|
||||
"trial_title": "Få Formbricks Pro gratis!",
|
||||
"trial_title": "Testa Pro-funktioner gratis!",
|
||||
"unlimited_responses": "Obegränsade svar",
|
||||
"unlimited_workspaces": "Obegränsat antal arbetsytor",
|
||||
"upgrade": "Uppgradera",
|
||||
"upgrade_now": "Uppgradera nu",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "använt",
|
||||
"yearly": "Årligen",
|
||||
"yearly_checkout_unavailable": "Årlig betalning är inte tillgänglig ännu. Lägg till en betalningsmetod på en månatlig plan först eller kontakta support.",
|
||||
"your_plan": "Din plan"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "Enterprise-funktioner",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
|
||||
"license_feature_access_control": "Åtkomstkontroll (RBAC)",
|
||||
"license_feature_audit_logs": "Granskningsloggar",
|
||||
"license_feature_contacts": "Kontakter & Segment",
|
||||
"license_feature_projects": "Arbetsytor",
|
||||
"license_feature_quotas": "Kvoter",
|
||||
"license_feature_remove_branding": "Ta bort varumärkning",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Skräppostskydd",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Tvåfaktorsautentisering",
|
||||
"license_feature_whitelabel": "White-label-mejl",
|
||||
"license_features_table_access": "Åtkomst",
|
||||
"license_features_table_description": "Företagsfunktioner och begränsningar som för närvarande är tillgängliga för den här instansen.",
|
||||
"license_features_table_disabled": "Inaktiverad",
|
||||
"license_features_table_enabled": "Aktiverad",
|
||||
"license_features_table_feature": "Funktion",
|
||||
"license_features_table_title": "Licensierade funktioner",
|
||||
"license_features_table_unlimited": "Obegränsad",
|
||||
"license_features_table_value": "Värde",
|
||||
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
|
||||
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
|
||||
"license_status": "Licensstatus",
|
||||
"license_status_active": "Aktiv",
|
||||
"license_status_description": "Status för din företagslicens.",
|
||||
"license_status_expired": "Utgången",
|
||||
"license_status_instance_mismatch": "Kopplad till en annan instans",
|
||||
"license_status_invalid": "Ogiltig licens",
|
||||
"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}.",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "Frågor? Kontakta",
|
||||
"recheck_license": "Kontrollera licensen igen",
|
||||
"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_success": "Licenskontrollen lyckades",
|
||||
"recheck_license_unreachable": "Licensservern är otillgänglig. Försök igen senare.",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "Fel vid sparande av ändringar",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).",
|
||||
"everyone": "Alla",
|
||||
"expand_preview": "Expandera förhandsgranskning",
|
||||
"external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
|
||||
"fallback_missing": "Reservvärde saknas",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
|
||||
@@ -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_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",
|
||||
"shrink_preview": "Minimera förhandsgranskning",
|
||||
"star": "Stjärna",
|
||||
"starts_with": "Börjar med",
|
||||
"state": "Delstat",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "Styling inställd på temastil",
|
||||
"subheading": "Underrubrik",
|
||||
"subtract": "Subtrahera -",
|
||||
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
|
||||
"survey_completed_heading": "Enkät slutförd",
|
||||
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
|
||||
"survey_display_settings": "Visningsinställningar för enkät",
|
||||
"survey_placement": "Enkätplacering",
|
||||
"survey_preview": "Enkätförhandsgranskning 👀",
|
||||
"survey_styling": "Formulärstil",
|
||||
"survey_trigger": "Enkätutlösare",
|
||||
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nej, tack!",
|
||||
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
|
||||
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
|
||||
"preview_survey_question_open_text_headline": "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_subheader": "Din feedback hjälper oss att bli bättre.",
|
||||
"preview_survey_welcome_card_headline": "Välkommen!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
|
||||
"coming_soon_title": "Vi är nästan där!",
|
||||
"follow_up_label": "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?",
|
||||
"generate_button": "Skapa arbetsflöde",
|
||||
"heading": "Vilket arbetsflöde vill du skapa?",
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"new": "新建",
|
||||
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
|
||||
"next": "下一步",
|
||||
"no_actions_found": "未找到操作",
|
||||
"no_background_image_found": "未找到 背景 图片。",
|
||||
"no_code": "无代码",
|
||||
"no_files_uploaded": "没有 文件 被 上传",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "请选择至少 一个调查",
|
||||
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
|
||||
"please_upgrade_your_plan": "请升级您的计划",
|
||||
"powered_by_formbricks": "由 Formbricks 提供支持",
|
||||
"preview": "预览",
|
||||
"preview_survey": "预览 Survey",
|
||||
"privacy": "隐私政策",
|
||||
@@ -401,7 +399,7 @@
|
||||
"something_went_wrong": "出错了",
|
||||
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
|
||||
"sort_by": "排序 依据",
|
||||
"start_free_trial": "开始免费试用",
|
||||
"start_free_trial": "开始 免费试用",
|
||||
"status": "状态",
|
||||
"step_by_step_manual": "分步 手册",
|
||||
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "标题",
|
||||
"top_left": "左上",
|
||||
"top_right": "右上",
|
||||
"trial_days_remaining": "试用期还剩 {count} 天",
|
||||
"trial_expired": "您的试用期已过期",
|
||||
"trial_one_day_remaining": "试用期还剩 1 天",
|
||||
"try_again": "再试一次",
|
||||
"type": "类型",
|
||||
"unknown_survey": "未知调查",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新 于",
|
||||
"upgrade_plan": "升级套餐",
|
||||
"upload": "上传",
|
||||
"upload_failed": "上传失败,请重试。",
|
||||
"upload_input_description": "点击 或 拖动 上传 文件",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "管理 API 密钥 以 访问 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "添加支付方式",
|
||||
"add_payment_method_to_upgrade_tooltip": "请先在上方添加付款方式以升级到付费套餐",
|
||||
"billing_interval_toggle": "账单周期",
|
||||
"current_plan_badge": "当前",
|
||||
"current_plan_cta": "当前方案",
|
||||
"custom_plan_description": "您的组织使用的是自定义计费设置。您仍然可以切换到下面的标准方案。",
|
||||
"custom_plan_title": "自定义方案",
|
||||
"cancelling": "正在取消",
|
||||
"failed_to_start_trial": "试用启动失败,请重试。",
|
||||
"keep_current_plan": "保持当前方案",
|
||||
"manage_billing_details": "管理卡片详情与发票",
|
||||
"monthly": "按月",
|
||||
"most_popular": "最受欢迎",
|
||||
"pending_change_removed": "已取消预定的方案变更。",
|
||||
"pending_plan_badge": "已预定",
|
||||
"pending_plan_change_description": "您的方案将在 {{date}} 切换至 {{plan}}。",
|
||||
"pending_plan_change_title": "预定的方案变更",
|
||||
"pending_plan_cta": "已预定",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_change_applied": "方案更新成功。",
|
||||
"plan_change_scheduled": "方案变更预定成功。",
|
||||
"manage_subscription": "管理订阅",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
|
||||
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
|
||||
"plan_hobby": "兴趣版",
|
||||
"plan_hobby_description": "适合开始使用 Formbricks Cloud 的个人和小团队。",
|
||||
"plan_hobby_feature_responses": "250 条回复 / 月",
|
||||
"plan_hobby_feature_workspaces": "1 个工作区",
|
||||
"plan_pro": "专业版",
|
||||
"plan_pro_description": "适合需要更高限额、自动化功能和动态超额使用的成长型团队。",
|
||||
"plan_pro_feature_responses": "2,000 条回复 / 月(动态超额)",
|
||||
"plan_pro_feature_workspaces": "3 个工作区",
|
||||
"plan_scale": "规模版",
|
||||
"plan_scale_description": "适合需要更大容量、更强治理能力和更高响应量的大型团队。",
|
||||
"plan_scale_feature_responses": "每月 5,000 次响应(动态超额)",
|
||||
"plan_scale_feature_workspaces": "5 个工作区",
|
||||
"plan_selection_description": "比较 Hobby、Pro 和 Scale 套餐,然后直接从 Formbricks 切换套餐。",
|
||||
"plan_selection_title": "选择您的套餐",
|
||||
"plan_unknown": "未知",
|
||||
"remove_branding": "移除 品牌",
|
||||
"retry_setup": "重试设置",
|
||||
"scale_banner_description": "升级到 Scale 套餐,解锁更高额度、团队协作和高级安全功能。",
|
||||
"scale_banner_title": "准备好扩容了吗?",
|
||||
"scale_feature_api": "完整 API 访问权限",
|
||||
"scale_feature_quota": "额度管理",
|
||||
"scale_feature_spam": "垃圾防护",
|
||||
"scale_feature_teams": "团队与访问角色",
|
||||
"select_plan_header_subtitle": "无需信用卡,没有任何附加条件。",
|
||||
"select_plan_header_title": "无缝集成的调查问卷,100% 展现您的品牌。",
|
||||
"select_plan_header_title": "立即发布专业的无品牌调查!",
|
||||
"status_trialing": "试用版",
|
||||
"stay_on_hobby_plan": "我想继续使用免费版计划",
|
||||
"stripe_setup_incomplete": "账单设置未完成",
|
||||
"stripe_setup_incomplete_description": "账单设置未成功完成。请重试以激活订阅。",
|
||||
"subscription": "订阅",
|
||||
"subscription_description": "管理你的订阅套餐并监控用量",
|
||||
"switch_at_period_end": "在周期结束时切换",
|
||||
"switch_plan_now": "立即切换套餐",
|
||||
"this_includes": "包含以下内容",
|
||||
"trial_alert_description": "添加支付方式以继续使用所有功能。",
|
||||
"trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。",
|
||||
"trial_feature_api_access": "API 访问",
|
||||
"trial_feature_attribute_segmentation": "基于属性的细分",
|
||||
"trial_feature_contact_segment_management": "联系人和细分管理",
|
||||
"trial_feature_email_followups": "电子邮件跟进",
|
||||
"trial_feature_hide_branding": "隐藏 Formbricks 品牌标识",
|
||||
"trial_feature_mobile_sdks": "iOS 和 Android SDK",
|
||||
"trial_feature_respondent_identification": "受访者识别",
|
||||
"trial_feature_unlimited_seats": "无限席位",
|
||||
"trial_feature_webhooks": "自定义 Webhook",
|
||||
"trial_feature_api_access": "获取完整 API 访问权限",
|
||||
"trial_feature_collaboration": "所有团队和协作功能",
|
||||
"trial_feature_email_followups": "设置邮件跟进",
|
||||
"trial_feature_quotas": "管理配额",
|
||||
"trial_feature_webhooks": "设置自定义 Webhook",
|
||||
"trial_feature_whitelabel": "完全白标化的问卷调查",
|
||||
"trial_no_credit_card": "14 天试用,无需信用卡",
|
||||
"trial_payment_method_added_description": "一切就绪!试用期结束后,您的专业版计划将自动继续。",
|
||||
"trial_title": "免费获取 Formbricks Pro!",
|
||||
"trial_title": "免费试用专业版功能!",
|
||||
"unlimited_responses": "无限反馈",
|
||||
"unlimited_workspaces": "无限工作区",
|
||||
"upgrade": "升级",
|
||||
"upgrade_now": "立即升级",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已用",
|
||||
"yearly": "按年付费",
|
||||
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
|
||||
"your_plan": "你的套餐"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "企业 功能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
|
||||
"license_feature_access_control": "访问控制(RBAC)",
|
||||
"license_feature_audit_logs": "审计日志",
|
||||
"license_feature_contacts": "联系人与细分",
|
||||
"license_feature_projects": "工作空间",
|
||||
"license_feature_quotas": "配额",
|
||||
"license_feature_remove_branding": "移除品牌标识",
|
||||
"license_feature_saml": "SAML 单点登录",
|
||||
"license_feature_spam_protection": "垃圾信息防护",
|
||||
"license_feature_sso": "OIDC 单点登录",
|
||||
"license_feature_two_factor_auth": "双因素认证",
|
||||
"license_feature_whitelabel": "白标电子邮件",
|
||||
"license_features_table_access": "访问权限",
|
||||
"license_features_table_description": "此实例当前可用的企业功能和限制。",
|
||||
"license_features_table_disabled": "已禁用",
|
||||
"license_features_table_enabled": "已启用",
|
||||
"license_features_table_feature": "功能",
|
||||
"license_features_table_title": "许可功能",
|
||||
"license_features_table_unlimited": "无限制",
|
||||
"license_features_table_value": "值",
|
||||
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
|
||||
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
|
||||
"license_status": "许可证状态",
|
||||
"license_status_active": "已激活",
|
||||
"license_status_description": "你的企业许可证状态。",
|
||||
"license_status_expired": "已过期",
|
||||
"license_status_instance_mismatch": "已绑定到其他实例",
|
||||
"license_status_invalid": "许可证无效",
|
||||
"license_status_unreachable": "无法访问",
|
||||
"license_unreachable_grace_period": "无法连接到许可证服务器。在为期 3 天的宽限期内,你的企业功能仍然可用,宽限期将于 {gracePeriodEnd} 结束。",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "问题 ? 请 联系",
|
||||
"recheck_license": "重新检查许可证",
|
||||
"recheck_license_failed": "许可证检查失败。许可证服务器可能无法访问。",
|
||||
"recheck_license_instance_mismatch": "此许可证已绑定到另一个 Formbricks 实例。请联系 Formbricks 支持团队解除先前的绑定。",
|
||||
"recheck_license_invalid": "许可证密钥无效。请确认你的 ENTERPRISE_LICENSE_KEY。",
|
||||
"recheck_license_success": "许可证检查成功",
|
||||
"recheck_license_unreachable": "许可证服务器无法访问,请稍后再试。",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "保存 更改 时 出错",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
|
||||
"everyone": "所有 人",
|
||||
"expand_preview": "展开预览",
|
||||
"external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。",
|
||||
"fallback_missing": "备用 缺失",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
@@ -1711,7 +1646,6 @@
|
||||
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
|
||||
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
|
||||
"spam_protection_threshold_heading": "响应 阈值",
|
||||
"shrink_preview": "收起预览",
|
||||
"star": "星",
|
||||
"starts_with": "以...开始",
|
||||
"state": "状态",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "样式 设置 为 主题 风格",
|
||||
"subheading": "子标题",
|
||||
"subtract": "减 -",
|
||||
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
|
||||
"survey_completed_heading": "调查 完成",
|
||||
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
|
||||
"survey_display_settings": "调查显示设置",
|
||||
"survey_placement": "调查 放置",
|
||||
"survey_preview": "问卷预览 👀",
|
||||
"survey_styling": "表单 样式",
|
||||
"survey_trigger": "调查 触发",
|
||||
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "不,谢谢!",
|
||||
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
|
||||
"preview_survey_question_2_subheader": "这是一个示例描述。",
|
||||
"preview_survey_question_open_text_headline": "还有其他想分享的内容吗?",
|
||||
"preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?",
|
||||
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
|
||||
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
|
||||
"preview_survey_welcome_card_headline": "欢迎!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
|
||||
"coming_soon_title": "我们快完成啦!",
|
||||
"follow_up_label": "还有其他想补充的内容吗?",
|
||||
"follow_up_label": "你还有其他想补充的吗?",
|
||||
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
|
||||
"generate_button": "生成工作流",
|
||||
"heading": "你想创建什么样的工作流?",
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"new": "新增",
|
||||
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
|
||||
"next": "下一步",
|
||||
"no_actions_found": "找不到動作",
|
||||
"no_background_image_found": "找不到背景圖片。",
|
||||
"no_code": "無程式碼",
|
||||
"no_files_uploaded": "沒有上傳任何檔案",
|
||||
@@ -340,7 +339,6 @@
|
||||
"please_select_at_least_one_survey": "請選擇至少一個問卷",
|
||||
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
|
||||
"please_upgrade_your_plan": "請升級您的方案",
|
||||
"powered_by_formbricks": "由 Formbricks 提供技術支援",
|
||||
"preview": "預覽",
|
||||
"preview_survey": "預覽問卷",
|
||||
"privacy": "隱私權政策",
|
||||
@@ -436,9 +434,6 @@
|
||||
"title": "標題",
|
||||
"top_left": "左上",
|
||||
"top_right": "右上",
|
||||
"trial_days_remaining": "試用期剩餘 {count} 天",
|
||||
"trial_expired": "您的試用期已結束",
|
||||
"trial_one_day_remaining": "試用期剩餘 1 天",
|
||||
"try_again": "再試一次",
|
||||
"type": "類型",
|
||||
"unknown_survey": "未知問卷",
|
||||
@@ -446,7 +441,6 @@
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
"updated_at": "更新時間",
|
||||
"upgrade_plan": "升級方案",
|
||||
"upload": "上傳",
|
||||
"upload_failed": "上傳失敗。請再試一次。",
|
||||
"upload_input_description": "點擊或拖曳以上傳檔案。",
|
||||
@@ -974,80 +968,44 @@
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "新增付款方式",
|
||||
"add_payment_method_to_upgrade_tooltip": "請先在上方新增付款方式以升級至付費方案",
|
||||
"billing_interval_toggle": "帳單週期",
|
||||
"current_plan_badge": "目前",
|
||||
"current_plan_cta": "目前方案",
|
||||
"custom_plan_description": "您的組織使用自訂計費設定。您仍可切換至下方的標準方案。",
|
||||
"custom_plan_title": "自訂方案",
|
||||
"cancelling": "正在取消",
|
||||
"failed_to_start_trial": "無法開始試用。請再試一次。",
|
||||
"keep_current_plan": "保留目前方案",
|
||||
"manage_billing_details": "管理卡片資訊與發票",
|
||||
"monthly": "每月",
|
||||
"most_popular": "最受歡迎",
|
||||
"pending_change_removed": "已取消預定的方案變更。",
|
||||
"pending_plan_badge": "已排程",
|
||||
"pending_plan_change_description": "您的方案將於 {{date}} 切換至 {{plan}}。",
|
||||
"pending_plan_change_title": "已排程的方案變更",
|
||||
"pending_plan_cta": "已排程",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_change_applied": "方案更新成功。",
|
||||
"plan_change_scheduled": "方案變更已成功排程。",
|
||||
"manage_subscription": "管理訂閱",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "包含 Hobby 的所有功能",
|
||||
"plan_feature_everything_in_pro": "包含 Pro 的所有功能",
|
||||
"plan_hobby": "興趣版",
|
||||
"plan_hobby_description": "適合個人與小型團隊開始使用 Formbricks Cloud。",
|
||||
"plan_hobby_feature_responses": "每月 250 次回應",
|
||||
"plan_hobby_feature_workspaces": "1 個工作區",
|
||||
"plan_pro": "專業版",
|
||||
"plan_pro_description": "適合需要更高限制、自動化功能和彈性超量使用的成長中團隊。",
|
||||
"plan_pro_feature_responses": "每月 2,000 次回應(動態超量計費)",
|
||||
"plan_pro_feature_workspaces": "3 個工作區",
|
||||
"plan_scale": "規模版",
|
||||
"plan_scale_description": "適合需要更大容量、更強管理機制和更高回應量的大型團隊。",
|
||||
"plan_scale_feature_responses": "每月 5,000 則回應(動態超額計費)",
|
||||
"plan_scale_feature_workspaces": "5 個工作區",
|
||||
"plan_selection_description": "比較 Hobby、Pro 和 Scale 方案,然後直接在 Formbricks 中切換方案。",
|
||||
"plan_selection_title": "選擇您的方案",
|
||||
"plan_unknown": "未知",
|
||||
"remove_branding": "移除品牌",
|
||||
"retry_setup": "重新設定",
|
||||
"scale_banner_description": "加入 Scale 方案,解鎖更高限制、團隊協作和進階安全功能。",
|
||||
"scale_banner_title": "準備好升級規模了嗎?",
|
||||
"scale_feature_api": "完整 API 存取",
|
||||
"scale_feature_quota": "額度管理",
|
||||
"scale_feature_spam": "垃圾訊息防護",
|
||||
"scale_feature_teams": "團隊與存取權限",
|
||||
"select_plan_header_subtitle": "無需信用卡,完全沒有附加條件。",
|
||||
"select_plan_header_title": "完美整合的問卷調查,100% 展現你的品牌。",
|
||||
"select_plan_header_title": "立即發送專業、無品牌標記的問卷調查!",
|
||||
"status_trialing": "試用版",
|
||||
"stay_on_hobby_plan": "我想繼續使用 Hobby 方案",
|
||||
"stripe_setup_incomplete": "帳單設定尚未完成",
|
||||
"stripe_setup_incomplete_description": "帳單設定未成功完成,請重新操作以啟用訂閱。",
|
||||
"subscription": "訂閱",
|
||||
"subscription_description": "管理您的訂閱方案並監控用量",
|
||||
"switch_at_period_end": "週期結束時切換",
|
||||
"switch_plan_now": "立即切換方案",
|
||||
"this_includes": "包含內容",
|
||||
"trial_alert_description": "新增付款方式以繼續使用所有功能。",
|
||||
"trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。",
|
||||
"trial_feature_api_access": "API 存取",
|
||||
"trial_feature_attribute_segmentation": "基於屬性的分群",
|
||||
"trial_feature_contact_segment_management": "聯絡人與分群管理",
|
||||
"trial_feature_email_followups": "電子郵件追蹤",
|
||||
"trial_feature_hide_branding": "隱藏 Formbricks 品牌標識",
|
||||
"trial_feature_mobile_sdks": "iOS 與 Android SDK",
|
||||
"trial_feature_respondent_identification": "受訪者識別",
|
||||
"trial_feature_unlimited_seats": "無限座位數",
|
||||
"trial_feature_webhooks": "自訂 Webhook",
|
||||
"trial_feature_api_access": "獲得完整 API 存取權限",
|
||||
"trial_feature_collaboration": "所有團隊與協作功能",
|
||||
"trial_feature_email_followups": "設定電子郵件追蹤",
|
||||
"trial_feature_quotas": "管理配額",
|
||||
"trial_feature_webhooks": "設定自訂 Webhook",
|
||||
"trial_feature_whitelabel": "完全白標問卷調查",
|
||||
"trial_no_credit_card": "14 天試用,無需信用卡",
|
||||
"trial_payment_method_added_description": "一切就緒!試用期結束後,您的 Pro 方案將自動繼續。",
|
||||
"trial_title": "免費獲得 Formbricks Pro!",
|
||||
"trial_title": "免費試用 Pro 功能!",
|
||||
"unlimited_responses": "無限回應",
|
||||
"unlimited_workspaces": "無限工作區",
|
||||
"upgrade": "升級",
|
||||
"upgrade_now": "立即升級",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已使用",
|
||||
"yearly": "年繳",
|
||||
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
|
||||
"your_plan": "您的方案"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1073,32 +1031,11 @@
|
||||
"enterprise_features": "企業版功能",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
|
||||
"license_feature_access_control": "存取控制 (RBAC)",
|
||||
"license_feature_audit_logs": "稽核日誌",
|
||||
"license_feature_contacts": "聯絡人與區隔",
|
||||
"license_feature_projects": "工作區",
|
||||
"license_feature_quotas": "配額",
|
||||
"license_feature_remove_branding": "移除品牌標識",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "垃圾訊息防護",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "雙重驗證",
|
||||
"license_feature_whitelabel": "白標電子郵件",
|
||||
"license_features_table_access": "存取權限",
|
||||
"license_features_table_description": "此執行個體目前可使用的企業功能與限制。",
|
||||
"license_features_table_disabled": "已停用",
|
||||
"license_features_table_enabled": "已啟用",
|
||||
"license_features_table_feature": "功能",
|
||||
"license_features_table_title": "授權功能",
|
||||
"license_features_table_unlimited": "無限制",
|
||||
"license_features_table_value": "值",
|
||||
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
|
||||
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
|
||||
"license_status": "授權狀態",
|
||||
"license_status_active": "有效",
|
||||
"license_status_description": "你的企業授權狀態。",
|
||||
"license_status_expired": "已過期",
|
||||
"license_status_instance_mismatch": "已綁定至其他執行個體",
|
||||
"license_status_invalid": "授權無效",
|
||||
"license_status_unreachable": "無法連線",
|
||||
"license_unreachable_grace_period": "無法連線至授權伺服器。在 3 天的寬限期內,你的企業功能仍可使用,寬限期將於 {gracePeriodEnd} 結束。",
|
||||
@@ -1109,7 +1046,6 @@
|
||||
"questions_please_reach_out_to": "有任何問題?請聯絡",
|
||||
"recheck_license": "重新檢查授權",
|
||||
"recheck_license_failed": "授權檢查失敗。授權伺服器可能無法連線。",
|
||||
"recheck_license_instance_mismatch": "此授權已綁定至不同的 Formbricks 執行個體。請聯繫 Formbricks 支援以解除先前的綁定。",
|
||||
"recheck_license_invalid": "授權金鑰無效。請確認你的 ENTERPRISE_LICENSE_KEY。",
|
||||
"recheck_license_success": "授權檢查成功",
|
||||
"recheck_license_unreachable": "授權伺服器無法連線,請稍後再試。",
|
||||
@@ -1451,7 +1387,6 @@
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
|
||||
"everyone": "所有人",
|
||||
"expand_preview": "展開預覽",
|
||||
"external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。",
|
||||
"fallback_missing": "遺失的回退",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
@@ -1711,7 +1646,6 @@
|
||||
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
|
||||
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
|
||||
"spam_protection_threshold_heading": "回應閾值",
|
||||
"shrink_preview": "收合預覽",
|
||||
"star": "星形",
|
||||
"starts_with": "開頭為",
|
||||
"state": "州/省",
|
||||
@@ -1721,12 +1655,10 @@
|
||||
"styling_set_to_theme_styles": "樣式設定為主題樣式",
|
||||
"subheading": "副標題",
|
||||
"subtract": "減 -",
|
||||
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
|
||||
"survey_completed_heading": "問卷已完成",
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
"survey_placement": "問卷位置",
|
||||
"survey_preview": "問卷預覽 👀",
|
||||
"survey_styling": "表單樣式設定",
|
||||
"survey_trigger": "問卷觸發器",
|
||||
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
|
||||
@@ -3077,7 +3009,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
|
||||
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
|
||||
"preview_survey_question_2_subheader": "這是一個範例說明。",
|
||||
"preview_survey_question_open_text_headline": "還有其他想分享的嗎?",
|
||||
"preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎?",
|
||||
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
|
||||
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
|
||||
"preview_survey_welcome_card_headline": "歡迎!",
|
||||
@@ -3332,7 +3264,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
|
||||
"coming_soon_title": "快完成囉!",
|
||||
"follow_up_label": "還有其他想補充的嗎?",
|
||||
"follow_up_label": "還有什麼想補充的嗎?",
|
||||
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
|
||||
"generate_button": "產生工作流程",
|
||||
"heading": "你想建立什麼樣的工作流程?",
|
||||
|
||||
@@ -5,9 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
@@ -17,7 +15,6 @@ interface ElementSkipProps {
|
||||
elements: TSurveyElement[];
|
||||
isFirstElementAnswered?: boolean;
|
||||
responseData: TResponseData;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ElementSkip = ({
|
||||
@@ -26,10 +23,8 @@ export const ElementSkip = ({
|
||||
elements,
|
||||
isFirstElementAnswered,
|
||||
responseData,
|
||||
locale,
|
||||
}: ElementSkipProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dateFormats = getSurveyDateFormatMap(elements);
|
||||
return (
|
||||
<div>
|
||||
{skippedElements && (
|
||||
@@ -86,11 +81,7 @@ export const ElementSkip = ({
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData,
|
||||
undefined,
|
||||
false,
|
||||
locale,
|
||||
dateFormats
|
||||
responseData
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
@@ -129,11 +120,7 @@ export const ElementSkip = ({
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData,
|
||||
undefined,
|
||||
false,
|
||||
locale,
|
||||
dateFormats
|
||||
responseData
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
|
||||
+4
-6
@@ -3,12 +3,11 @@ import React from "react";
|
||||
import { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
|
||||
@@ -22,7 +21,6 @@ interface RenderResponseProps {
|
||||
element: TSurveyElement;
|
||||
survey: TSurvey;
|
||||
language: string | null;
|
||||
locale: TUserLocale;
|
||||
isExpanded?: boolean;
|
||||
showId: boolean;
|
||||
}
|
||||
@@ -32,7 +30,6 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
element,
|
||||
survey,
|
||||
language,
|
||||
locale,
|
||||
isExpanded = true,
|
||||
showId,
|
||||
}) => {
|
||||
@@ -66,8 +63,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
break;
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
if (typeof responseData === "string") {
|
||||
const formattedDate =
|
||||
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
|
||||
const parsedDate = new Date(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>;
|
||||
}
|
||||
|
||||
+1
-11
@@ -6,9 +6,7 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -23,17 +21,14 @@ interface SingleResponseCardBodyProps {
|
||||
survey: TSurvey;
|
||||
response: TResponseWithQuotas;
|
||||
skippedQuestions: string[][];
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const SingleResponseCardBody = ({
|
||||
survey,
|
||||
response,
|
||||
skippedQuestions,
|
||||
locale,
|
||||
}: SingleResponseCardBodyProps) => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const dateFormats = getSurveyDateFormatMap(elements);
|
||||
const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false;
|
||||
const { t } = useTranslation();
|
||||
const formatTextWithSlashes = (text: string) => {
|
||||
@@ -66,7 +61,6 @@ export const SingleResponseCardBody = ({
|
||||
status={"welcomeCard"}
|
||||
isFirstElementAnswered={isFirstElementAnswered}
|
||||
responseData={response.data}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
@@ -104,9 +98,7 @@ export const SingleResponseCardBody = ({
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
response.data,
|
||||
response.variables,
|
||||
true,
|
||||
locale,
|
||||
dateFormats
|
||||
true
|
||||
)
|
||||
)
|
||||
)}
|
||||
@@ -117,7 +109,6 @@ export const SingleResponseCardBody = ({
|
||||
survey={survey}
|
||||
responseData={response.data[question.id]}
|
||||
language={response.language}
|
||||
locale={locale}
|
||||
showId={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -127,7 +118,6 @@ export const SingleResponseCardBody = ({
|
||||
skippedElements={skipped}
|
||||
elements={elements}
|
||||
responseData={response.data}
|
||||
locale={locale}
|
||||
status={
|
||||
response.finished ||
|
||||
(skippedQuestions.length > 0 &&
|
||||
|
||||
@@ -137,12 +137,7 @@ export const SingleResponseCard = ({
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<SingleResponseCardBody
|
||||
survey={survey}
|
||||
response={response}
|
||||
skippedQuestions={skippedQuestions}
|
||||
locale={locale}
|
||||
/>
|
||||
<SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
|
||||
|
||||
<ResponseTagsWrapper
|
||||
key={response.id}
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details with method and path", () => {
|
||||
test("logs API error details", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -228,7 +228,7 @@ describe("utils", () => {
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -238,11 +238,9 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "POST",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -277,8 +275,6 @@ describe("utils", () => {
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "",
|
||||
method: "GET",
|
||||
path: "/api/test",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -289,7 +285,7 @@ describe("utils", () => {
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
|
||||
test("log API error details with SENTRY_DSN set", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -299,23 +295,11 @@ describe("utils", () => {
|
||||
// Mock Sentry's captureException method
|
||||
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
|
||||
|
||||
// Capture the scope mock for tag verification
|
||||
const scopeSetTagMock = vi.fn();
|
||||
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: scopeSetTagMock,
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -325,60 +309,20 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "DELETE",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Verify Sentry scope tags include method and path
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("does not send to Sentry for non-internal_server_error types", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
});
|
||||
|
||||
vi.mocked(Sentry.captureException).mockClear();
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
|
||||
mockRequest.headers.set("x-request-id", "456");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify Sentry.captureException was NOT called for non-500 errors
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// But structured logging should still happen
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,13 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
|
||||
// Use Sentry scope to add correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setTag("method", method);
|
||||
scope.setTag("path", path);
|
||||
scope.setLevel("error");
|
||||
|
||||
scope.setExtra("originalError", error);
|
||||
@@ -29,8 +24,6 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
logger
|
||||
.withContext({
|
||||
correlationId,
|
||||
method,
|
||||
path,
|
||||
error,
|
||||
})
|
||||
.error("API V2 Error Details");
|
||||
|
||||
-13
@@ -1,4 +1,3 @@
|
||||
import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
export const GET = async (request: Request, props: { params: Promise<TContactLinkParams> }) =>
|
||||
@@ -49,17 +47,6 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
|
||||
});
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(params.surveyId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
return handleApiError(request, {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{ field: "contacts", issue: "Contacts are only enabled for Enterprise Edition, please upgrade." },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const surveyResult = await getSurvey(params.surveyId);
|
||||
|
||||
if (!surveyResult.ok) {
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("Auth Utils", () => {
|
||||
expect(hash1).not.toBe(hash2);
|
||||
expect(await verifyPassword(password, hash1)).toBe(true);
|
||||
expect(await verifyPassword(password, hash2)).toBe(true);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
test("should hash complex passwords correctly", async () => {
|
||||
const complexPassword = "MyC0mpl3x!P@ssw0rd#2024$%^&*()";
|
||||
@@ -135,7 +135,7 @@ describe("Auth Utils", () => {
|
||||
expect(hashedComplex.length).toBe(60);
|
||||
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
|
||||
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
test("should handle bcrypt errors gracefully and log warning", async () => {
|
||||
// Save the original bcryptjs implementation
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZCloudBillingInterval } from "@formbricks/types/organizations";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -11,17 +10,14 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session";
|
||||
import { createSetupCheckoutSession } from "@/modules/ee/billing/api/lib/create-setup-checkout-session";
|
||||
import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled";
|
||||
import {
|
||||
createPaidPlanCheckoutSession,
|
||||
createProTrialSubscription,
|
||||
createScaleTrialSubscription,
|
||||
ensureCloudStripeSetupForOrganization,
|
||||
ensureStripeCustomerForOrganization,
|
||||
reconcileCloudStripeSubscriptionsForOrganization,
|
||||
switchOrganizationToCloudPlan,
|
||||
syncOrganizationBillingFromStripe,
|
||||
undoPendingOrganizationPlanChange,
|
||||
} from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { stripeClient } from "@/modules/ee/billing/lib/stripe-client";
|
||||
|
||||
const ZManageSubscriptionAction = z.object({
|
||||
environmentId: ZId,
|
||||
@@ -49,7 +45,7 @@ export const manageSubscriptionAction = authenticatedActionClient
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
throw new AuthorizationError("You do not have an associated Stripe CustomerId");
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
@@ -57,64 +53,75 @@ export const manageSubscriptionAction = authenticatedActionClient
|
||||
organization.billing.stripeCustomerId,
|
||||
`${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`
|
||||
);
|
||||
ctx.auditLoggingCtx.newObject = { portalSessionCreated: true };
|
||||
ctx.auditLoggingCtx.newObject = { portalSession: result };
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZCreatePlanCheckoutAction = z.object({
|
||||
environmentId: ZId,
|
||||
targetPlan: z.enum(["pro", "scale"]),
|
||||
targetInterval: ZCloudBillingInterval,
|
||||
const ZIsSubscriptionCancelledAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const createPlanCheckoutAction = authenticatedActionClient
|
||||
.inputSchema(ZCreatePlanCheckoutAction)
|
||||
.action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
export const isSubscriptionCancelledAction = authenticatedActionClient
|
||||
.inputSchema(ZIsSubscriptionCancelledAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
return await isSubscriptionCancelled(parsedInput.organizationId);
|
||||
});
|
||||
|
||||
if (!organization.billing?.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
const ZCreatePricingTableCustomerSessionAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
if (organization.billing.stripe?.subscriptionId) {
|
||||
throw new OperationNotAllowedError("paid_checkout_requires_no_existing_subscription");
|
||||
}
|
||||
export const createPricingTableCustomerSessionAction = authenticatedActionClient
|
||||
.inputSchema(ZCreatePricingTableCustomerSessionAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const checkoutUrl = await createPaidPlanCheckoutSession({
|
||||
organizationId,
|
||||
customerId: organization.billing.stripeCustomerId,
|
||||
environmentId: parsedInput.environmentId,
|
||||
plan: parsedInput.targetPlan,
|
||||
interval: parsedInput.targetInterval,
|
||||
});
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
checkoutCreated: true,
|
||||
targetPlan: parsedInput.targetPlan,
|
||||
targetInterval: parsedInput.targetInterval,
|
||||
};
|
||||
if (!organization.billing?.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
return checkoutUrl;
|
||||
})
|
||||
);
|
||||
if (!stripeClient) {
|
||||
return { clientSecret: null };
|
||||
}
|
||||
|
||||
const customerSession = await stripeClient.customerSessions.create({
|
||||
customer: organization.billing.stripeCustomerId,
|
||||
components: {
|
||||
pricing_table: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { clientSecret: customerSession.client_secret ?? null };
|
||||
});
|
||||
|
||||
const ZRetryStripeSetupAction = z.object({
|
||||
organizationId: ZId,
|
||||
@@ -138,59 +145,11 @@ export const retryStripeSetupAction = authenticatedActionClient
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const ZCreateTrialPaymentCheckoutAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const createTrialPaymentCheckoutAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateTrialPaymentCheckoutAction)
|
||||
.action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
const subscriptionId = organization.billing.stripe?.subscriptionId;
|
||||
if (!subscriptionId) {
|
||||
throw new ResourceNotFoundError("subscription", organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const returnUrl = `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing`;
|
||||
const checkoutUrl = await createSetupCheckoutSession(
|
||||
organization.billing.stripeCustomerId,
|
||||
subscriptionId,
|
||||
returnUrl,
|
||||
organizationId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = { setupCheckoutCreated: true };
|
||||
return checkoutUrl;
|
||||
})
|
||||
);
|
||||
|
||||
const ZStartScaleTrialAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const startHobbyAction = authenticatedActionClient
|
||||
export const startScaleTrialAction = authenticatedActionClient
|
||||
.inputSchema(ZStartScaleTrialAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
@@ -209,143 +168,12 @@ export const startHobbyAction = authenticatedActionClient
|
||||
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
organization.billing?.stripeCustomerId ??
|
||||
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
|
||||
if (!customerId) {
|
||||
if (!organization.billing?.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
|
||||
await createScaleTrialSubscription(parsedInput.organizationId, organization.billing.stripeCustomerId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "scale-trial");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const startProTrialAction = authenticatedActionClient
|
||||
.inputSchema(ZStartScaleTrialAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(parsedInput.organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
organization.billing?.stripeCustomerId ??
|
||||
(await ensureStripeCustomerForOrganization(parsedInput.organizationId)).customerId;
|
||||
if (!customerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
await createProTrialSubscription(parsedInput.organizationId, customerId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const ZChangeBillingPlanAction = z.discriminatedUnion("targetPlan", [
|
||||
z.object({
|
||||
environmentId: ZId,
|
||||
targetPlan: z.literal("hobby"),
|
||||
targetInterval: z.literal("monthly"),
|
||||
}),
|
||||
z.object({
|
||||
environmentId: ZId,
|
||||
targetPlan: z.enum(["pro", "scale"]),
|
||||
targetInterval: ZCloudBillingInterval,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const changeBillingPlanAction = authenticatedActionClient.inputSchema(ZChangeBillingPlanAction).action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
const result = await switchOrganizationToCloudPlan({
|
||||
organizationId,
|
||||
customerId: organization.billing.stripeCustomerId,
|
||||
targetPlan: parsedInput.targetPlan,
|
||||
targetInterval: parsedInput.targetInterval,
|
||||
});
|
||||
|
||||
if (result.mode === "immediate") {
|
||||
await syncOrganizationBillingFromStripe(organizationId);
|
||||
}
|
||||
// Scheduled downgrades already persist the pending snapshot locally and
|
||||
// the ensuing subscription_schedule webhook performs the full Stripe resync.
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
targetPlan: parsedInput.targetPlan,
|
||||
targetInterval: parsedInput.targetInterval,
|
||||
mode: result.mode,
|
||||
};
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZUndoPendingPlanChangeAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const undoPendingPlanChangeAction = authenticatedActionClient
|
||||
.inputSchema(ZUndoPendingPlanChangeAction)
|
||||
.action(
|
||||
withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", organizationId);
|
||||
}
|
||||
|
||||
if (!organization.billing.stripeCustomerId) {
|
||||
throw new ResourceNotFoundError("OrganizationBilling", organizationId);
|
||||
}
|
||||
|
||||
await undoPendingOrganizationPlanChange(organizationId, organization.billing.stripeCustomerId);
|
||||
await syncOrganizationBillingFromStripe(organizationId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import Stripe from "stripe";
|
||||
import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Creates a Stripe Checkout Session in `setup` mode so the customer can enter
|
||||
* a payment method, billing address, and tax ID — without creating a new subscription.
|
||||
* After completion the webhook handler attaches the payment method to the existing
|
||||
* trial subscription.
|
||||
*/
|
||||
export const createSetupCheckoutSession = async (
|
||||
stripeCustomerId: string,
|
||||
subscriptionId: string,
|
||||
returnUrl: string,
|
||||
organizationId: string
|
||||
): Promise<string> => {
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set.");
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
|
||||
});
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const currency = subscription.currency ?? "usd";
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "setup",
|
||||
customer: stripeCustomerId,
|
||||
currency,
|
||||
billing_address_collection: "required",
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
required: "if_supported",
|
||||
},
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
name: "auto",
|
||||
},
|
||||
success_url: `${returnUrl}?checkout_success=1`,
|
||||
cancel_url: returnUrl,
|
||||
metadata: {
|
||||
organizationId,
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error("Stripe did not return a Checkout Session URL");
|
||||
}
|
||||
|
||||
return session.url;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getStripeClient } from "./stripe-client";
|
||||
|
||||
export const isSubscriptionCancelled = async (
|
||||
organizationId: string
|
||||
): Promise<{
|
||||
cancelled: boolean;
|
||||
date: Date | null;
|
||||
}> => {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Team not found.");
|
||||
let isNewTeam =
|
||||
!organization.billing.stripeCustomerId ||
|
||||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
|
||||
|
||||
if (!organization.billing.stripeCustomerId || isNewTeam) {
|
||||
return {
|
||||
cancelled: false,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: organization.billing.stripeCustomerId,
|
||||
});
|
||||
|
||||
for (const subscription of subscriptions.data) {
|
||||
if (subscription.cancel_at_period_end) {
|
||||
const periodEndTimestamp = subscription.cancel_at ?? subscription.items.data[0]?.current_period_end;
|
||||
return {
|
||||
cancelled: true,
|
||||
date: periodEndTimestamp ? new Date(periodEndTimestamp * 1000) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
cancelled: false,
|
||||
date: null,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(err, "Error checking if subscription is cancelled");
|
||||
return {
|
||||
cancelled: false,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -12,55 +12,10 @@ const relevantEvents = new Set([
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"invoice.finalized",
|
||||
"entitlements.active_entitlement_summary.updated",
|
||||
"subscription_schedule.created",
|
||||
"subscription_schedule.updated",
|
||||
"subscription_schedule.released",
|
||||
"subscription_schedule.canceled",
|
||||
"subscription_schedule.completed",
|
||||
]);
|
||||
|
||||
/**
|
||||
* When a setup-mode Checkout Session completes, the customer has just provided a
|
||||
* payment method + billing address. We attach that payment method as the default
|
||||
* on the customer (for future invoices) and on the trial subscription so Stripe
|
||||
* can charge it when the trial ends.
|
||||
*/
|
||||
const handleSetupCheckoutCompleted = async (
|
||||
session: Stripe.Checkout.Session,
|
||||
stripe: Stripe
|
||||
): Promise<void> => {
|
||||
if (session.mode !== "setup" || !session.setup_intent) return;
|
||||
|
||||
const setupIntentId =
|
||||
typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent.id;
|
||||
|
||||
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||
const paymentMethodId =
|
||||
typeof setupIntent.payment_method === "string"
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
logger.warn({ sessionId: session.id }, "Setup checkout completed but no payment method found");
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
|
||||
if (customerId) {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionId = session.metadata?.subscriptionId;
|
||||
if (subscriptionId) {
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
default_payment_method: paymentMethodId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getMetadataOrganizationId = (eventObject: Stripe.Event.Data.Object): string | null => {
|
||||
if (!("metadata" in eventObject) || !eventObject.metadata) {
|
||||
return null;
|
||||
@@ -146,10 +101,6 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.type === "checkout.session.completed") {
|
||||
await handleSetupCheckoutCompleted(event.data.object, stripe);
|
||||
}
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
|
||||
await syncOrganizationBillingFromStripe(organizationId, {
|
||||
id: event.id,
|
||||
|
||||
@@ -1,39 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import { createElement, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type TCloudBillingInterval,
|
||||
type TOrganization,
|
||||
type TOrganizationStripePendingChange,
|
||||
type TOrganizationStripeSubscriptionStatus,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { TOrganization, TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import {
|
||||
changeBillingPlanAction,
|
||||
createPlanCheckoutAction,
|
||||
createTrialPaymentCheckoutAction,
|
||||
createPricingTableCustomerSessionAction,
|
||||
isSubscriptionCancelledAction,
|
||||
manageSubscriptionAction,
|
||||
retryStripeSetupAction,
|
||||
undoPendingPlanChangeAction,
|
||||
} from "../actions";
|
||||
import type { TStripeBillingCatalogDisplay } from "../lib/stripe-billing-catalog";
|
||||
import { TrialAlert } from "./trial-alert";
|
||||
import { UsageCard } from "./usage-card";
|
||||
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
const STRIPE_SUPPORTED_LOCALES = new Set([
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"en-GB",
|
||||
"es",
|
||||
"es-419",
|
||||
"et",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"fr-CA",
|
||||
"hr",
|
||||
"hu",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lt",
|
||||
"lv",
|
||||
"ms",
|
||||
"mt",
|
||||
"nb",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sl",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"vi",
|
||||
"zh",
|
||||
"zh-HK",
|
||||
"zh-TW",
|
||||
]);
|
||||
|
||||
type TDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "unknown";
|
||||
type TStandardPlan = "hobby" | "pro" | "scale";
|
||||
const getStripeLocaleOverride = (locale?: string): string | undefined => {
|
||||
if (!locale) return undefined;
|
||||
|
||||
const normalizedLocale = locale.trim();
|
||||
if (STRIPE_SUPPORTED_LOCALES.has(normalizedLocale)) {
|
||||
return normalizedLocale;
|
||||
}
|
||||
|
||||
const baseLocale = normalizedLocale.split("-")[0];
|
||||
if (STRIPE_SUPPORTED_LOCALES.has(baseLocale)) {
|
||||
return baseLocale;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
|
||||
interface PricingTableProps {
|
||||
organization: TOrganization;
|
||||
@@ -43,22 +87,17 @@ interface PricingTableProps {
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
hasBillingRights: boolean;
|
||||
currentCloudPlan: TDisplayPlan;
|
||||
currentBillingInterval: TCloudBillingInterval | null;
|
||||
currentCloudPlan: "hobby" | "pro" | "scale" | "custom" | "unknown";
|
||||
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
pendingChange: TOrganizationStripePendingChange | null;
|
||||
stripePublishableKey: string | null;
|
||||
stripePricingTableId: string | null;
|
||||
isStripeSetupIncomplete: boolean;
|
||||
trialDaysRemaining: number | null;
|
||||
billingCatalog: TStripeBillingCatalogDisplay;
|
||||
}
|
||||
|
||||
const STANDARD_PLAN_LEVEL: Record<TStandardPlan, number> = {
|
||||
hobby: 0,
|
||||
pro: 1,
|
||||
scale: 2,
|
||||
};
|
||||
|
||||
const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string) => {
|
||||
const getCurrentCloudPlanLabel = (
|
||||
plan: "hobby" | "pro" | "scale" | "custom" | "unknown",
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (plan === "hobby") return t("environments.settings.billing.plan_hobby");
|
||||
if (plan === "pro") return t("environments.settings.billing.plan_pro");
|
||||
if (plan === "scale") return t("environments.settings.billing.plan_scale");
|
||||
@@ -66,70 +105,6 @@ const getCurrentCloudPlanLabel = (plan: TDisplayPlan, t: (key: string) => string
|
||||
return t("environments.settings.billing.plan_unknown");
|
||||
};
|
||||
|
||||
const formatMoney = (currency: string, unitAmount: number | null, locale: string) => {
|
||||
if (unitAmount == null) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: unitAmount % 100 === 0 ? 0 : 2,
|
||||
}).format(unitAmount / 100);
|
||||
};
|
||||
|
||||
type TPlanCardData = {
|
||||
plan: TStandardPlan;
|
||||
interval: TCloudBillingInterval;
|
||||
amount: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const getPlanPeriodLabel = (
|
||||
plan: TStandardPlan,
|
||||
interval: TCloudBillingInterval,
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (plan === "hobby" || interval === "monthly") {
|
||||
return t("environments.settings.billing.per_month");
|
||||
}
|
||||
|
||||
return t("environments.settings.billing.per_year");
|
||||
};
|
||||
|
||||
const getPlanChangePayload = (environmentId: string, plan: TStandardPlan, interval: TCloudBillingInterval) =>
|
||||
plan === "hobby"
|
||||
? {
|
||||
environmentId,
|
||||
targetPlan: "hobby" as const,
|
||||
targetInterval: "monthly" as const,
|
||||
}
|
||||
: {
|
||||
environmentId,
|
||||
targetPlan: plan,
|
||||
targetInterval: interval,
|
||||
};
|
||||
|
||||
const getPlanChangeSuccessMessage = (
|
||||
mode: "immediate" | "scheduled" | undefined,
|
||||
t: (key: string) => string
|
||||
) => {
|
||||
if (mode === "scheduled") {
|
||||
return t("environments.settings.billing.plan_change_scheduled");
|
||||
}
|
||||
|
||||
return t("environments.settings.billing.plan_change_applied");
|
||||
};
|
||||
|
||||
const getActionErrorMessage = (serverError: string, t: (key: string) => string) => {
|
||||
if (serverError === "mixed_interval_checkout_unsupported") {
|
||||
return t("environments.settings.billing.yearly_checkout_unavailable");
|
||||
}
|
||||
|
||||
return t("common.something_went_wrong_please_try_again");
|
||||
};
|
||||
|
||||
export const PricingTable = ({
|
||||
environmentId,
|
||||
organization,
|
||||
@@ -139,144 +114,102 @@ export const PricingTable = ({
|
||||
usageCycleEnd,
|
||||
hasBillingRights,
|
||||
currentCloudPlan,
|
||||
currentBillingInterval,
|
||||
currentSubscriptionStatus,
|
||||
pendingChange,
|
||||
stripePublishableKey,
|
||||
stripePricingTableId,
|
||||
isStripeSetupIncomplete,
|
||||
trialDaysRemaining,
|
||||
billingCatalog,
|
||||
}: PricingTableProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isRetryingStripeSetup, setIsRetryingStripeSetup] = useState(false);
|
||||
const [isPlanActionPending, setIsPlanActionPending] = useState<string | null>(null);
|
||||
const [selectedInterval, setSelectedInterval] = useState<TCloudBillingInterval>(
|
||||
currentBillingInterval ?? "monthly"
|
||||
);
|
||||
const [cancellingOn, setCancellingOn] = useState<Date | null>(null);
|
||||
const [pricingTableCustomerSessionClientSecret, setPricingTableCustomerSessionClientSecret] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const isTrialing = currentSubscriptionStatus === "trialing";
|
||||
const hasPaymentMethod = organization.billing.stripe?.hasPaymentMethod === true;
|
||||
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
|
||||
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
|
||||
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
|
||||
const usageCycleLabel = `${formatDateForDisplay(usageCycleStart, locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})} - ${formatDateForDisplay(usageCycleEnd, locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}`;
|
||||
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
|
||||
const currentPlanLevel =
|
||||
currentCloudPlan === "hobby" || currentCloudPlan === "pro" || currentCloudPlan === "scale"
|
||||
? STANDARD_PLAN_LEVEL[currentCloudPlan]
|
||||
: null;
|
||||
const isUpgradeablePlan = currentCloudPlan === "hobby" || currentCloudPlan === "unknown";
|
||||
const showPricingTable =
|
||||
hasBillingRights && isUpgradeablePlan && !!stripePublishableKey && !!stripePricingTableId;
|
||||
const canManageSubscription =
|
||||
hasBillingRights && !isUpgradeablePlan && !!organization.billing.stripeCustomerId;
|
||||
const stripeLocaleOverride = useMemo(
|
||||
() => getStripeLocaleOverride(i18n.resolvedLanguage ?? i18n.language),
|
||||
[i18n.language, i18n.resolvedLanguage]
|
||||
);
|
||||
const stripePricingTableProps = useMemo(() => {
|
||||
const props: Record<string, string> = {
|
||||
"pricing-table-id": stripePricingTableId ?? "",
|
||||
"publishable-key": stripePublishableKey ?? "",
|
||||
};
|
||||
|
||||
if (stripeLocaleOverride) {
|
||||
props["__locale-override"] = stripeLocaleOverride;
|
||||
}
|
||||
|
||||
if (pricingTableCustomerSessionClientSecret) {
|
||||
props["customer-session-client-secret"] = pricingTableCustomerSessionClientSecret;
|
||||
} else {
|
||||
props["client-reference-id"] = organization.id;
|
||||
}
|
||||
|
||||
return props;
|
||||
}, [
|
||||
organization.id,
|
||||
pricingTableCustomerSessionClientSecret,
|
||||
stripeLocaleOverride,
|
||||
stripePricingTableId,
|
||||
stripePublishableKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("checkout_success")) {
|
||||
const timer = setTimeout(() => router.refresh(), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
const checkSubscriptionStatus = async () => {
|
||||
if (!hasBillingRights || !canManageSubscription) {
|
||||
setCancellingOn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isSubscriptionCancelledResponse = await isSubscriptionCancelledAction({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
if (isSubscriptionCancelledResponse?.data) {
|
||||
setCancellingOn(isSubscriptionCancelledResponse.data.date);
|
||||
}
|
||||
} catch {
|
||||
// Ignore permission/network failures here and keep rendering billing UI.
|
||||
}
|
||||
};
|
||||
checkSubscriptionStatus();
|
||||
}, [canManageSubscription, hasBillingRights, organization.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPricingTable) {
|
||||
setPricingTableCustomerSessionClientSecret(null);
|
||||
return;
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
const planCards = useMemo<TPlanCardData[]>(() => {
|
||||
return [
|
||||
{
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
amount: formatMoney(
|
||||
billingCatalog.hobby.monthly.currency,
|
||||
billingCatalog.hobby.monthly.unitAmount,
|
||||
locale
|
||||
),
|
||||
description: t("environments.settings.billing.plan_hobby_description"),
|
||||
features: [
|
||||
t("environments.settings.billing.plan_hobby_feature_workspaces"),
|
||||
t("environments.settings.billing.plan_hobby_feature_responses"),
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "pro",
|
||||
interval: selectedInterval,
|
||||
amount: formatMoney(
|
||||
billingCatalog.pro[selectedInterval].currency,
|
||||
billingCatalog.pro[selectedInterval].unitAmount,
|
||||
locale
|
||||
),
|
||||
description: t("environments.settings.billing.plan_pro_description"),
|
||||
features: [
|
||||
t("environments.settings.billing.plan_feature_everything_in_hobby"),
|
||||
t("environments.settings.billing.plan_pro_feature_workspaces"),
|
||||
t("environments.settings.billing.plan_pro_feature_responses"),
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "scale",
|
||||
interval: selectedInterval,
|
||||
amount: formatMoney(
|
||||
billingCatalog.scale[selectedInterval].currency,
|
||||
billingCatalog.scale[selectedInterval].unitAmount,
|
||||
locale
|
||||
),
|
||||
description: t("environments.settings.billing.plan_scale_description"),
|
||||
features: [
|
||||
t("environments.settings.billing.plan_feature_everything_in_pro"),
|
||||
t("environments.settings.billing.plan_scale_feature_workspaces"),
|
||||
t("environments.settings.billing.plan_scale_feature_responses"),
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [billingCatalog, locale, selectedInterval, t]);
|
||||
|
||||
const persistEnvironmentId = () => {
|
||||
if (globalThis.window !== undefined) {
|
||||
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToExternalUrl = (url: string) => {
|
||||
if (globalThis.window !== undefined) {
|
||||
globalThis.window.location.href = url;
|
||||
}
|
||||
};
|
||||
|
||||
const openBillingPortal = async () => {
|
||||
const response = await manageSubscriptionAction({ environmentId });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
if (response?.data && typeof response.data === "string") {
|
||||
router.push(response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
};
|
||||
|
||||
const openTrialPaymentCheckout = async () => {
|
||||
try {
|
||||
persistEnvironmentId();
|
||||
const response = await createTrialPaymentCheckoutAction({ environmentId });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
const loadPricingTableCustomerSession = async () => {
|
||||
try {
|
||||
const response = await createPricingTableCustomerSessionAction({ environmentId });
|
||||
setPricingTableCustomerSessionClientSecret(response?.data?.clientSecret ?? null);
|
||||
} catch {
|
||||
setPricingTableCustomerSessionClientSecret(null);
|
||||
}
|
||||
if (response?.data && typeof response.data === "string") {
|
||||
navigateToExternalUrl(response.data);
|
||||
return;
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} catch (error) {
|
||||
console.error("Failed to create setup checkout session:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
};
|
||||
|
||||
void loadPricingTableCustomerSession();
|
||||
}, [environmentId, showPricingTable]);
|
||||
|
||||
const openCustomerPortal = async () => {
|
||||
const manageSubscriptionResponse = await manageSubscriptionAction({
|
||||
environmentId,
|
||||
});
|
||||
if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") {
|
||||
router.push(manageSubscriptionResponse.data);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -284,15 +217,11 @@ export const PricingTable = ({
|
||||
setIsRetryingStripeSetup(true);
|
||||
try {
|
||||
const response = await retryStripeSetupAction({ organizationId: organization.id });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
if (response?.data) {
|
||||
router.refresh();
|
||||
return;
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
@@ -300,160 +229,23 @@ export const PricingTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToPlanCheckout = async (
|
||||
plan: Exclude<TStandardPlan, "hobby">,
|
||||
interval: TCloudBillingInterval
|
||||
): Promise<void> => {
|
||||
if (existingSubscriptionId) {
|
||||
await openTrialPaymentCheckout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (interval === "yearly") {
|
||||
toast.error(t("environments.settings.billing.yearly_checkout_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
persistEnvironmentId();
|
||||
const response = await createPlanCheckoutAction({
|
||||
environmentId,
|
||||
targetPlan: plan,
|
||||
targetInterval: interval,
|
||||
});
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.data && typeof response.data === "string") {
|
||||
navigateToExternalUrl(response.data);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
};
|
||||
|
||||
const handlePlanAction = async (plan: TStandardPlan, interval: TCloudBillingInterval) => {
|
||||
const actionKey = `${plan}-${interval}`;
|
||||
setIsPlanActionPending(actionKey);
|
||||
|
||||
try {
|
||||
if (!hasPaymentMethod && plan !== "hobby") {
|
||||
await redirectToPlanCheckout(plan, interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await changeBillingPlanAction(getPlanChangePayload(environmentId, plan, interval));
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
toast.success(getPlanChangeSuccessMessage(response?.data?.mode, t));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to change billing plan:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsPlanActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const undoPendingChange = async () => {
|
||||
setIsPlanActionPending("undo");
|
||||
try {
|
||||
const response = await undoPendingPlanChangeAction({ environmentId });
|
||||
if (response?.serverError) {
|
||||
toast.error(getActionErrorMessage(response.serverError, t));
|
||||
return;
|
||||
}
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.billing.pending_change_removed"));
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} catch (error) {
|
||||
console.error("Failed to undo pending plan change:", error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsPlanActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getCtaLabel = (plan: TStandardPlan, interval: TCloudBillingInterval) => {
|
||||
const isCurrentSelection =
|
||||
currentCloudPlan === plan && (plan === "hobby" || currentBillingInterval === interval);
|
||||
if (isCurrentSelection) {
|
||||
return t("environments.settings.billing.current_plan_cta");
|
||||
}
|
||||
|
||||
const isPendingSelection =
|
||||
pendingChange?.targetPlan === plan && (plan === "hobby" || pendingChange.targetInterval === interval);
|
||||
if (isPendingSelection) {
|
||||
return t("environments.settings.billing.pending_plan_cta");
|
||||
}
|
||||
|
||||
if (!hasPaymentMethod && plan !== "hobby") {
|
||||
return t("environments.settings.billing.upgrade_now");
|
||||
}
|
||||
|
||||
if (currentPlanLevel === null) {
|
||||
return t("environments.settings.billing.switch_plan_now");
|
||||
}
|
||||
|
||||
return STANDARD_PLAN_LEVEL[plan] > currentPlanLevel
|
||||
? t("environments.settings.billing.upgrade_now")
|
||||
: t("environments.settings.billing.switch_at_period_end");
|
||||
};
|
||||
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
|
||||
const usageCycleLabel = `${usageCycleStart.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})} - ${usageCycleEnd.toLocaleDateString(i18n.resolvedLanguage ?? i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex max-w-6xl flex-col gap-4">
|
||||
{trialDaysRemaining !== null &&
|
||||
(hasPaymentMethod ? (
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} hasPaymentMethod>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.trial_payment_method_added_description")}
|
||||
</AlertDescription>
|
||||
</TrialAlert>
|
||||
) : (
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining}>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.trial_alert_description")}
|
||||
</AlertDescription>
|
||||
{hasBillingRights && (
|
||||
<AlertButton onClick={() => void openTrialPaymentCheckout()}>
|
||||
{t("environments.settings.billing.add_payment_method")}
|
||||
</AlertButton>
|
||||
)}
|
||||
</TrialAlert>
|
||||
))}
|
||||
|
||||
{pendingChange && (
|
||||
<Alert variant="info" className="max-w-4xl">
|
||||
<AlertTitle>{t("environments.settings.billing.pending_plan_change_title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.pending_plan_change_description")
|
||||
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
|
||||
.replace(
|
||||
"{{date}}",
|
||||
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>
|
||||
)}
|
||||
|
||||
<div className="flex max-w-4xl flex-col gap-4">
|
||||
{isStripeSetupIncomplete && hasBillingRights && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{t("environments.settings.billing.stripe_setup_incomplete")}</AlertTitle>
|
||||
@@ -465,24 +257,14 @@ export const PricingTable = ({
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentCloudPlan === "custom" && (
|
||||
<Alert>
|
||||
<AlertTitle>{t("environments.settings.billing.custom_plan_title")}</AlertTitle>
|
||||
<AlertDescription>{t("environments.settings.billing.custom_plan_description")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SettingsCard
|
||||
title={t("environments.settings.billing.subscription")}
|
||||
description={t("environments.settings.billing.subscription_description")}
|
||||
buttonInfo={
|
||||
canShowSubscriptionButton
|
||||
canManageSubscription
|
||||
? {
|
||||
text: hasPaymentMethod
|
||||
? t("environments.settings.billing.manage_billing_details")
|
||||
: t("environments.settings.billing.add_payment_method"),
|
||||
onClick: () => void (hasPaymentMethod ? openBillingPortal() : openTrialPaymentCheckout()),
|
||||
text: t("environments.settings.billing.manage_subscription"),
|
||||
onClick: () => void openCustomerPortal(),
|
||||
variant: "default",
|
||||
}
|
||||
: undefined
|
||||
@@ -492,19 +274,8 @@ export const PricingTable = ({
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.settings.billing.your_plan")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="success" size="normal" text={getCurrentCloudPlanLabel(currentCloudPlan, t)} />
|
||||
{currentCloudPlan !== "hobby" && currentBillingInterval && (
|
||||
<Badge
|
||||
type="gray"
|
||||
size="normal"
|
||||
text={
|
||||
currentBillingInterval === "monthly"
|
||||
? t("environments.settings.billing.monthly")
|
||||
: t("environments.settings.billing.yearly")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentSubscriptionStatus === "trialing" && (
|
||||
<Badge
|
||||
type="warning"
|
||||
@@ -512,9 +283,24 @@ export const PricingTable = ({
|
||||
text={t("environments.settings.billing.status_trialing")}
|
||||
/>
|
||||
)}
|
||||
{cancellingOn && (
|
||||
<Badge
|
||||
type="warning"
|
||||
size="normal"
|
||||
text={`${t("environments.settings.billing.cancelling")}: ${cancellingOn.toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
}
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<UsageCard
|
||||
metric={t("common.responses")}
|
||||
@@ -523,11 +309,11 @@ export const PricingTable = ({
|
||||
isUnlimited={responsesUnlimitedCheck}
|
||||
unlimitedLabel={t("environments.settings.billing.unlimited_responses")}
|
||||
/>
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.settings.billing.usage_cycle")}: {usageCycleLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UsageCard
|
||||
metric={t("common.workspaces")}
|
||||
currentCount={projectCount}
|
||||
@@ -538,136 +324,35 @@ export const PricingTable = ({
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{showPlanSelector && (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.billing.plan_selection_title")}
|
||||
description={t("environments.settings.billing.plan_selection_description")}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div
|
||||
className="flex w-fit rounded-xl border border-slate-200 bg-slate-100 p-1"
|
||||
role="tablist"
|
||||
aria-label={t("environments.settings.billing.billing_interval_toggle")}>
|
||||
{(["monthly", "yearly"] as const).map((interval) => (
|
||||
<button
|
||||
key={interval}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedInterval === interval}
|
||||
tabIndex={selectedInterval === interval ? 0 : -1}
|
||||
onClick={() => setSelectedInterval(interval)}
|
||||
className={cn(
|
||||
"rounded-lg px-5 py-2 text-sm font-medium transition-colors",
|
||||
selectedInterval === interval
|
||||
? "bg-slate-900 text-white"
|
||||
: "text-slate-600 hover:text-slate-900"
|
||||
)}>
|
||||
{interval === "monthly"
|
||||
? t("environments.settings.billing.monthly")
|
||||
: t("environments.settings.billing.yearly")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{planCards.map((planCard) => {
|
||||
const isCurrentSelection =
|
||||
currentCloudPlan === planCard.plan &&
|
||||
(planCard.plan === "hobby" || currentBillingInterval === planCard.interval);
|
||||
const isPendingSelection =
|
||||
pendingChange?.targetPlan === planCard.plan &&
|
||||
(planCard.plan === "hobby" || pendingChange.targetInterval === planCard.interval);
|
||||
const isMissingPaymentMethodUpgrade =
|
||||
hasBillingRights &&
|
||||
!isStripeSetupIncomplete &&
|
||||
!isTrialing &&
|
||||
!isCurrentSelection &&
|
||||
!isPendingSelection &&
|
||||
!hasPaymentMethod &&
|
||||
planCard.plan !== "hobby";
|
||||
const isDisabled =
|
||||
!hasBillingRights ||
|
||||
isCurrentSelection ||
|
||||
isPendingSelection ||
|
||||
isStripeSetupIncomplete ||
|
||||
isMissingPaymentMethodUpgrade ||
|
||||
(isTrialing && !hasPaymentMethod);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${planCard.plan}-${planCard.interval}`}
|
||||
className={cn(
|
||||
"grid h-full grid-rows-[minmax(1.75rem,auto)_minmax(8rem,auto)_minmax(4.5rem,auto)_auto_1fr] rounded-2xl border bg-white p-6 shadow-sm",
|
||||
planCard.plan === "pro" ? "border-slate-900/20" : "border-slate-200"
|
||||
)}>
|
||||
<div className="mb-4 flex min-h-7 items-start gap-2">
|
||||
{planCard.plan === "pro" && (
|
||||
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">
|
||||
{t("environments.settings.billing.most_popular")}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentSelection && (
|
||||
<span className="rounded-md bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">
|
||||
{t("environments.settings.billing.current_plan_badge")}
|
||||
</span>
|
||||
)}
|
||||
{isPendingSelection && (
|
||||
<span className="rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
|
||||
{t("environments.settings.billing.pending_plan_badge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-32">
|
||||
<h3 className="text-3xl font-semibold text-slate-900">
|
||||
{getCurrentCloudPlanLabel(planCard.plan, t)}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">{planCard.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex min-h-[3rem] items-end gap-2">
|
||||
<span className="text-3xl font-normal tracking-tight text-slate-900">
|
||||
{planCard.amount}
|
||||
</span>
|
||||
<span className="pb-1 text-sm text-slate-500">
|
||||
{getPlanPeriodLabel(planCard.plan, planCard.interval, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipRenderer
|
||||
shouldRender={isMissingPaymentMethodUpgrade}
|
||||
triggerClass="block w-full"
|
||||
tooltipContent={t(
|
||||
"environments.settings.billing.add_payment_method_to_upgrade_tooltip"
|
||||
)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-4 w-full"
|
||||
disabled={isDisabled}
|
||||
loading={isPlanActionPending === `${planCard.plan}-${planCard.interval}`}
|
||||
onClick={() => void handlePlanAction(planCard.plan, planCard.interval)}>
|
||||
{getCtaLabel(planCard.plan, planCard.interval)}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<div className="mt-8 border-t border-slate-100 pt-6">
|
||||
<p className="mb-4 text-sm font-semibold text-slate-900">
|
||||
{t("environments.settings.billing.this_includes")}
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{planCard.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3 text-sm text-slate-700">
|
||||
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{currentCloudPlan === "pro" && (
|
||||
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-slate-800 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t("environments.settings.billing.scale_banner_title")}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-300">
|
||||
{t("environments.settings.billing.scale_banner_description")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-400">
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_teams")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_api")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_quota")}</span>
|
||||
<span>✓ {t("environments.settings.billing.scale_feature_spam")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={openCustomerPortal} className="shrink-0">
|
||||
{t("environments.settings.billing.upgrade")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPricingTable && (
|
||||
<div className="mb-12 w-full max-w-4xl">
|
||||
<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="afterInteractive" />
|
||||
{createElement("stripe-pricing-table", stripePricingTableProps)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -11,8 +11,7 @@ import ethereumLogo from "@/images/customer-logos/ethereum-logo.png";
|
||||
import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
|
||||
import githubLogo from "@/images/customer-logos/github-logo.png";
|
||||
import siemensLogo from "@/images/customer-logos/siemens.png";
|
||||
import { startProTrialAction } from "@/modules/ee/billing/actions";
|
||||
import { startHobbyAction } from "@/modules/ee/billing/actions";
|
||||
import { startScaleTrialAction } from "@/modules/ee/billing/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface SelectPlanCardProps {
|
||||
@@ -32,25 +31,21 @@ const CUSTOMER_LOGOS = [
|
||||
export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps) => {
|
||||
const router = useRouter();
|
||||
const [isStartingTrial, setIsStartingTrial] = useState(false);
|
||||
const [isStartingHobby, setIsStartingHobby] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const TRIAL_FEATURE_KEYS = [
|
||||
t("environments.settings.billing.trial_feature_unlimited_seats"),
|
||||
t("environments.settings.billing.trial_feature_hide_branding"),
|
||||
t("environments.settings.billing.trial_feature_respondent_identification"),
|
||||
t("environments.settings.billing.trial_feature_contact_segment_management"),
|
||||
t("environments.settings.billing.trial_feature_attribute_segmentation"),
|
||||
t("environments.settings.billing.trial_feature_mobile_sdks"),
|
||||
t("environments.settings.billing.trial_feature_email_followups"),
|
||||
t("environments.settings.billing.trial_feature_whitelabel"),
|
||||
t("environments.settings.billing.trial_feature_collaboration"),
|
||||
t("environments.settings.billing.trial_feature_webhooks"),
|
||||
t("environments.settings.billing.trial_feature_api_access"),
|
||||
t("environments.settings.billing.trial_feature_email_followups"),
|
||||
t("environments.settings.billing.trial_feature_quotas"),
|
||||
] as const;
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
setIsStartingTrial(true);
|
||||
try {
|
||||
const result = await startProTrialAction({ organizationId });
|
||||
const result = await startScaleTrialAction({ organizationId });
|
||||
if (result?.data) {
|
||||
router.push(nextUrl);
|
||||
} else if (result?.serverError === "trial_already_used") {
|
||||
@@ -66,20 +61,8 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueHobby = async () => {
|
||||
setIsStartingHobby(true);
|
||||
try {
|
||||
const result = await startHobbyAction({ organizationId });
|
||||
if (result?.data) {
|
||||
router.push(nextUrl);
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
setIsStartingHobby(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
setIsStartingHobby(false);
|
||||
}
|
||||
const handleContinueFree = () => {
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -112,7 +95,7 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
|
||||
onClick={handleStartTrial}
|
||||
className="mt-4 w-full"
|
||||
loading={isStartingTrial}
|
||||
disabled={isStartingTrial || isStartingHobby}>
|
||||
disabled={isStartingTrial}>
|
||||
{t("common.start_free_trial")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -138,10 +121,9 @@ export const SelectPlanCard = ({ nextUrl, organizationId }: SelectPlanCardProps)
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleContinueHobby}
|
||||
disabled={isStartingTrial || isStartingHobby}
|
||||
onClick={handleContinueFree}
|
||||
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
|
||||
{isStartingHobby ? t("common.loading") : t("environments.settings.billing.stay_on_hobby_plan")}
|
||||
{t("environments.settings.billing.stay_on_hobby_plan")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
|
||||
type TrialAlertVariant = "error" | "warning" | "info" | "success";
|
||||
|
||||
const getTrialVariant = (daysRemaining: number): TrialAlertVariant => {
|
||||
if (daysRemaining <= 3) return "error";
|
||||
if (daysRemaining <= 7) return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
interface TrialAlertProps {
|
||||
trialDaysRemaining: number;
|
||||
size?: "small";
|
||||
hasPaymentMethod?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TrialAlert = ({
|
||||
trialDaysRemaining,
|
||||
size,
|
||||
hasPaymentMethod = false,
|
||||
children,
|
||||
}: TrialAlertProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (trialDaysRemaining <= 0) return t("common.trial_expired");
|
||||
if (trialDaysRemaining === 1) return t("common.trial_one_day_remaining");
|
||||
return t("common.trial_days_remaining", { count: trialDaysRemaining });
|
||||
}, [trialDaysRemaining, t]);
|
||||
|
||||
const variant = hasPaymentMethod ? "success" : getTrialVariant(trialDaysRemaining);
|
||||
|
||||
return (
|
||||
<Alert variant={variant} size={size} className="max-w-4xl">
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -29,10 +29,7 @@ describe("cloud-billing-display", () => {
|
||||
expect(result).toEqual({
|
||||
organizationId: "org_1",
|
||||
currentCloudPlan: "pro",
|
||||
currentBillingInterval: null,
|
||||
currentSubscriptionStatus: null,
|
||||
pendingChange: null,
|
||||
trialDaysRemaining: null,
|
||||
usageCycleStart: new Date("2026-01-15T00:00:00.000Z"),
|
||||
usageCycleEnd: new Date("2026-02-15T00:00:00.000Z"),
|
||||
billing,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import "server-only";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
type TCloudBillingInterval,
|
||||
type TOrganizationStripePendingChange,
|
||||
type TOrganizationStripeSubscriptionStatus,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { type TOrganizationStripeSubscriptionStatus } from "@formbricks/types/organizations";
|
||||
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "./organization-billing";
|
||||
|
||||
@@ -13,10 +9,7 @@ export type TCloudBillingDisplayPlan = "hobby" | "pro" | "scale" | "custom" | "u
|
||||
export type TCloudBillingDisplayContext = {
|
||||
organizationId: string;
|
||||
currentCloudPlan: TCloudBillingDisplayPlan;
|
||||
currentBillingInterval: TCloudBillingInterval | null;
|
||||
currentSubscriptionStatus: TOrganizationStripeSubscriptionStatus | null;
|
||||
pendingChange: TOrganizationStripePendingChange | null;
|
||||
trialDaysRemaining: number | null;
|
||||
usageCycleStart: Date;
|
||||
usageCycleEnd: Date;
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>;
|
||||
@@ -34,34 +27,6 @@ const resolveCurrentSubscriptionStatus = (
|
||||
return billing.stripe?.subscriptionStatus ?? null;
|
||||
};
|
||||
|
||||
const resolveCurrentBillingInterval = (
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
|
||||
): TCloudBillingInterval | null => {
|
||||
return billing.stripe?.interval ?? null;
|
||||
};
|
||||
|
||||
const resolvePendingChange = (
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
|
||||
): TOrganizationStripePendingChange | null => {
|
||||
return billing.stripe?.pendingChange ?? null;
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
const resolveTrialDaysRemaining = (
|
||||
billing: NonNullable<Awaited<ReturnType<typeof getOrganizationBillingWithReadThroughSync>>>
|
||||
): number | null => {
|
||||
if (billing.stripe?.subscriptionStatus !== "trialing" || !billing.stripe.trialEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trialEndDate = new Date(billing.stripe.trialEnd);
|
||||
if (!Number.isFinite(trialEndDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return Math.ceil((trialEndDate.getTime() - Date.now()) / MS_PER_DAY);
|
||||
};
|
||||
|
||||
export const getCloudBillingDisplayContext = async (
|
||||
organizationId: string
|
||||
): Promise<TCloudBillingDisplayContext> => {
|
||||
@@ -76,10 +41,7 @@ export const getCloudBillingDisplayContext = async (
|
||||
return {
|
||||
organizationId,
|
||||
currentCloudPlan: resolveCurrentCloudPlan(billing),
|
||||
currentBillingInterval: resolveCurrentBillingInterval(billing),
|
||||
currentSubscriptionStatus: resolveCurrentSubscriptionStatus(billing),
|
||||
pendingChange: resolvePendingChange(billing),
|
||||
trialDaysRemaining: resolveTrialDaysRemaining(billing),
|
||||
usageCycleStart: usageCycleWindow.start,
|
||||
usageCycleEnd: usageCycleWindow.end,
|
||||
billing,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pricesList: vi.fn(),
|
||||
cacheWithCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./stripe-client", () => ({
|
||||
stripeClient: {
|
||||
prices: {
|
||||
list: mocks.pricesList,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const cacheStore = vi.hoisted(() => new Map<string, unknown>());
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
withCache: mocks.cacheWithCache,
|
||||
},
|
||||
}));
|
||||
|
||||
const createPrice = ({
|
||||
id,
|
||||
plan,
|
||||
kind,
|
||||
interval,
|
||||
}: {
|
||||
id: string;
|
||||
plan: "hobby" | "pro" | "scale";
|
||||
kind: "base" | "responses";
|
||||
interval: "monthly" | "yearly";
|
||||
}) => ({
|
||||
id,
|
||||
active: true,
|
||||
currency: "usd",
|
||||
unit_amount: kind === "responses" ? 0 : interval === "monthly" ? 1000 : 10000,
|
||||
metadata: {
|
||||
formbricks_plan: plan,
|
||||
formbricks_price_kind: kind,
|
||||
formbricks_interval: interval,
|
||||
},
|
||||
recurring: {
|
||||
usage_type: kind === "base" ? "licensed" : "metered",
|
||||
interval: interval === "monthly" ? "month" : "year",
|
||||
},
|
||||
product: {
|
||||
id: `prod_${plan}`,
|
||||
active: true,
|
||||
metadata: {
|
||||
formbricks_plan: plan,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("stripe-billing-catalog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
cacheStore.clear();
|
||||
|
||||
mocks.cacheWithCache.mockImplementation(async (fn: () => Promise<unknown>, key: string) => {
|
||||
if (cacheStore.has(key)) {
|
||||
return cacheStore.get(key);
|
||||
}
|
||||
|
||||
const value = await fn();
|
||||
cacheStore.set(key, value);
|
||||
return value;
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves the metadata-backed billing catalog", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { getCatalogItemsForPlan, getStripeBillingCatalogDisplay } =
|
||||
await import("./stripe-billing-catalog");
|
||||
|
||||
await expect(getCatalogItemsForPlan("hobby", "monthly")).resolves.toEqual([
|
||||
{ price: "price_hobby_monthly", quantity: 1 },
|
||||
]);
|
||||
await expect(getCatalogItemsForPlan("pro", "yearly")).resolves.toEqual([
|
||||
{ price: "price_pro_yearly", quantity: 1 },
|
||||
{ price: "price_pro_responses" },
|
||||
]);
|
||||
await expect(getStripeBillingCatalogDisplay()).resolves.toEqual({
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
currency: "usd",
|
||||
unitAmount: 1000,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
monthly: {
|
||||
plan: "pro",
|
||||
interval: "monthly",
|
||||
currency: "usd",
|
||||
unitAmount: 1000,
|
||||
},
|
||||
yearly: {
|
||||
plan: "pro",
|
||||
interval: "yearly",
|
||||
currency: "usd",
|
||||
unitAmount: 10000,
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
monthly: {
|
||||
plan: "scale",
|
||||
interval: "monthly",
|
||||
currency: "usd",
|
||||
unitAmount: 1000,
|
||||
},
|
||||
yearly: {
|
||||
plan: "scale",
|
||||
interval: "yearly",
|
||||
currency: "usd",
|
||||
unitAmount: 10000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("fails fast when the catalog is incomplete", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" })],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { getCatalogItemsForPlan } = await import("./stripe-billing-catalog");
|
||||
|
||||
await expect(getCatalogItemsForPlan("pro", "monthly")).rejects.toThrow(
|
||||
"Expected exactly one Stripe price for pro/base/monthly, but found 0"
|
||||
);
|
||||
});
|
||||
|
||||
test("reuses the shared cached catalog across module reloads", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const firstModule = await import("./stripe-billing-catalog");
|
||||
await firstModule.getStripeBillingCatalogDisplay();
|
||||
|
||||
vi.resetModules();
|
||||
|
||||
const secondModule = await import("./stripe-billing-catalog");
|
||||
await secondModule.getStripeBillingCatalogDisplay();
|
||||
|
||||
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cacheWithCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("falls back to direct Stripe fetch when shared cache is unavailable", async () => {
|
||||
mocks.pricesList.mockResolvedValue({
|
||||
data: [
|
||||
createPrice({ id: "price_hobby_monthly", plan: "hobby", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_monthly", plan: "pro", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_pro_yearly", plan: "pro", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_pro_responses", plan: "pro", kind: "responses", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_monthly", plan: "scale", kind: "base", interval: "monthly" }),
|
||||
createPrice({ id: "price_scale_yearly", plan: "scale", kind: "base", interval: "yearly" }),
|
||||
createPrice({ id: "price_scale_responses", plan: "scale", kind: "responses", interval: "monthly" }),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
mocks.cacheWithCache.mockImplementationOnce(async (fn: () => Promise<unknown>) => await fn());
|
||||
|
||||
const { getStripeBillingCatalogDisplay } = await import("./stripe-billing-catalog");
|
||||
|
||||
await expect(getStripeBillingCatalogDisplay()).resolves.toMatchObject({
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.pricesList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,337 +0,0 @@
|
||||
import "server-only";
|
||||
import { cache as reactCache } from "react";
|
||||
import Stripe from "stripe";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import type { TCloudBillingInterval } from "@formbricks/types/organizations";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { stripeClient } from "./stripe-client";
|
||||
|
||||
export type TStandardCloudPlan = "hobby" | "pro" | "scale";
|
||||
type TStripePriceKind = "base" | "responses";
|
||||
|
||||
type TStripeCatalogPrice = Stripe.Price & {
|
||||
product: Stripe.Product | Stripe.DeletedProduct;
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalogItem = {
|
||||
plan: TStandardCloudPlan;
|
||||
interval: TCloudBillingInterval;
|
||||
basePrice: TStripeCatalogPrice;
|
||||
responsePrice: TStripeCatalogPrice | null;
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalog = {
|
||||
hobby: {
|
||||
monthly: TStripeBillingCatalogItem;
|
||||
};
|
||||
pro: {
|
||||
monthly: TStripeBillingCatalogItem;
|
||||
yearly: TStripeBillingCatalogItem;
|
||||
};
|
||||
scale: {
|
||||
monthly: TStripeBillingCatalogItem;
|
||||
yearly: TStripeBillingCatalogItem;
|
||||
};
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalogDisplayItem = {
|
||||
plan: TStandardCloudPlan;
|
||||
interval: TCloudBillingInterval;
|
||||
currency: string;
|
||||
unitAmount: number | null;
|
||||
};
|
||||
|
||||
export type TStripeBillingCatalogDisplay = {
|
||||
hobby: {
|
||||
monthly: TStripeBillingCatalogDisplayItem;
|
||||
};
|
||||
pro: {
|
||||
monthly: TStripeBillingCatalogDisplayItem;
|
||||
yearly: TStripeBillingCatalogDisplayItem;
|
||||
};
|
||||
scale: {
|
||||
monthly: TStripeBillingCatalogDisplayItem;
|
||||
yearly: TStripeBillingCatalogDisplayItem;
|
||||
};
|
||||
};
|
||||
|
||||
const STANDARD_CLOUD_PLANS = new Set<TStandardCloudPlan>(["hobby", "pro", "scale"]);
|
||||
const STRIPE_BILLING_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const STRIPE_BILLING_CATALOG_CACHE_VERSION = "v1";
|
||||
|
||||
const getStripeBillingCatalogCacheKey = () =>
|
||||
createCacheKey.custom(
|
||||
"billing",
|
||||
"stripe_catalog",
|
||||
`${hashString(env.STRIPE_SECRET_KEY ?? "stripe-unconfigured")}-${STRIPE_BILLING_CATALOG_CACHE_VERSION}`
|
||||
);
|
||||
|
||||
const getPriceProduct = (price: Stripe.Price): Stripe.Product | Stripe.DeletedProduct | null => {
|
||||
if (typeof price.product === "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return price.product;
|
||||
};
|
||||
|
||||
const getPricePlan = (price: Stripe.Price): TStandardCloudPlan | null => {
|
||||
const product = getPriceProduct(price);
|
||||
const plan =
|
||||
price.metadata?.formbricks_plan ??
|
||||
(!product || product.deleted ? undefined : product.metadata?.formbricks_plan);
|
||||
|
||||
if (!plan || !STANDARD_CLOUD_PLANS.has(plan as TStandardCloudPlan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return plan as TStandardCloudPlan;
|
||||
};
|
||||
|
||||
const normalizeInterval = (interval: string | null | undefined): TCloudBillingInterval | null => {
|
||||
if (interval === "month" || interval === "monthly") return "monthly";
|
||||
if (interval === "year" || interval === "yearly") return "yearly";
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPriceInterval = (price: Stripe.Price): TCloudBillingInterval | null => {
|
||||
const metadataInterval = normalizeInterval(price.metadata?.formbricks_interval);
|
||||
if (metadataInterval) {
|
||||
return metadataInterval;
|
||||
}
|
||||
|
||||
return normalizeInterval(price.recurring?.interval);
|
||||
};
|
||||
|
||||
const getPriceKind = (price: Stripe.Price): TStripePriceKind | null => {
|
||||
const metadataKind = price.metadata?.formbricks_price_kind;
|
||||
if (metadataKind === "base" || metadataKind === "responses") {
|
||||
return metadataKind;
|
||||
}
|
||||
|
||||
if (price.recurring?.usage_type === "licensed") {
|
||||
return "base";
|
||||
}
|
||||
|
||||
if (price.recurring?.usage_type === "metered") {
|
||||
return "responses";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isCatalogCandidate = (price: Stripe.Price): price is TStripeCatalogPrice => {
|
||||
if (!price.active || !price.recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const product = getPriceProduct(price);
|
||||
if (!product || product.deleted || !product.active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getPricePlan(price) !== null && getPriceKind(price) !== null && getPriceInterval(price) !== null;
|
||||
};
|
||||
|
||||
const listAllActivePrices = async (): Promise<TStripeCatalogPrice[]> => {
|
||||
if (!stripeClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prices: TStripeCatalogPrice[] = [];
|
||||
let startingAfter: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await stripeClient.prices.list({
|
||||
active: true,
|
||||
limit: 100,
|
||||
expand: ["data.product"],
|
||||
...(startingAfter ? { starting_after: startingAfter } : {}),
|
||||
});
|
||||
|
||||
for (const price of result.data) {
|
||||
if (isCatalogCandidate(price)) {
|
||||
prices.push(price);
|
||||
}
|
||||
}
|
||||
|
||||
const lastItem = result.data.at(-1);
|
||||
startingAfter = result.has_more && lastItem ? lastItem.id : undefined;
|
||||
} while (startingAfter);
|
||||
|
||||
return prices;
|
||||
};
|
||||
|
||||
const getSinglePrice = (
|
||||
prices: TStripeCatalogPrice[],
|
||||
plan: TStandardCloudPlan,
|
||||
kind: TStripePriceKind,
|
||||
interval: TCloudBillingInterval
|
||||
): TStripeCatalogPrice => {
|
||||
const matches = prices.filter(
|
||||
(price) =>
|
||||
getPricePlan(price) === plan && getPriceKind(price) === kind && getPriceInterval(price) === interval
|
||||
);
|
||||
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one Stripe price for ${plan}/${kind}/${interval}, but found ${matches.length}`
|
||||
);
|
||||
}
|
||||
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
const fetchStripeBillingCatalog = async (): Promise<TStripeBillingCatalog> => {
|
||||
if (!stripeClient) {
|
||||
throw new Error("Stripe is not configured");
|
||||
}
|
||||
|
||||
const prices = await listAllActivePrices();
|
||||
|
||||
if (prices.length === 0) {
|
||||
throw new Error("No active Stripe billing catalog prices found");
|
||||
}
|
||||
|
||||
return {
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
basePrice: getSinglePrice(prices, "hobby", "base", "monthly"),
|
||||
responsePrice: null,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
monthly: {
|
||||
plan: "pro",
|
||||
interval: "monthly",
|
||||
basePrice: getSinglePrice(prices, "pro", "base", "monthly"),
|
||||
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
|
||||
},
|
||||
yearly: {
|
||||
plan: "pro",
|
||||
interval: "yearly",
|
||||
basePrice: getSinglePrice(prices, "pro", "base", "yearly"),
|
||||
responsePrice: getSinglePrice(prices, "pro", "responses", "monthly"),
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
monthly: {
|
||||
plan: "scale",
|
||||
interval: "monthly",
|
||||
basePrice: getSinglePrice(prices, "scale", "base", "monthly"),
|
||||
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
|
||||
},
|
||||
yearly: {
|
||||
plan: "scale",
|
||||
interval: "yearly",
|
||||
basePrice: getSinglePrice(prices, "scale", "base", "yearly"),
|
||||
responsePrice: getSinglePrice(prices, "scale", "responses", "monthly"),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getStripeBillingCatalog = reactCache(async (): Promise<TStripeBillingCatalog> => {
|
||||
return await cache.withCache(
|
||||
fetchStripeBillingCatalog,
|
||||
getStripeBillingCatalogCacheKey(),
|
||||
STRIPE_BILLING_CATALOG_CACHE_TTL_MS
|
||||
);
|
||||
});
|
||||
|
||||
export const getStripeBillingCatalogDisplay = reactCache(async (): Promise<TStripeBillingCatalogDisplay> => {
|
||||
const catalog = await getStripeBillingCatalog();
|
||||
|
||||
return {
|
||||
hobby: {
|
||||
monthly: {
|
||||
plan: "hobby",
|
||||
interval: "monthly",
|
||||
currency: catalog.hobby.monthly.basePrice.currency,
|
||||
unitAmount: catalog.hobby.monthly.basePrice.unit_amount,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
monthly: {
|
||||
plan: "pro",
|
||||
interval: "monthly",
|
||||
currency: catalog.pro.monthly.basePrice.currency,
|
||||
unitAmount: catalog.pro.monthly.basePrice.unit_amount,
|
||||
},
|
||||
yearly: {
|
||||
plan: "pro",
|
||||
interval: "yearly",
|
||||
currency: catalog.pro.yearly.basePrice.currency,
|
||||
unitAmount: catalog.pro.yearly.basePrice.unit_amount,
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
monthly: {
|
||||
plan: "scale",
|
||||
interval: "monthly",
|
||||
currency: catalog.scale.monthly.basePrice.currency,
|
||||
unitAmount: catalog.scale.monthly.basePrice.unit_amount,
|
||||
},
|
||||
yearly: {
|
||||
plan: "scale",
|
||||
interval: "yearly",
|
||||
currency: catalog.scale.yearly.basePrice.currency,
|
||||
unitAmount: catalog.scale.yearly.basePrice.unit_amount,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const getCatalogItemForPlan = async (
|
||||
plan: TStandardCloudPlan,
|
||||
interval: TCloudBillingInterval
|
||||
): Promise<TStripeBillingCatalogItem> => {
|
||||
const catalog = await getStripeBillingCatalog();
|
||||
|
||||
if (plan === "hobby") {
|
||||
return catalog.hobby.monthly;
|
||||
}
|
||||
|
||||
return catalog[plan][interval];
|
||||
};
|
||||
|
||||
export const getCatalogItemsForPlan = async (
|
||||
plan: TStandardCloudPlan,
|
||||
interval: TCloudBillingInterval
|
||||
): Promise<Array<{ price: string; quantity?: number }>> => {
|
||||
const item = await getCatalogItemForPlan(plan, interval);
|
||||
|
||||
return [
|
||||
{ price: item.basePrice.id, quantity: 1 },
|
||||
...(item.responsePrice ? [{ price: item.responsePrice.id }] : []),
|
||||
];
|
||||
};
|
||||
|
||||
export const getIntervalFromPrice = (
|
||||
price: Stripe.Price | null | undefined
|
||||
): TCloudBillingInterval | null => {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPriceInterval(price);
|
||||
};
|
||||
|
||||
export const getPlanFromPrice = (price: Stripe.Price | null | undefined): TStandardCloudPlan | null => {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPricePlan(price);
|
||||
};
|
||||
|
||||
export const getPriceKindFromPrice = (price: Stripe.Price | null | undefined): TStripePriceKind | null => {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPriceKind(price);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
||||
import { getStripeBillingCatalogDisplay } from "@/modules/ee/billing/lib/stripe-billing-catalog";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -21,10 +21,7 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [cloudBillingDisplayContext, billingCatalog] = await Promise.all([
|
||||
getCloudBillingDisplayContext(organization.id),
|
||||
getStripeBillingCatalogDisplay(),
|
||||
]);
|
||||
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
||||
|
||||
const organizationWithSyncedBilling = {
|
||||
...organization,
|
||||
@@ -56,14 +53,12 @@ export const PricingPage = async (props: { params: Promise<{ environmentId: stri
|
||||
projectCount={projectCount}
|
||||
hasBillingRights={hasBillingRights}
|
||||
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
|
||||
currentBillingInterval={cloudBillingDisplayContext.currentBillingInterval}
|
||||
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
|
||||
pendingChange={cloudBillingDisplayContext.pendingChange}
|
||||
usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
|
||||
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
|
||||
stripePublishableKey={env.STRIPE_PUBLISHABLE_KEY ?? null}
|
||||
stripePricingTableId={env.STRIPE_PRICING_TABLE_ID ?? null}
|
||||
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
|
||||
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
|
||||
billingCatalog={billingCatalog}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,12 @@ import { getServerSession } from "next-auth";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
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 locale = user.locale ?? DEFAULT_LOCALE;
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
return (
|
||||
<ActivityTimeline
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getLocale } from "@/lingodotdev/language";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
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 }) => {
|
||||
const t = await getTranslate();
|
||||
const locale = await getLocale();
|
||||
const [contact, attributesWithKeyInfo] = await Promise.all([
|
||||
getContact(contactId),
|
||||
getContactAttributesWithKeyInfo(contactId),
|
||||
@@ -45,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
return <IdBadge id={attr.value} />;
|
||||
}
|
||||
|
||||
return formatAttributeValue(attr.value, attr.dataType, locale);
|
||||
return formatAttributeValue(attr.value, attr.dataType);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
@@ -61,15 +61,7 @@ export const generateAttributeTableColumns = (
|
||||
header: t("common.created_at"),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return (
|
||||
<span>
|
||||
{formatDateForDisplay(createdAt, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const AttributesTable = ({
|
||||
// Generate columns
|
||||
const columns = useMemo(() => {
|
||||
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale);
|
||||
}, [searchValue, isReadOnly, isExpanded, locale, t]);
|
||||
}, [searchValue, isReadOnly, isExpanded]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { TFunction } from "i18next";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
@@ -13,7 +12,6 @@ export const generateContactTableColumns = (
|
||||
searchValue: string,
|
||||
data: TContactTableData[],
|
||||
isReadOnly: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TContactTableData>[] => {
|
||||
const userColumn: ColumnDef<TContactTableData> = {
|
||||
@@ -77,7 +75,7 @@ export const generateContactTableColumns = (
|
||||
cell: ({ row }: { row: { original: TContactTableData } }) => {
|
||||
const attribute = row.original.attributes.find((a) => a.key === attr.key);
|
||||
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} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export const ContactsPageLayout = async ({
|
||||
description={upgradePromptDescription ?? t("environments.contacts.unlock_contacts_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({
|
||||
label: t("common.contacts"),
|
||||
href: `/environments/${environmentId}/contacts`,
|
||||
},
|
||||
{
|
||||
id: "attributes",
|
||||
label: t("common.attributes"),
|
||||
href: `/environments/${environmentId}/attributes`,
|
||||
},
|
||||
{
|
||||
id: "segments",
|
||||
label: t("common.segments"),
|
||||
href: `/environments/${environmentId}/segments`,
|
||||
},
|
||||
{
|
||||
id: "attributes",
|
||||
label: t("common.attributes"),
|
||||
href: `/environments/${environmentId}/attributes`,
|
||||
},
|
||||
];
|
||||
|
||||
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 { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -66,15 +65,14 @@ export const ContactsTable = ({
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Generate columns
|
||||
const columns = useMemo(() => {
|
||||
return generateContactTableColumns(searchValue, data, isReadOnly, locale, t);
|
||||
}, [searchValue, data, isReadOnly, locale, t]);
|
||||
return generateContactTableColumns(searchValue, data, isReadOnly, t);
|
||||
}, [searchValue, data, isReadOnly]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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.
|
||||
@@ -28,11 +27,12 @@ export const formatAttributeValue = (
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return formatDateForDisplay(date, locale, {
|
||||
// Use Intl.DateTimeFormat for locale-aware date formatting
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}).format(date);
|
||||
} catch {
|
||||
// If date parsing fails, return the raw value
|
||||
return String(value);
|
||||
|
||||
@@ -97,13 +97,14 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
);
|
||||
|
||||
const ZUpdateSegmentAction = z.object({
|
||||
environmentId: ZId,
|
||||
segmentId: ZId,
|
||||
data: ZSegmentUpdateInput,
|
||||
});
|
||||
|
||||
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
|
||||
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { Label } from "@/modules/ui/components/label";
|
||||
|
||||
@@ -11,8 +11,7 @@ interface SegmentActivityTabProps {
|
||||
}
|
||||
|
||||
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { activeSurveys, inactiveSurveys } = currentSegment;
|
||||
|
||||
@@ -44,13 +43,13 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{formatDateTimeForDisplay(currentSegment.createdAt, locale)}
|
||||
{convertDateTimeStringShort(currentSegment.createdAt?.toString())}
|
||||
</p>
|
||||
</div>{" "}
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{formatDateTimeForDisplay(currentSegment.updatedAt, locale)}
|
||||
{convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -75,6 +75,7 @@ export function SegmentSettings({
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
const data = await updateSegmentAction({
|
||||
environmentId,
|
||||
segmentId: segment.id,
|
||||
data: {
|
||||
title: segment.title,
|
||||
@@ -133,10 +134,6 @@ export function SegmentSettings({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segment.filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// parse the filters to check if they are valid
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { timeSinceDate } from "@/lib/time";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
|
||||
export const generateSegmentTableColumns = (
|
||||
t: TFunction,
|
||||
locale: string
|
||||
): ColumnDef<TSegmentWithSurveyNames>[] => {
|
||||
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
|
||||
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
@@ -37,7 +33,11 @@ export const generateSegmentTableColumns = (
|
||||
accessorKey: "updatedAt",
|
||||
header: t("common.updated_at"),
|
||||
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"),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDateForDisplay(row.original.createdAt, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { timeSinceDate } from "@/lib/time";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
|
||||
type TSegmentTableDataRowProps = {
|
||||
@@ -26,8 +24,6 @@ export const SegmentTableDataRow = ({
|
||||
}: TSegmentTableDataRowProps) => {
|
||||
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
|
||||
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
|
||||
const { i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,16 +46,14 @@ export const SegmentTableDataRow = ({
|
||||
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
|
||||
</div>
|
||||
<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 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">
|
||||
{formatDateForDisplay(createdAt, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -22,13 +22,12 @@ export function SegmentTable({
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: SegmentTableUpdatedProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const { t } = useTranslation();
|
||||
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateSegmentTableColumns(t, locale);
|
||||
}, [locale, t]);
|
||||
return generateSegmentTableColumns(t);
|
||||
}, []);
|
||||
|
||||
const table = useReactTable({
|
||||
data: segments,
|
||||
|
||||
@@ -124,7 +124,7 @@ export function TargetingCard({
|
||||
};
|
||||
|
||||
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, data });
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
|
||||
return updatedSegment?.data as TSegment;
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ export function TargetingCard({
|
||||
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
|
||||
try {
|
||||
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, data });
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -75,13 +75,9 @@ export const recheckLicenseAction = authenticatedActionClient
|
||||
try {
|
||||
freshLicense = await fetchLicenseFresh();
|
||||
} catch (error) {
|
||||
// 400 = invalid license key, 403 = license bound to another instance.
|
||||
// Return directly so the UI shows the correct message.
|
||||
if (error instanceof LicenseApiError && (error.status === 400 || error.status === 403)) {
|
||||
return {
|
||||
active: false,
|
||||
status: error.status === 400 ? ("invalid_license" as const) : ("instance_mismatch" as const),
|
||||
};
|
||||
// 400 = invalid license key — return directly so the UI shows the correct message
|
||||
if (error instanceof LicenseApiError && error.status === 400) {
|
||||
return { active: false, status: "invalid_license" as const };
|
||||
}
|
||||
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 () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getInstanceId } from "@/lib/instance";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
TLicenseStatus,
|
||||
TEnterpriseLicenseStatusReturn,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
|
||||
// Configuration
|
||||
@@ -52,7 +52,7 @@ type TEnterpriseLicenseResult = {
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
status: TLicenseStatus;
|
||||
status: TEnterpriseLicenseStatusReturn;
|
||||
};
|
||||
|
||||
type TPreviousResult = {
|
||||
@@ -407,9 +407,8 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
return fetchLicenseFromServerInternal(retryCount + 1);
|
||||
}
|
||||
|
||||
// 400 = invalid license key, 403 = license bound to another instance.
|
||||
// Propagate both so callers can distinguish them from unreachable.
|
||||
if (res.status === 400 || res.status === 403) {
|
||||
// 400 = invalid license key — propagate so callers can distinguish from unreachable
|
||||
if (res.status === 400) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -586,7 +585,7 @@ const computeLicenseState = async (
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: liveLicenseDetails?.status ?? "unreachable",
|
||||
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
|
||||
};
|
||||
memoryCache = { data: graceResult, timestamp: Date.now() };
|
||||
return graceResult;
|
||||
@@ -633,15 +632,14 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
|
||||
try {
|
||||
liveLicenseDetails = await fetchLicense();
|
||||
} catch (error) {
|
||||
if (error instanceof LicenseApiError && (error.status === 400 || error.status === 403)) {
|
||||
const status = error.status === 400 ? "invalid_license" : "instance_mismatch";
|
||||
if (error instanceof LicenseApiError && error.status === 400) {
|
||||
const invalidResult: TEnterpriseLicenseResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status,
|
||||
status: "invalid_license" as const,
|
||||
};
|
||||
memoryCache = { data: invalidResult, timestamp: Date.now() };
|
||||
return invalidResult;
|
||||
|
||||
@@ -29,10 +29,9 @@ export const ZEnterpriseLicenseDetails = z.object({
|
||||
|
||||
export type TEnterpriseLicenseDetails = z.infer<typeof ZEnterpriseLicenseDetails>;
|
||||
|
||||
export type TLicenseStatus =
|
||||
export type TEnterpriseLicenseStatusReturn =
|
||||
| "active"
|
||||
| "expired"
|
||||
| "instance_mismatch"
|
||||
| "unreachable"
|
||||
| "invalid_license"
|
||||
| "no-license";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user