mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 06:10:56 -06:00
Compare commits
1 Commits
4.1.2
...
feat/singl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39c3a877a |
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
- check-latest-release
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
|
||||
docker-build-cloud:
|
||||
name: Build & push Formbricks Cloud to ECR
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
with:
|
||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
needs:
|
||||
- check-latest-release
|
||||
- docker-build-community
|
||||
@@ -154,4 +154,4 @@ jobs:
|
||||
release_tag: ${{ github.event.release.tag_name }}
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -23,6 +24,8 @@ const Page = async (props) => {
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) return notFound();
|
||||
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
@@ -34,10 +37,11 @@ const Page = async (props) => {
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||
<ProjectAndOrgSwitch
|
||||
currentOrganizationId={organization.id}
|
||||
currentOrganizationName={organization.name}
|
||||
organizations={organizations}
|
||||
projects={[]}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={0}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getProjectsByUserId } from "./lib/project";
|
||||
|
||||
const ZCreateProjectAction = z.object({
|
||||
organizationId: ZId,
|
||||
@@ -87,59 +84,3 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches organizations list for switcher dropdown.
|
||||
* Called on-demand when user opens the organization switcher.
|
||||
*/
|
||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetOrganizationsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getOrganizationsByUserId(ctx.user.id);
|
||||
});
|
||||
|
||||
const ZGetProjectsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches projects list for switcher dropdown.
|
||||
* Called on-demand when user opens the project switcher.
|
||||
*/
|
||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetProjectsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Need membership for getProjectsByUserId (1 DB query)
|
||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||
if (!membership) {
|
||||
throw new Error("Membership not found");
|
||||
}
|
||||
|
||||
return await getProjectsByUserId(ctx.user.id, membership);
|
||||
});
|
||||
|
||||
@@ -1,49 +1,104 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
|
||||
interface EnvironmentLayoutProps {
|
||||
layoutData: TEnvironmentLayoutData;
|
||||
environmentId: string;
|
||||
session: Session;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
|
||||
const t = await getTranslate();
|
||||
const [user, environment, organizations, organization] = await Promise.all([
|
||||
getUser(session.user.id),
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationsByUserId(session.user.id),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
// Destructure all data from props (NO database queries)
|
||||
const {
|
||||
user,
|
||||
environment,
|
||||
organization,
|
||||
membership,
|
||||
project, // Current project details
|
||||
environments, // All project environments (for environment switcher)
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
} = layoutData;
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
// Calculate derived values (no queries)
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
if (!currentUserMembership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
|
||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
||||
getProjectsByUserId(user.id, currentUserMembership),
|
||||
getEnvironments(environment.projectId),
|
||||
getAccessControlPermission(organization.billing.plan),
|
||||
]);
|
||||
|
||||
if (!projects || !environments || !organizations) {
|
||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||
}
|
||||
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.project_permission_not_found"));
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
|
||||
// Find the current project from the projects array
|
||||
const project = projects.find((p) => p.id === environment.projectId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
@@ -67,24 +122,26 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
<MainNavigation
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
projects={projects}
|
||||
user={user}
|
||||
project={{ id: project.id, name: project.name }}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
currentProjectId={project.id}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={active}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
membershipRole={membership.role}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
project: { id: string; name: string };
|
||||
projects: { id: string; name: string }[];
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
@@ -52,7 +52,7 @@ export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
user,
|
||||
project,
|
||||
projects,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
@@ -65,6 +65,7 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -9,7 +9,9 @@ import { getAccessFlags } from "@/lib/membership/utils";
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -22,7 +24,9 @@ interface TopControlBarProps {
|
||||
export const TopControlBar = ({
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -42,7 +46,9 @@ export const TopControlBar = ({
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
@@ -25,11 +23,10 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useOrganization } from "../context/environment-context";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||
organizations: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
currentEnvironmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -50,7 +47,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
|
||||
|
||||
export const OrganizationBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
currentOrganizationName,
|
||||
organizations,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
isFormbricksCloud,
|
||||
@@ -63,45 +60,7 @@ export const OrganizationBreadcrumb = ({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Get current organization name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { organization: currentOrganization } = useOrganization();
|
||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||
|
||||
// Lazy-load organizations when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
||||
setIsLoadingOrganizations(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort organizations by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load organizations");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
|
||||
}
|
||||
setIsLoadingOrganizations(false);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isOrganizationDropdownOpen,
|
||||
currentOrganizationId,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
loadError,
|
||||
t,
|
||||
]);
|
||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||
|
||||
if (!currentOrganization) {
|
||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||
@@ -167,7 +126,7 @@ export const OrganizationBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{organizationName}</span>
|
||||
<span>{currentOrganization.name}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -183,52 +142,30 @@ export const OrganizationBreadcrumb = ({
|
||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganizationId}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<div>
|
||||
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.organization_settings")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||
@@ -7,9 +8,9 @@ import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
currentOrganizationName?: string; // Optional: for pages without context
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId?: string;
|
||||
currentProjectName?: string; // Optional: for pages without context
|
||||
projects: { id: string; name: string }[];
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
@@ -17,15 +18,15 @@ interface ProjectAndOrgSwitchProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMember: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
currentOrganizationName,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
currentProjectName,
|
||||
projects,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
@@ -36,6 +37,11 @@ export const ProjectAndOrgSwitch = ({
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
||||
const sortedOrganizations = useMemo(
|
||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations]
|
||||
);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
@@ -44,9 +50,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentOrganizationName={currentOrganizationName}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
organizations={sortedOrganizations}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
@@ -54,9 +60,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentProjectName={currentProjectName}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
projects={sortedProjects}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
@@ -20,11 +18,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useProject } from "../context/environment-context";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
currentProjectName?: string; // Optional: pass directly if context not available
|
||||
projects: { id: string; name: string }[];
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -47,7 +44,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
currentProjectName,
|
||||
projects,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -62,41 +59,9 @@ export const ProjectBreadcrumb = ({
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get current project name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { project: currentProject } = useProject();
|
||||
const projectName = currentProject?.name || currentProjectName || "";
|
||||
|
||||
// Lazy-load projects when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||
setIsLoadingProjects(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load projects");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
}
|
||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
@@ -135,6 +100,8 @@ export const ProjectBreadcrumb = ({
|
||||
},
|
||||
];
|
||||
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||
logger.error(errorMessage);
|
||||
@@ -199,7 +166,7 @@ export const ProjectBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{projectName}</span>
|
||||
<span>{currentProject.name}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -214,48 +181,26 @@ export const ProjectBreadcrumb = ({
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setProjects([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProjectId}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProject.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
@@ -22,44 +20,25 @@ export const useEnvironment = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useProject = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { project: null };
|
||||
}
|
||||
return { project: context.project };
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { organization: null };
|
||||
}
|
||||
return { organization: context.organization };
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project, organization]
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
|
||||
@@ -14,27 +15,46 @@ const EnvLayout = async (props: {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
|
||||
// Check session first (required for userId)
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const [project, environment] = await Promise.all([
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
|
||||
if (!membership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={layoutData.session}
|
||||
user={layoutData.user}
|
||||
organization={layoutData.organization}>
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</EnvironmentIdBaseLayout>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import {
|
||||
ERRORS,
|
||||
@@ -123,7 +122,7 @@ export const AddIntegrationModal = ({
|
||||
const questions = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getTextContent(getLocalizedValue(q.headline, "default")),
|
||||
name: getLocalizedValue(q.headline, "default"),
|
||||
type: q.type,
|
||||
}))
|
||||
: [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -8,14 +8,7 @@ import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ResponseCardModalProps {
|
||||
responses: TResponse[];
|
||||
@@ -49,37 +42,25 @@ export const ResponseCardModal = ({
|
||||
locale,
|
||||
}: ResponseCardModalProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const idToIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < responses.length; i++) {
|
||||
map.set(responses[i].id, i);
|
||||
}
|
||||
return map;
|
||||
}, [responses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedResponseId) {
|
||||
setOpen(true);
|
||||
const index = idToIndexMap.get(selectedResponseId) ?? -1;
|
||||
const index = responses.findIndex((response) => response.id === selectedResponseId);
|
||||
setCurrentIndex(index);
|
||||
setIsNavigating(false);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [selectedResponseId, idToIndexMap, setOpen]);
|
||||
}, [selectedResponseId, responses, setOpen]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
||||
setIsNavigating(true);
|
||||
setSelectedResponseId(responses[currentIndex + 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIndex !== null && currentIndex > 0) {
|
||||
setIsNavigating(true);
|
||||
setSelectedResponseId(responses[currentIndex - 1].id);
|
||||
}
|
||||
};
|
||||
@@ -91,8 +72,8 @@ export const ResponseCardModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
// If no response is selected or currentIndex is null or invalid, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null;
|
||||
// If no response is selected or currentIndex is null, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
@@ -100,11 +81,6 @@ export const ResponseCardModal = ({
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>Survey Response Details</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogDescription>
|
||||
Response {currentIndex + 1} of {responses.length}
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
@@ -120,16 +96,12 @@ export const ResponseCardModal = ({
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={currentIndex === 0 || isNavigating}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === responses.length - 1 || isNavigating}
|
||||
disabled={currentIndex === responses.length - 1}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<ChevronRight />
|
||||
|
||||
@@ -28,63 +28,60 @@ interface ResponseDataViewProps {
|
||||
quotas: TSurveyQuota[];
|
||||
}
|
||||
|
||||
// Helper function to format array values to record with specified keys
|
||||
const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]): Record<string, string> => {
|
||||
if (!Array.isArray(responseValue)) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (let index = 0; index < responseValue.length; index++) {
|
||||
const curr = responseValue[index];
|
||||
result[keys[index]] = curr || "";
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
return formatArrayToRecord(responseValue, addressKeys);
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return formatArrayToRecord(responseValue, contactInfoKeys);
|
||||
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
const responseData: Record<string, any> = {};
|
||||
let responseData: Record<string, any> = {};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
survey.questions.forEach((question) => {
|
||||
const responseValue = response.data[question.id];
|
||||
switch (question.type) {
|
||||
case "matrix":
|
||||
if (typeof responseValue === "object") {
|
||||
Object.assign(responseData, responseValue);
|
||||
responseData = { ...responseData, ...responseValue };
|
||||
}
|
||||
break;
|
||||
case "address":
|
||||
Object.assign(responseData, formatAddressData(responseValue));
|
||||
responseData = { ...responseData, ...formatAddressData(responseValue) };
|
||||
break;
|
||||
case "contactInfo":
|
||||
Object.assign(responseData, formatContactInfoData(responseValue));
|
||||
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
|
||||
break;
|
||||
default:
|
||||
responseData[question.id] = responseValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (survey.hiddenFields.fieldIds) {
|
||||
for (const fieldId of survey.hiddenFields.fieldIds) {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
}
|
||||
}
|
||||
survey.hiddenFields.fieldIds?.forEach((fieldId) => {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
});
|
||||
|
||||
return responseData;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
const mapResponsesToTableData = (
|
||||
export const mapResponsesToTableData = (
|
||||
responses: TResponseWithQuotas[],
|
||||
survey: TSurvey,
|
||||
t: TFunction
|
||||
@@ -130,10 +127,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
quotas,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedResponseId, setSelectedResponseId] = React.useState<string | null>(null);
|
||||
const setSelectedResponseIdTransition = React.useCallback((id: string | null) => {
|
||||
React.startTransition(() => setSelectedResponseId(id));
|
||||
}, []);
|
||||
const data = mapResponsesToTableData(responses, survey, t);
|
||||
|
||||
return (
|
||||
@@ -154,8 +147,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
locale={locale}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
selectedResponseId={selectedResponseId}
|
||||
setSelectedResponseId={setSelectedResponseIdTransition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -122,11 +122,12 @@ export const ResponsePage = ({
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setResponses([]);
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-9 gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
</div>
|
||||
<ResponseDataView
|
||||
|
||||
@@ -39,12 +39,6 @@ import {
|
||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
const SkeletonCell = () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
);
|
||||
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
survey: TSurvey;
|
||||
@@ -61,8 +55,6 @@ interface ResponseTableProps {
|
||||
locale: TUserLocale;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
selectedResponseId: string | null;
|
||||
setSelectedResponseId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ResponseTable = ({
|
||||
@@ -81,13 +73,12 @@ export const ResponseTable = ({
|
||||
locale,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
selectedResponseId,
|
||||
setSelectedResponseId,
|
||||
}: ResponseTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
@@ -95,10 +86,7 @@ export const ResponseTable = ({
|
||||
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
);
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
@@ -122,13 +110,7 @@ export const ResponseTable = ({
|
||||
|
||||
// Memoize table data and columns
|
||||
const tableData: TResponseTableData[] = useMemo(
|
||||
() =>
|
||||
isFetchingFirstPage
|
||||
? Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
|
||||
)
|
||||
: data,
|
||||
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
|
||||
[data, isFetchingFirstPage]
|
||||
);
|
||||
|
||||
@@ -137,7 +119,11 @@ export const ResponseTable = ({
|
||||
isFetchingFirstPage
|
||||
? columns.map((column) => ({
|
||||
...column,
|
||||
cell: SkeletonCell,
|
||||
cell: () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
),
|
||||
}))
|
||||
: columns,
|
||||
[columns, isFetchingFirstPage]
|
||||
@@ -261,8 +247,8 @@ export const ResponseTable = ({
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
{/* disable auto animation if there are more than 200 responses for performance optimizations */}
|
||||
<TableBody ref={responses && responses.length > 200 ? undefined : parent}>
|
||||
|
||||
<TableBody ref={parent}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@@ -275,6 +261,7 @@ export const ResponseTable = ({
|
||||
row={row}
|
||||
isExpanded={isExpanded ?? false}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
|
||||
import { TableCell } from "@/modules/ui/components/table";
|
||||
@@ -11,18 +10,21 @@ interface ResponseTableCellProps {
|
||||
row: Row<TResponseTableData>;
|
||||
isExpanded: boolean;
|
||||
setSelectedResponseId: (responseId: string | null) => void;
|
||||
responses: TResponse[] | null;
|
||||
}
|
||||
|
||||
const ResponseTableCellComponent = ({
|
||||
export const ResponseTableCell = ({
|
||||
cell,
|
||||
row,
|
||||
isExpanded,
|
||||
setSelectedResponseId,
|
||||
responses,
|
||||
}: ResponseTableCellProps) => {
|
||||
// Function to handle cell click
|
||||
const handleCellClick = () => {
|
||||
if (cell.column.id !== "select") {
|
||||
setSelectedResponseId(row.id);
|
||||
const response = responses?.find((response) => response.id === row.id);
|
||||
if (response) setSelectedResponseId(response.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,5 +66,3 @@ const ResponseTableCellComponent = ({
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResponseTableCell = React.memo(ResponseTableCellComponent);
|
||||
|
||||
@@ -314,7 +314,7 @@ export const generateResponseTableColumns = (
|
||||
|
||||
const singleUseIdColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "singleUseId",
|
||||
header: () => <div className="gap-x-1.5">{t("environments.surveys.responses.single_use_id")}</div>,
|
||||
header: () => t("environments.surveys.responses.single_use_id"),
|
||||
cell: ({ row }) => {
|
||||
return <p className="truncate text-slate-900">{row.original.singleUseId}</p>;
|
||||
},
|
||||
|
||||
@@ -86,7 +86,7 @@ export const MultipleChoiceSummary = ({
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result) => {
|
||||
{results.map((result, resultsIdx) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||
return (
|
||||
<Fragment key={result.value}>
|
||||
@@ -107,7 +107,7 @@ export const MultipleChoiceSummary = ({
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{result.value}
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { ResponseFilter } from "./ResponseFilter";
|
||||
|
||||
enum DateSelected {
|
||||
FROM = "common.from",
|
||||
@@ -136,7 +137,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
|
||||
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
|
||||
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
|
||||
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
|
||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||
|
||||
@@ -270,179 +270,201 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
|
||||
useClickOutside(datePickerRef, () => handleDatePickerClose());
|
||||
return (
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsDownloadDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("common.download")}
|
||||
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
</span>
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
<>
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="flex min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span className="text-sm text-slate-700">
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
"focus:bg-muted cursor-pointer outline-none",
|
||||
isDownloading && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isDownloading}
|
||||
data-testid="fb__custom-filter-download-responses-button">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
{isDownloading ? (
|
||||
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange || dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange ? hoveredRange : dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/modules/ui/components/command";
|
||||
@@ -50,160 +48,117 @@ export const QuestionFilterComboBox = ({
|
||||
disabled = false,
|
||||
fieldId,
|
||||
}: QuestionFilterComboBoxProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const commandRef = useRef(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { t } = useTranslation();
|
||||
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||
const commandRef = React.useRef(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
const defaultLanguageCode = "default";
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
const { t } = useTranslation();
|
||||
// multiple when question type is multi selection
|
||||
const isMultiple =
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
|
||||
|
||||
// Check if multiple selection is allowed
|
||||
const isMultiple = useMemo(
|
||||
() =>
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either"),
|
||||
[type, filterValue]
|
||||
);
|
||||
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||
const options = isMultiple
|
||||
? filterComboBoxOptions?.filter(
|
||||
(o) =>
|
||||
!filterComboBoxValue?.includes(
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||
)
|
||||
)
|
||||
: filterComboBoxOptions;
|
||||
|
||||
// Filter out already selected options for multi-select
|
||||
const options = useMemo(() => {
|
||||
if (!isMultiple) return filterComboBoxOptions;
|
||||
|
||||
return filterComboBoxOptions?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
|
||||
// Disable combo box for NPS/Rating when Submitted/Skipped
|
||||
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
|
||||
const isDisabledComboBox =
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
// Check if this is a text input field (URL meta field)
|
||||
// Check if this is a URL field with string comparison operations that require text input
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
|
||||
// Filter options based on search query
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
const filteredOptions = options?.filter((o) =>
|
||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCommandItemSelect = (o: string) => {
|
||||
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
|
||||
if (isMultiple) {
|
||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||
onChangeFilterComboBoxValue(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeFilterComboBoxValue(value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
|
||||
|
||||
const handleOpenDropdown = () => {
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(true);
|
||||
};
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
// Helper to filter out a specific value from the array
|
||||
const getFilteredValues = (valueToRemove: string): string[] => {
|
||||
if (!Array.isArray(filterComboBoxValue)) return [];
|
||||
return filterComboBoxValue.filter((i) => i !== valueToRemove);
|
||||
};
|
||||
|
||||
// Handle removal of a multi-select tag
|
||||
const handleRemoveTag = (e: React.MouseEvent, valueToRemove: string) => {
|
||||
e.stopPropagation();
|
||||
const filteredValues = getFilteredValues(valueToRemove);
|
||||
handleRemoveMultiSelect(filteredValues);
|
||||
};
|
||||
|
||||
// Render a single multi-select tag
|
||||
const renderTag = (value: string, index: number) => (
|
||||
<button
|
||||
key={`${value}-${index}`}
|
||||
type="button"
|
||||
onClick={(e) => handleRemoveTag(e, value)}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
|
||||
{value}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render multi-select tags
|
||||
const renderMultiSelectTags = () => {
|
||||
if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex grow gap-2 overflow-auto">
|
||||
{filterComboBoxValue.map((value, index) => renderTag(value, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate content based on filterComboBoxValue state
|
||||
const renderComboBoxContent = () => {
|
||||
if (!filterComboBoxValue || filterComboBoxValue.length === 0) {
|
||||
return (
|
||||
<p className={clsx("text-sm", isComboBoxDisabled ? "text-slate-300" : "text-slate-400")}>
|
||||
{t("common.select")}...
|
||||
</p>
|
||||
const commandItemOnSelect = (o: string) => {
|
||||
if (!isMultiple) {
|
||||
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
|
||||
} else {
|
||||
onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(filterComboBoxValue)) {
|
||||
return renderMultiSelectTags();
|
||||
if (!isMultiple) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
||||
{filterOptions && filterOptions.length <= 1 ? (
|
||||
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
if (value) setOpen(false);
|
||||
value && setOpen(false);
|
||||
setOpenFilterValue(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
)}>
|
||||
{filterValue ? (
|
||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
{!filterValue ? (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
) : (
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<>
|
||||
{openFilterValue ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
<DropdownMenuContent className="bg-white p-2">
|
||||
{filterOptions?.map((o, index) => (
|
||||
<DropdownMenuItem
|
||||
key={`${o}-${index}`}
|
||||
className="cursor-pointer"
|
||||
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
|
||||
onClick={() => onChangeFilterValue(o)}>
|
||||
{o}
|
||||
</DropdownMenuItem>
|
||||
@@ -211,78 +166,78 @@ export const QuestionFilterComboBox = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isTextInputField ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
||||
disabled={isComboBoxDisabled}
|
||||
placeholder={t("common.enter_url")}
|
||||
disabled={disabled || !filterValue}
|
||||
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
||||
/>
|
||||
) : (
|
||||
<Command ref={commandRef} className="relative h-fit w-full min-w-0 overflow-visible bg-transparent">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isComboBoxDisabled ? -1 : 0}
|
||||
className={clsx(
|
||||
"flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2",
|
||||
isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
)}
|
||||
onClick={handleOpenDropdown}
|
||||
onKeyDown={(e) => {
|
||||
const isActivationKey = e.key === "Enter" || e.key === " ";
|
||||
if (isActivationKey && !isComboBoxDisabled) {
|
||||
e.preventDefault();
|
||||
handleOpenDropdown();
|
||||
}
|
||||
}}>
|
||||
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(!open);
|
||||
}}
|
||||
disabled={isComboBoxDisabled}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandList className="max-h-52">
|
||||
<CommandInput
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={`${t("common.search")}...`}
|
||||
className="border-none"
|
||||
/>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={optionValue}
|
||||
onSelect={() => handleCommandItemSelect(o)}
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{optionValue}
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -112,46 +111,51 @@ const questionIcons = {
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
const getIconBackground = (type: OptionsType | string): string => {
|
||||
const backgroundMap: Record<string, string> = {
|
||||
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
|
||||
[OptionsType.QUESTIONS]: "bg-brand-dark",
|
||||
[OptionsType.TAGS]: "bg-indigo-500",
|
||||
[OptionsType.QUOTAS]: "bg-slate-500",
|
||||
};
|
||||
return backgroundMap[type] ?? "bg-amber-500";
|
||||
};
|
||||
|
||||
const getLabelClassName = (type: OptionsType | string, label?: string): string => {
|
||||
if (type !== OptionsType.META) return "";
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getDisplayIcon = () => {
|
||||
if (!type) return null;
|
||||
if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType);
|
||||
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
|
||||
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
|
||||
if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS);
|
||||
if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS);
|
||||
return null;
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return getIcon(OptionsType.QUOTAS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = () => {
|
||||
if (type === OptionsType.ATTRIBUTES) {
|
||||
return "bg-indigo-500";
|
||||
} else if (type === OptionsType.QUESTIONS) {
|
||||
return "bg-brand-dark";
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return "bg-indigo-500";
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return "bg-slate-500";
|
||||
} else {
|
||||
return "bg-amber-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-white",
|
||||
getIconBackground(type ?? "")
|
||||
)}>
|
||||
{getDisplayIcon()}
|
||||
</span>
|
||||
<p className={clsx("truncate text-sm text-slate-600", getLabelClassName(type ?? "", label))}>
|
||||
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -165,74 +169,64 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
const hasSelection = selected.hasOwnProperty("label");
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<Command
|
||||
ref={commandRef}
|
||||
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center justify-between"
|
||||
onClick={() => !open && setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
!open && setOpen(true);
|
||||
}
|
||||
}}>
|
||||
{!open && hasSelection && <SelectedCommandItem {...selected} />}
|
||||
{(open || !hasSelection) && (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||
{!open && selected.hasOwnProperty("label") && (
|
||||
<SelectedCommandItem
|
||||
label={selected?.label}
|
||||
type={selected?.type}
|
||||
questionType={selected?.questionType}
|
||||
/>
|
||||
)}
|
||||
{(open || !selected.hasOwnProperty("label")) && (
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}>
|
||||
<SelectedCommandItem {...o} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
<div>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o, i) => (
|
||||
<CommandItem
|
||||
key={`${o.label}-${i}`}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,32 +31,6 @@ export type QuestionFilterOptions = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverTriggerButtonProps>(
|
||||
({ isOpen, children, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...props}
|
||||
className="flex min-w-[8rem] cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white p-2 hover:border-slate-400">
|
||||
<span className="text-sm text-slate-700">{children}</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
PopoverTriggerButton.displayName = "PopoverTriggerButton";
|
||||
|
||||
interface ResponseFilterProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
@@ -134,6 +108,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
clearItem();
|
||||
handleApplyFilters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
@@ -152,9 +127,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
const clearedFilters = { filter: [], responseStatus: "all" as const };
|
||||
setFilterValue(clearedFilters);
|
||||
setSelectedFilter(clearedFilters);
|
||||
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
|
||||
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -210,6 +184,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
handleApplyFilters();
|
||||
}
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
@@ -219,26 +196,36 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[300px] rounded-lg border-slate-200 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="font-semibold text-slate-800">
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-slate800 hidden text-lg font-semibold sm:block">
|
||||
{t("environments.surveys.summary.show_all_responses_that_match")}
|
||||
</p>
|
||||
<p className="block text-base text-slate-500 sm:hidden">
|
||||
{t("environments.surveys.summary.show_all_responses_where")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={filterValue.responseStatus ?? "all"}
|
||||
onValueChange={(val) => {
|
||||
handleResponseStatusChange(val as TResponseStatus);
|
||||
}}>
|
||||
<SelectTrigger className="w-full bg-white text-slate-700">
|
||||
}}
|
||||
defaultValue={filterValue.responseStatus}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -298,38 +285,35 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
<p className="block font-light text-slate-500 md:hidden">Delete</p>
|
||||
<TrashIcon
|
||||
className="w-4 cursor-pointer text-slate-500 md:text-black"
|
||||
onClick={() => handleDeleteFilter(i)}
|
||||
aria-label={t("common.delete")}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<div className="my-6 flex items-center">
|
||||
<p className="mr-6 text-base text-slate-600">And</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus width={18} height={18} className="ml-2" />
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyFilters}>
|
||||
{t("common.apply_filters")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
3
apps/web/app/s/[surveyId]/loading.tsx
Normal file
3
apps/web/app/s/[surveyId]/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
|
||||
|
||||
export default LinkSurveyLoading;
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"locale": {
|
||||
"source": "en-US",
|
||||
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW", "es-ES"]
|
||||
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW"]
|
||||
},
|
||||
"version": 1.8
|
||||
}
|
||||
|
||||
@@ -173,7 +173,6 @@ checksums:
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
|
||||
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
|
||||
@@ -183,8 +182,6 @@ checksums:
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
@@ -331,7 +328,6 @@ checksums:
|
||||
common/segments: 271db72d5b973fbc5fadab216177eaae
|
||||
common/select: 5ac04c47a98deb85906bc02e0de91ab0
|
||||
common/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
common/select_filter: c50082c3981f1161022f9787a19aed71
|
||||
common/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
||||
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
||||
@@ -398,7 +394,6 @@ checksums:
|
||||
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
||||
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
||||
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
||||
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||
@@ -746,7 +741,7 @@ checksums:
|
||||
environments/project/general/project_deleted_successfully: dbedf0f0739b822f3951de4aeb2fc26f
|
||||
environments/project/general/project_name_settings_description: 079c6380ad539543a9aa8772bc1b0fa2
|
||||
environments/project/general/project_name_updated_successfully: f95f70f4a49d451dc0441a51d05a3aa3
|
||||
environments/project/general/recontact_waiting_time: 0566dc710b4b9644e276e311b419c4c0
|
||||
environments/project/general/recontact_waiting_time: 9c5ebb18960dec73def053de89e63272
|
||||
environments/project/general/recontact_waiting_time_settings_description: 8922cde1f95777f9a2747fb4bed57ab5
|
||||
environments/project/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
|
||||
environments/project/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
|
||||
@@ -826,6 +821,7 @@ checksums:
|
||||
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
|
||||
environments/projects_environments_organizations_not_found: 9d450087c4035083f93bda9aa1889c43
|
||||
environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905
|
||||
environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a
|
||||
environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093
|
||||
@@ -916,12 +912,15 @@ checksums:
|
||||
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
|
||||
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
|
||||
environments/settings/billing/monthly_identified_users: 0795735f6b241d31edac576a77dd7e55
|
||||
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
|
||||
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
|
||||
environments/settings/billing/plan_upgraded_successfully: 52e2a258cc9ca8a512c288bf6f18cf37
|
||||
environments/settings/billing/premium_support_with_slas: 2e33d4442c16bfececa6cae7b2081e5d
|
||||
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
|
||||
environments/settings/billing/startup: 4c4ac5a0b9dc62100bca6c6465f31c4c
|
||||
environments/settings/billing/startup_description: 964fcb2c77f49b80266c94606e3f4506
|
||||
environments/settings/billing/switch_plan: fb3e1941051a4273ca29224803570f4b
|
||||
environments/settings/billing/switch_plan_confirmation_text: 910a6df56964619975c6ed5651a55db7
|
||||
environments/settings/billing/team_access_roles: 1cc4af14e589f6c09ab92a4f21958049
|
||||
environments/settings/billing/unable_to_upgrade_plan: 50fc725609411d139e534c85eeb2879e
|
||||
environments/settings/billing/unlimited_miu: 29c3f5bd01c2a09fdf1d3601665ce90f
|
||||
@@ -1142,6 +1141,7 @@ checksums:
|
||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||
environments/surveys/edit/always_show_survey: b0ae6a873ce2eeb0aea2e6d4cb04c540
|
||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||
@@ -1224,7 +1224,8 @@ checksums:
|
||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 8b4623eab862615fa60064400008eb23
|
||||
environments/surveys/edit/decide_how_often_people_can_answer_this_survey: 58427b0f0a7a258c24fa2acd9913e95e
|
||||
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
|
||||
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
|
||||
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
|
||||
@@ -1252,7 +1253,7 @@ checksums:
|
||||
environments/surveys/edit/equals_one_of: 369a451add4b79bc003f952f0e1bfcc9
|
||||
environments/surveys/edit/error_publishing_survey: bf9fab1d8ea7132a2e9b4b7b09f18b1f
|
||||
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: c6668f9cf127fd922bec695dc548fe12
|
||||
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
||||
environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
|
||||
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
|
||||
@@ -1323,9 +1324,8 @@ checksums:
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
||||
environments/surveys/edit/ignore_global_waiting_time: 1e7f1465aeb6d26c325ad7f135b207a8
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 33f0320ec85067a06198a841348e9fc6
|
||||
environments/surveys/edit/ignore_waiting_time_between_surveys: 8145b6aef535fde5ee54dea63e66f64a
|
||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||
environments/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031
|
||||
environments/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884
|
||||
@@ -1392,10 +1392,9 @@ checksums:
|
||||
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
|
||||
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
|
||||
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
|
||||
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
|
||||
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
|
||||
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
|
||||
environments/surveys/edit/overwrites_waiting_period_between_surveys_to_x_days: 8d5596b024cbe8c82b021dcf6c73ba05
|
||||
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
|
||||
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
||||
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
|
||||
@@ -1452,8 +1451,7 @@ checksums:
|
||||
environments/surveys/edit/range: 1fad969ecf3de1c21df046b93053c422
|
||||
environments/surveys/edit/recall_data: 39beabd626c0af15316885cff5d5d9b8
|
||||
environments/surveys/edit/recall_information_from: 884cfd143456fab1a91f0744cc92f0c8
|
||||
environments/surveys/edit/recontact_options_section: 57a23e1bcab6baa484b27b615e6c906a
|
||||
environments/surveys/edit/recontact_options_section_description: 1e04011440c339a3b5cfff12d55b7f12
|
||||
environments/surveys/edit/recontact_options: 0f570378a531da60448fde37abd50214
|
||||
environments/surveys/edit/redirect_thank_you_card: 09f721c4b62e2584e40a53507092ea83
|
||||
environments/surveys/edit/redirect_to_url: f17d726bbc3391561447b3f4010635cf
|
||||
environments/surveys/edit/remove_description: b52de820b4bbcb354eb62246c4112a9a
|
||||
@@ -1462,8 +1460,6 @@ checksums:
|
||||
environments/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
environments/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a
|
||||
environments/surveys/edit/reset_to_theme_styles_main_text: d86fb2213d3b2efbd0361526dc6cb27b
|
||||
environments/surveys/edit/respect_global_waiting_time: 850e7e64ec890c591b2d07741ef26e11
|
||||
environments/surveys/edit/respect_global_waiting_time_description: 5235fee102d619cb391c5aa2c75b61be
|
||||
environments/surveys/edit/response_limit_can_t_be_set_to_0: 278664873ee3b1046dbcb58848efc12a
|
||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
@@ -1488,7 +1484,7 @@ checksums:
|
||||
environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
|
||||
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
|
||||
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
|
||||
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
|
||||
environments/surveys/edit/show_multiple_times: 5e6e0244c20feca78723c79aa1ddcf62
|
||||
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||
@@ -1518,12 +1514,13 @@ checksums:
|
||||
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
|
||||
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
|
||||
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
|
||||
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654
|
||||
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
|
||||
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 219b15081cbafaa391e266bd2cc4c9d4
|
||||
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: c145b7be481ae1fe6f66298d9a5cf838
|
||||
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
||||
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
||||
environments/surveys/edit/this_extension_is_already_added: 201d636539836c95958e28cecd8f3240
|
||||
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
|
||||
environments/surveys/edit/this_setting_overwrites_your: 6f980149a5a4adc2cfe3dac4f367e7e5
|
||||
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||
@@ -1534,7 +1531,7 @@ checksums:
|
||||
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
|
||||
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
|
||||
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
|
||||
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
|
||||
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
|
||||
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
|
||||
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
|
||||
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
@@ -1542,6 +1539,7 @@ checksums:
|
||||
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
|
||||
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
|
||||
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
|
||||
environments/surveys/edit/use_with_caution: 7c35d3ad68dd001e53cbd9d57c96af91
|
||||
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
|
||||
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
|
||||
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
|
||||
@@ -1551,13 +1549,11 @@ checksums:
|
||||
environments/surveys/edit/variable_used_in_recall_welcome: 60321b2f40ae01cd10f99ed77bb986ba
|
||||
environments/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
|
||||
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
|
||||
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
|
||||
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
|
||||
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
||||
environments/surveys/edit/waiting_time_across_surveys: 5c5a7653d797c86c4008f13a40434ad8
|
||||
environments/surveys/edit/waiting_time_across_surveys_description: 1bbee2fee49f842056547c336f8fd788
|
||||
environments/surveys/edit/waiting_period: 21775d12b2cb831134b1f47450eaf1f3
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
environments/surveys/edit/when_conditions_match_waiting_time_will_be_ignored_and_survey_shown: e7fe9c56664da4670e52e38656d8705d
|
||||
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
||||
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
||||
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations: b12b28699e02ff9ba69bcbae838ba5da
|
||||
@@ -1577,8 +1573,6 @@ checksums:
|
||||
environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127
|
||||
environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5
|
||||
environments/surveys/responses/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
|
||||
environments/surveys/responses/an_error_occurred_adding_the_tag: f211ea1ceb8a93b415d88a8deed874ef
|
||||
environments/surveys/responses/an_error_occurred_creating_the_tag: 89689815f8aff6ff3ba821ab599c540c
|
||||
environments/surveys/responses/an_error_occurred_deleting_the_tag: c63f28ac2a4cda558423ea7f975d5b8b
|
||||
environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f
|
||||
environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70
|
||||
@@ -1775,6 +1769,7 @@ checksums:
|
||||
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||
environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515
|
||||
environments/surveys/summary/show_all_responses_that_match: c199f03983d7fcdd5972cc2759558c68
|
||||
environments/surveys/summary/show_all_responses_where: 370a56de4692a588f7ebdbf7f1e28f6f
|
||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd
|
||||
|
||||
@@ -19,7 +19,8 @@ export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||
// Other
|
||||
export const CRON_SECRET = env.CRON_SECRET;
|
||||
export const DEFAULT_BRAND_COLOR = "#64748b";
|
||||
export const FB_LOGO_URL = `${WEBAPP_URL}/logo-transparent.png`;
|
||||
export const FB_LOGO_URL =
|
||||
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
|
||||
|
||||
export const PRIVACY_URL = env.PRIVACY_URL;
|
||||
export const TERMS_URL = env.TERMS_URL;
|
||||
@@ -174,7 +175,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
@@ -182,17 +182,21 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
export enum PROJECT_FEATURE_KEYS {
|
||||
FREE = "free",
|
||||
STARTUP = "startup",
|
||||
CUSTOM = "custom",
|
||||
SCALE = "scale",
|
||||
ENTERPRISE = "enterprise",
|
||||
}
|
||||
|
||||
export enum STRIPE_PROJECT_NAMES {
|
||||
STARTUP = "Formbricks Startup",
|
||||
CUSTOM = "Formbricks Custom",
|
||||
SCALE = "Formbricks Scale",
|
||||
ENTERPRISE = "Formbricks Enterprise",
|
||||
}
|
||||
|
||||
export enum STRIPE_PRICE_LOOKUP_KEYS {
|
||||
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
|
||||
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
|
||||
SCALE_MONTHLY = "formbricks_scale_monthly",
|
||||
SCALE_YEARLY = "formbricks_scale_yearly",
|
||||
}
|
||||
|
||||
export const BILLING_LIMITS = {
|
||||
@@ -206,10 +210,10 @@ export const BILLING_LIMITS = {
|
||||
RESPONSES: 5000,
|
||||
MIU: 7500,
|
||||
},
|
||||
CUSTOM: {
|
||||
PROJECTS: null,
|
||||
RESPONSES: null,
|
||||
MIU: null,
|
||||
SCALE: {
|
||||
PROJECTS: 5,
|
||||
RESPONSES: 10000,
|
||||
MIU: 30000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -137,7 +137,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Engleză (SUA)",
|
||||
"ja-JP": "英語(米国)",
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -152,7 +151,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Germană",
|
||||
"ja-JP": "ドイツ語",
|
||||
"zh-Hans-CN": "德语",
|
||||
"es-ES": "Alemán",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -167,7 +165,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Portugheză (Brazilia)",
|
||||
"ja-JP": "ポルトガル語(ブラジル)",
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -182,7 +179,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Franceză",
|
||||
"ja-JP": "フランス語",
|
||||
"zh-Hans-CN": "法语",
|
||||
"es-ES": "Francés",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -197,7 +193,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Chineză (Tradicională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -212,7 +207,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Portugheză (Portugalia)",
|
||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -227,7 +221,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Română",
|
||||
"ja-JP": "ルーマニア語",
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"es-ES": "Rumano",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,7 +235,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Japoneză",
|
||||
"ja-JP": "日本語",
|
||||
"zh-Hans-CN": "日语",
|
||||
"es-ES": "Japonés",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -257,22 +249,6 @@ export const appLanguages = [
|
||||
"ro-RO": "Chineză (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"de-DE": "Spanisch",
|
||||
"pt-BR": "Espanhol",
|
||||
"fr-FR": "Espagnol",
|
||||
"zh-Hant-TW": "西班牙語",
|
||||
"pt-PT": "Espanhol",
|
||||
"ro-RO": "Spaniol",
|
||||
"ja-JP": "スペイン語",
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"es-ES": "Español",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, ja, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, fr, ja, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -101,8 +101,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return ja;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "es-ES":
|
||||
return es;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ export const I18nProvider = ({ children, language, defaultLanguage }: I18nProvid
|
||||
initializeI18n();
|
||||
}, [locale, defaultLanguage]);
|
||||
|
||||
// Don't render children until i18n is ready to prevent race conditions
|
||||
// Don't render children until i18n is ready to prevent hydration issues
|
||||
if (!isReady) {
|
||||
return null;
|
||||
return <div style={{ visibility: "hidden" }}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_projects": "Fehler beim Laden der Projekte",
|
||||
"finish": "Fertigstellen",
|
||||
"follow_these": "Folge diesen",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segmente",
|
||||
"select": "Auswählen",
|
||||
"select_all": "Alles auswählen",
|
||||
"select_filter": "Filter auswählen",
|
||||
"select_survey": "Umfrage auswählen",
|
||||
"select_teams": "Teams auswählen",
|
||||
"selected": "Ausgewählt",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "Benutzer-ID",
|
||||
"user_not_found": "Benutzer nicht gefunden",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variablen-IDs",
|
||||
"variables": "Variablen",
|
||||
"verified_email": "Verifizierte E-Mail",
|
||||
"video": "Video",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "Projekt erfolgreich gelöscht",
|
||||
"project_name_settings_description": "Ändere den Namen deines Projekts.",
|
||||
"project_name_updated_successfully": "Projektname erfolgreich aktualisiert",
|
||||
"recontact_waiting_time": "Projektweite Wartezeit zwischen Umfragen",
|
||||
"recontact_waiting_time": "Wartezeit für erneuten Kontakt",
|
||||
"recontact_waiting_time_settings_description": "Steuere, wie oft Nutzer in allen App-Umfragen eine Umfrage angezeigt bekommen können.",
|
||||
"this_action_cannot_be_undone": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"wait_x_days_before_showing_next_survey": "Warte X Tage, bevor die nächste Umfrage angezeigt wird:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projekte, Umgebungen oder Organisationen nicht gefunden",
|
||||
"segments": {
|
||||
"add_filter_below": "Filter unten hinzufügen",
|
||||
"add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"monthly_identified_users": "Monatlich identifizierte Nutzer",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
|
||||
"premium_support_with_slas": "Premium-Support mit SLAs",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"startup": "Start-up",
|
||||
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
|
||||
"switch_plan": "Plan wechseln",
|
||||
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
|
||||
"team_access_roles": "Rollen für Teammitglieder",
|
||||
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
|
||||
"unlimited_miu": "Unbegrenzte MIU",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "Mehrfachauswahl erlauben",
|
||||
"allow_multiple_files": "Mehrere Dateien zulassen",
|
||||
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
|
||||
"always_show_survey": "Umfrage immer anzeigen",
|
||||
"and_launch_surveys_in_your_website_or_app": "und Umfragen auf deiner Website oder App starten.",
|
||||
"animation": "Animation",
|
||||
"app_survey_description": "Bette eine Umfrage in deine Web-App oder Website ein, um Antworten zu sammeln.",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "Benutzerdefinierter Hostname",
|
||||
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
|
||||
"date_format": "Datumsformat",
|
||||
"days_before_showing_this_survey_again": "Tage nachdem eine beliebige Umfrage angezeigt wurde, bevor diese Umfrage erscheinen kann.",
|
||||
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
|
||||
"delete_choice": "Auswahl löschen",
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "Entspricht einem von",
|
||||
"error_publishing_survey": "Beim Veröffentlichen der Umfrage ist ein Fehler aufgetreten.",
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
|
||||
"everyone": "Jeder",
|
||||
"external_urls_paywall_tooltip": "Bitte aktualisieren, um die externe URL anzupassen. Phishing-Prävention.",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
||||
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
|
||||
"ignore_global_waiting_time": "Projektweite Wartezeit ignorieren",
|
||||
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.",
|
||||
"ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren",
|
||||
"image": "Bild",
|
||||
"includes_all_of": "Enthält alles von",
|
||||
"includes_one_of": "Enthält eines von",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "Optional",
|
||||
"options": "Optionen",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
|
||||
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
|
||||
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
|
||||
"overwrite_placement": "Platzierung überschreiben",
|
||||
"overwrite_the_global_placement_of_the_survey": "Platzierung für diese Umfrage überschreiben",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "Überschreibt die Wartezeit zwischen Umfragen auf {days} Tag(e).",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Wähle einen Hintergrund aus oder lade deinen eigenen hoch.",
|
||||
"picture_idx": "Bild {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "Reichweite",
|
||||
"recall_data": "Daten abrufen",
|
||||
"recall_information_from": "Information abrufen von ...",
|
||||
"recontact_options_section": "Optionen zur erneuten Kontaktaufnahme",
|
||||
"recontact_options_section_description": "Wenn die Wartezeit es erlaubt, wählen Sie aus, wie oft diese Umfrage einer Person angezeigt werden kann.",
|
||||
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
|
||||
"redirect_thank_you_card": "Weiterleitung anlegen",
|
||||
"redirect_to_url": "Zu URL weiterleiten",
|
||||
"remove_description": "Beschreibung entfernen",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "Erforderlich",
|
||||
"reset_to_theme_styles": "Styling zurücksetzen",
|
||||
"reset_to_theme_styles_main_text": "Bist Du sicher, dass Du das Styling auf die Themenstile zurücksetzen möchtest? Dadurch wird jegliches benutzerdefinierte Styling entfernt.",
|
||||
"respect_global_waiting_time": "Projektweite Wartezeit verwenden",
|
||||
"respect_global_waiting_time_description": "Diese Umfrage folgt der in der Projektkonfiguration festgelegten Wartezeit. Sie wird nur angezeigt, wenn in diesem Zeitraum keine andere Umfrage erschienen ist.",
|
||||
"response_limit_can_t_be_set_to_0": "Das Antwortlimit kann nicht auf 0 gesetzt werden",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
|
||||
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "Erweiterte Einstellungen anzeigen",
|
||||
"show_button": "Button anzeigen",
|
||||
"show_language_switch": "Sprachwechsel anzeigen",
|
||||
"show_multiple_times": "Begrenzte Anzahl von Malen anzeigen",
|
||||
"show_multiple_times": "Mehrfach anzeigen",
|
||||
"show_only_once": "Nur einmal anzeigen",
|
||||
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
|
||||
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "Schalte Mehrsprachigkeit ein, um loszulegen 👉",
|
||||
"targeted": "Gezielt",
|
||||
"ten_points": "10 Punkte",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Höchstens die angegebene Anzahl von Malen anzeigen oder bis sie antworten (je nachdem, was zuerst eintritt).",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Die Umfrage wird mehrmals angezeigt, bis Du antwortest",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Die Umfrage wird einmal angezeigt, auch wenn die Person nicht antwortet.",
|
||||
"then": "dann",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
|
||||
"this_extension_is_already_added": "Diese Erweiterung ist bereits hinzugefügt.",
|
||||
"this_file_type_is_not_supported": "Dieser Dateityp wird nicht unterstützt.",
|
||||
"this_setting_overwrites_your": "Diese Einstellung überschreibt deine",
|
||||
"three_points": "3 Punkte",
|
||||
"times": "Zeiten",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
|
||||
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
|
||||
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
|
||||
"until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben",
|
||||
"until_they_submit_a_response": "Bis sie eine Antwort einreichen",
|
||||
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
|
||||
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
|
||||
"upload": "Hochladen",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "Oberes Label",
|
||||
"url_filters": "URL-Filter",
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"use_with_caution": "Mit Vorsicht verwenden",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
|
||||
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
|
||||
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
|
||||
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
|
||||
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
|
||||
"wait": "Warte",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
|
||||
"waiting_time_across_surveys": "Projektweite Wartezeit",
|
||||
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wählen Sie aus, wie diese Umfrage mit der projektweiten Wartezeit interagiert.",
|
||||
"waiting_period": "Wartezeit",
|
||||
"welcome_message": "Willkommensnachricht",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Wenn die Bedingungen übereinstimmen, wird die Wartezeit ignoriert und die Umfrage angezeigt.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
|
||||
"you_have_not_created_a_segment_yet": "Du hast noch keinen Segment erstellt.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Du musst zwei oder mehr Sprachen in deinem Projekt einrichten, um mit Übersetzungen zu arbeiten.",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresszeile 1",
|
||||
"address_line_2": "Adresszeile 2",
|
||||
"an_error_occurred_adding_the_tag": "Beim Hinzufügen des Tags ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_creating_the_tag": "Beim Erstellen des Tags ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
|
||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||
"expand_rows": "Expand rows",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_projects": "Failed to load projects",
|
||||
"finish": "Finish",
|
||||
"follow_these": "Follow these",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segments",
|
||||
"select": "Select",
|
||||
"select_all": "Select all",
|
||||
"select_filter": "Select filter",
|
||||
"select_survey": "Select Survey",
|
||||
"select_teams": "Select teams",
|
||||
"selected": "Selected",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "User ID",
|
||||
"user_not_found": "User not found",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variable IDs",
|
||||
"variables": "Variables",
|
||||
"verified_email": "Verified Email",
|
||||
"video": "Video",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "Project deleted successfully",
|
||||
"project_name_settings_description": "Change your projects name.",
|
||||
"project_name_updated_successfully": "Project name updated successfully",
|
||||
"recontact_waiting_time": "Project-wide Waiting Time Between Surveys",
|
||||
"recontact_waiting_time": "Recontact Waiting Time",
|
||||
"recontact_waiting_time_settings_description": "Control how frequently users can be surveyed across all app surveys.",
|
||||
"this_action_cannot_be_undone": "This action cannot be undone.",
|
||||
"wait_x_days_before_showing_next_survey": "Wait X days before showing next survey:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "See which teams can access this project."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projects, environments or organizations not found",
|
||||
"segments": {
|
||||
"add_filter_below": "Add filter below",
|
||||
"add_your_first_filter_to_get_started": "Add your first filter to get started",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "Manage Subscription",
|
||||
"monthly": "Monthly",
|
||||
"monthly_identified_users": "Monthly Identified Users",
|
||||
"per_month": "per month",
|
||||
"per_year": "per year",
|
||||
"plan_upgraded_successfully": "Plan upgraded successfully",
|
||||
"premium_support_with_slas": "Premium support with SLAs",
|
||||
"remove_branding": "Remove Branding",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Everything in Free with additional features.",
|
||||
"switch_plan": "Switch Plan",
|
||||
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
|
||||
"team_access_roles": "Team Access Roles",
|
||||
"unable_to_upgrade_plan": "Unable to upgrade plan",
|
||||
"unlimited_miu": "Unlimited MIU",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "Allow multi-select",
|
||||
"allow_multiple_files": "Allow multiple files",
|
||||
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
|
||||
"always_show_survey": "Always show survey",
|
||||
"and_launch_surveys_in_your_website_or_app": "and launch surveys in your website or app.",
|
||||
"animation": "Animation",
|
||||
"app_survey_description": "Embed a survey in your web app or website to collect responses.",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "Custom hostname",
|
||||
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
|
||||
"date_format": "Date format",
|
||||
"days_before_showing_this_survey_again": "days after any survey is shown before this survey can appear.",
|
||||
"days_before_showing_this_survey_again": "days before showing this survey again.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
|
||||
"delete_choice": "Delete choice",
|
||||
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "Equals one of",
|
||||
"error_publishing_survey": "An error occured while publishing the survey.",
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
|
||||
"everyone": "Everyone",
|
||||
"external_urls_paywall_tooltip": "Please upgrade to customize external URL. Phishing prevention.",
|
||||
"fallback_missing": "Fallback missing",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
|
||||
"ignore_global_waiting_time": "Ignore project-wide waiting time",
|
||||
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.",
|
||||
"ignore_waiting_time_between_surveys": "Ignore waiting time between surveys",
|
||||
"image": "Image",
|
||||
"includes_all_of": "Includes all of",
|
||||
"includes_one_of": "Includes one of",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "Optional",
|
||||
"options": "Options",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
|
||||
"overwrite_global_waiting_time": "Set custom waiting time",
|
||||
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
|
||||
"overwrite_placement": "Overwrite placement",
|
||||
"overwrite_the_global_placement_of_the_survey": "Overwrite the global placement of the survey",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "Overwrites waiting period between surveys to {days} day(s).",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Pick a background from our library or upload your own.",
|
||||
"picture_idx": "Picture {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "Range",
|
||||
"recall_data": "Recall data",
|
||||
"recall_information_from": "Recall information from ...",
|
||||
"recontact_options_section": "Recontact options",
|
||||
"recontact_options_section_description": "If the waiting time allows, choose how often this survey can be shown to a person.",
|
||||
"recontact_options": "Recontact Options",
|
||||
"redirect_thank_you_card": "Redirect thank you card",
|
||||
"redirect_to_url": "Redirect to Url",
|
||||
"remove_description": "Remove description",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "Required",
|
||||
"reset_to_theme_styles": "Reset to theme styles",
|
||||
"reset_to_theme_styles_main_text": "Are you sure you want to reset the styling to the theme styles? This will remove all custom styling.",
|
||||
"respect_global_waiting_time": "Use project-wide waiting time",
|
||||
"respect_global_waiting_time_description": "This survey follows the waiting time set in project configuration. It only shows if no other survey has appeared during that period.",
|
||||
"response_limit_can_t_be_set_to_0": "Response limit can't be set to 0",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Response limits, redirections and more.",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "Show Advanced settings",
|
||||
"show_button": "Show Button",
|
||||
"show_language_switch": "Show language switch",
|
||||
"show_multiple_times": "Show a limited number of times",
|
||||
"show_multiple_times": "Show multiple times",
|
||||
"show_only_once": "Show only once",
|
||||
"show_survey_maximum_of": "Show survey maximum of",
|
||||
"show_survey_to_users": "Show survey to % of users",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "Switch multi-lanugage on to get started \uD83D\uDC49",
|
||||
"targeted": "Targeted",
|
||||
"ten_points": "10 points",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Show at most the specified number of times, or until they respond (whichever comes first).",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they don't respond.",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "The survey will be shown multiple times until they respond",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "The survey will be shown once, even if person doesn't respond.",
|
||||
"then": "Then",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
|
||||
"this_extension_is_already_added": "This extension is already added.",
|
||||
"this_file_type_is_not_supported": "This file type is not supported.",
|
||||
"this_setting_overwrites_your": "This setting overwrites your",
|
||||
"three_points": "3 points",
|
||||
"times": "times",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "Target specific user groups based on attributes or device information",
|
||||
"unlock_targeting_title": "Unlock targeting with a higher plan",
|
||||
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
|
||||
"until_they_submit_a_response": "Ask until they submit a response",
|
||||
"until_they_submit_a_response": "Until they submit a response",
|
||||
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
|
||||
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
|
||||
"upload": "Upload",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "Upper Label",
|
||||
"url_filters": "URL Filters",
|
||||
"url_not_supported": "URL not supported",
|
||||
"use_with_caution": "Use with caution",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "Variable \"{variable}\" is being recalled in Welcome Card.",
|
||||
"verify_email_before_submission": "Verify email before submission",
|
||||
"verify_email_before_submission_description": "Only let people with a real email respond.",
|
||||
"visibility_and_recontact": "Visibility & Recontact",
|
||||
"visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.",
|
||||
"wait": "Wait",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey",
|
||||
"waiting_time_across_surveys": "Project-wide waiting time",
|
||||
"waiting_time_across_surveys_description": "To prevent survey fatigue, choose how this survey interacts with the project-wide waiting time.",
|
||||
"waiting_period": "waiting period",
|
||||
"welcome_message": "Welcome message",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "When conditions match, waiting time will be ignored and survey shown.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.",
|
||||
"you_have_not_created_a_segment_yet": "You have not created a segment yet",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "You need to have two or more languages set up in your project to work with translations.",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Address Line 1",
|
||||
"address_line_2": "Address Line 2",
|
||||
"an_error_occurred_adding_the_tag": "An error occurred adding the tag",
|
||||
"an_error_occurred_creating_the_tag": "An error occurred creating the tag",
|
||||
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "Setup integrations",
|
||||
"share_survey": "Share survey",
|
||||
"show_all_responses_that_match": "Show all responses that match",
|
||||
"show_all_responses_where": "Show all responses where...",
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,7 +200,6 @@
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
|
||||
"error_rate_limit_title": "Limite de Taux Dépassée",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_projects": "Échec du chargement des projets",
|
||||
"finish": "Terminer",
|
||||
"follow_these": "Suivez ceci",
|
||||
"formbricks_version": "Version de Formbricks",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segments",
|
||||
"select": "Sélectionner",
|
||||
"select_all": "Sélectionner tout",
|
||||
"select_filter": "Sélectionner un filtre",
|
||||
"select_survey": "Sélectionner l'enquête",
|
||||
"select_teams": "Sélectionner les équipes",
|
||||
"selected": "Sélectionné",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "Identifiant d'utilisateur",
|
||||
"user_not_found": "Utilisateur non trouvé",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Identifiants variables",
|
||||
"variables": "Variables",
|
||||
"verified_email": "Email vérifié",
|
||||
"video": "Vidéo",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "Projet supprimé avec succès",
|
||||
"project_name_settings_description": "Vous pouvez modifier le nom de votre projet.",
|
||||
"project_name_updated_successfully": "Le nom du projet a été mis à jour avec succès.",
|
||||
"recontact_waiting_time": "Temps d'attente entre les enquêtes à l'échelle du projet",
|
||||
"recontact_waiting_time": "Délai avant nouveau contact",
|
||||
"recontact_waiting_time_settings_description": "Vous pouvez contrôler la fréquence à laquelle les utilisateurs sont sollicités pour répondre aux enquêtes.",
|
||||
"this_action_cannot_be_undone": "Cette action ne peut pas être annulée.",
|
||||
"wait_x_days_before_showing_next_survey": "Nombre de jours devant s'écouler avant une nouvelle sollicitation :",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projets, environnements ou organisations non trouvés",
|
||||
"segments": {
|
||||
"add_filter_below": "Ajouter un filtre ci-dessous",
|
||||
"add_your_first_filter_to_get_started": "Ajoutez votre premier filtre pour commencer.",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "Gérer l'abonnement",
|
||||
"monthly": "Mensuel",
|
||||
"monthly_identified_users": "Utilisateurs mensuels identifiés",
|
||||
"per_month": "par mois",
|
||||
"per_year": "par an",
|
||||
"plan_upgraded_successfully": "Plan mis à jour avec succès",
|
||||
"premium_support_with_slas": "Assistance premium avec accord de niveau de service",
|
||||
"remove_branding": "Suppression du logo",
|
||||
"startup": "Initial",
|
||||
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
|
||||
"switch_plan": "Changer de plan",
|
||||
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
|
||||
"team_access_roles": "Gestion des accès",
|
||||
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
|
||||
"unlimited_miu": "MIU Illimité",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "Autoriser la sélection multiple",
|
||||
"allow_multiple_files": "Autoriser plusieurs fichiers",
|
||||
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
|
||||
"always_show_survey": "Afficher toujours l'enquête",
|
||||
"and_launch_surveys_in_your_website_or_app": "et lancez des enquêtes sur votre site web ou votre application.",
|
||||
"animation": "Animation",
|
||||
"app_survey_description": "Intégrez une enquête dans votre application web ou votre site web pour collecter des réponses.",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "Nom d'hôte personnalisé",
|
||||
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
|
||||
"date_format": "Format de date",
|
||||
"days_before_showing_this_survey_again": "jours après qu'une enquête soit affichée avant que cette enquête puisse apparaître.",
|
||||
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
|
||||
"delete_choice": "Supprimer l'option",
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "Égal à l'un de",
|
||||
"error_publishing_survey": "Une erreur est survenue lors de la publication de l'enquête.",
|
||||
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses ; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
|
||||
"everyone": "Tout le monde",
|
||||
"external_urls_paywall_tooltip": "Veuillez passer à la version supérieure pour personnaliser l'URL externe. Prévention contre l'hameçonnage.",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "Nom d'hôte",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
|
||||
"ignore_global_waiting_time": "Ignorer le temps d'attente à l'échelle du projet",
|
||||
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes",
|
||||
"image": "Image",
|
||||
"includes_all_of": "Comprend tous les",
|
||||
"includes_one_of": "Comprend un de",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "Optionnel",
|
||||
"options": "Options",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
|
||||
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
|
||||
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
|
||||
"overwrite_placement": "Surcharge de placement",
|
||||
"overwrite_the_global_placement_of_the_survey": "Surcharger le placement global de l'enquête",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "Remplace la période d'attente entre les enquêtes par {days} jour(s).",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Choisissez un arrière-plan dans notre bibliothèque ou téléchargez le vôtre.",
|
||||
"picture_idx": "Image {idx}",
|
||||
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "Plage",
|
||||
"recall_data": "Rappel des données",
|
||||
"recall_information_from": "Rappeler les informations de ...",
|
||||
"recontact_options_section": "Options de recontact",
|
||||
"recontact_options_section_description": "Si le temps d'attente le permet, choisissez la fréquence à laquelle cette enquête peut être présentée à une personne.",
|
||||
"recontact_options": "Options de recontact",
|
||||
"redirect_thank_you_card": "Carte de remerciement de redirection",
|
||||
"redirect_to_url": "Rediriger vers l'URL",
|
||||
"remove_description": "Supprimer la description",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "Requis",
|
||||
"reset_to_theme_styles": "Réinitialiser aux styles de thème",
|
||||
"reset_to_theme_styles_main_text": "Êtes-vous sûr de vouloir réinitialiser le style aux styles du thème ? Cela supprimera tous les styles personnalisés.",
|
||||
"respect_global_waiting_time": "Utiliser le temps d'attente à l'échelle du projet",
|
||||
"respect_global_waiting_time_description": "Cette enquête respecte le temps d'attente défini dans la configuration du projet. Elle ne s'affiche que si aucune autre enquête n'est apparue pendant cette période.",
|
||||
"response_limit_can_t_be_set_to_0": "La limite de réponse ne peut pas être fixée à 0.",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "Afficher les paramètres avancés",
|
||||
"show_button": "Afficher le bouton",
|
||||
"show_language_switch": "Afficher le changement de langue",
|
||||
"show_multiple_times": "Afficher un nombre limité de fois",
|
||||
"show_multiple_times": "Afficher plusieurs fois",
|
||||
"show_only_once": "Afficher une seule fois",
|
||||
"show_survey_maximum_of": "Afficher le maximum du sondage de",
|
||||
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "Activez le multilingue pour commencer 👉",
|
||||
"targeted": "Ciblé",
|
||||
"ten_points": "10 points",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Afficher au maximum le nombre de fois spécifié, ou jusqu'à ce qu'ils répondent (selon la première éventualité).",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "L'enquête sera affichée plusieurs fois jusqu'à ce qu'ils répondent.",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "L'enquête sera affichée une fois, même si la personne ne répond pas.",
|
||||
"then": "Alors",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
|
||||
"this_extension_is_already_added": "Cette extension est déjà ajoutée.",
|
||||
"this_file_type_is_not_supported": "Ce type de fichier n'est pas pris en charge.",
|
||||
"this_setting_overwrites_your": "Ce paramètre écrase votre",
|
||||
"three_points": "3 points",
|
||||
"times": "fois",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "Cibler des groupes d'utilisateurs spécifiques en fonction des attributs ou des informations sur l'appareil",
|
||||
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
|
||||
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
|
||||
"until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse",
|
||||
"until_they_submit_a_response": "Jusqu'à ce qu'ils soumettent une réponse",
|
||||
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
|
||||
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
|
||||
"upload": "Télécharger",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "Étiquette supérieure",
|
||||
"url_filters": "Filtres d'URL",
|
||||
"url_not_supported": "URL non supportée",
|
||||
"use_with_caution": "À utiliser avec précaution",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
|
||||
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
||||
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
|
||||
"visibility_and_recontact": "Visibilité et recontact",
|
||||
"visibility_and_recontact_description": "Contrôlez quand cette enquête peut apparaître et à quelle fréquence elle peut réapparaître.",
|
||||
"wait": "Attendre",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Attendez quelques secondes après le déclencheur avant de montrer l'enquête.",
|
||||
"waiting_time_across_surveys": "Temps d'attente à l'échelle du projet",
|
||||
"waiting_time_across_surveys_description": "Pour éviter la lassitude face aux enquêtes, choisissez comment cette enquête interagit avec le temps d'attente à l'échelle du projet.",
|
||||
"waiting_period": "période d'attente",
|
||||
"welcome_message": "Message de bienvenue",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Lorsque les conditions correspondent, le temps d'attente sera ignoré et l'enquête sera affichée.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Sans filtre, tous vos utilisateurs peuvent être sondés.",
|
||||
"you_have_not_created_a_segment_yet": "Tu n'as pas encore créé de segment.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Vous devez avoir deux langues ou plus configurées dans votre projet pour travailler avec des traductions.",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Ligne d'adresse 1",
|
||||
"address_line_2": "Ligne d'adresse 2",
|
||||
"an_error_occurred_adding_the_tag": "Une erreur est survenue lors de l'ajout de l'étiquette",
|
||||
"an_error_occurred_creating_the_tag": "Une erreur est survenue lors de la création de l'étiquette",
|
||||
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
|
||||
"browser": "Navigateur",
|
||||
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
"show_all_responses_where": "Afficher toutes les réponses où...",
|
||||
"starts": "Commence",
|
||||
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
|
||||
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "編集",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
"environment_not_found": "環境が見つかりません",
|
||||
"environment_notice": "現在、{environment} 環境にいます。",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
|
||||
"error_rate_limit_title": "レート制限を超えました",
|
||||
"expand_rows": "行を展開",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
|
||||
"finish": "完了",
|
||||
"follow_these": "こちらの手順に従って",
|
||||
"formbricks_version": "Formbricksバージョン",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "セグメント",
|
||||
"select": "選択",
|
||||
"select_all": "すべて選択",
|
||||
"select_filter": "フィルターを選択",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_teams": "チームを選択",
|
||||
"selected": "選択済み",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "ユーザーID",
|
||||
"user_not_found": "ユーザーが見つかりません",
|
||||
"variable": "変数",
|
||||
"variable_ids": "変数ID",
|
||||
"variables": "変数",
|
||||
"verified_email": "認証済みメールアドレス",
|
||||
"video": "動画",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "プロジェクトを削除しました",
|
||||
"project_name_settings_description": "プロジェクト名を変更します。",
|
||||
"project_name_updated_successfully": "プロジェクト名を更新しました",
|
||||
"recontact_waiting_time": "フォーム間のプロジェクト全体の待機時間",
|
||||
"recontact_waiting_time": "再接触の待機時間",
|
||||
"recontact_waiting_time_settings_description": "アプリ内フォーム全体で、ユーザーにどの頻度で表示するかを制御します。",
|
||||
"this_action_cannot_be_undone": "この操作は取り消せません。",
|
||||
"wait_x_days_before_showing_next_survey": "次のフォームを表示するまでの待機日数:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "プロジェクト、環境、または組織が見つかりません",
|
||||
"segments": {
|
||||
"add_filter_below": "下にフィルターを追加",
|
||||
"add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "サブスクリプションを管理",
|
||||
"monthly": "月間",
|
||||
"monthly_identified_users": "月間識別ユーザー数",
|
||||
"per_month": "月",
|
||||
"per_year": "年",
|
||||
"plan_upgraded_successfully": "プランを正常にアップグレードしました",
|
||||
"premium_support_with_slas": "SLA付きプレミアムサポート",
|
||||
"remove_branding": "ブランディングを削除",
|
||||
"startup": "スタートアップ",
|
||||
"startup_description": "無料プランのすべての機能に追加機能。",
|
||||
"switch_plan": "プランを切り替え",
|
||||
"switch_plan_confirmation_text": "本当に {plan} プランに切り替えますか? {price} {period} が請求されます。",
|
||||
"team_access_roles": "チームアクセスロール",
|
||||
"unable_to_upgrade_plan": "プランをアップグレードできません",
|
||||
"unlimited_miu": "無制限のMIU",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "複数選択を許可",
|
||||
"allow_multiple_files": "複数のファイルを許可",
|
||||
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
|
||||
"always_show_survey": "常にフォームを表示",
|
||||
"and_launch_surveys_in_your_website_or_app": "ウェブサイトやアプリでフォームを公開できます。",
|
||||
"animation": "アニメーション",
|
||||
"app_survey_description": "回答を収集するために、ウェブアプリまたはウェブサイトにフォームを埋め込みます。",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "カスタムホスト名",
|
||||
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
|
||||
"date_format": "日付形式",
|
||||
"days_before_showing_this_survey_again": "任意のフォームが表示された後、このフォームが再表示されるまでの日数。",
|
||||
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
|
||||
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
|
||||
"delete_choice": "選択肢を削除",
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "のいずれかと等しい",
|
||||
"error_publishing_survey": "フォームの公開中にエラーが発生しました。",
|
||||
"error_saving_changes": "変更の保存中にエラーが発生しました",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
|
||||
"everyone": "全員",
|
||||
"external_urls_paywall_tooltip": "外部 URL をカスタマイズするにはアップグレードしてください 。 フィッシング防止 。",
|
||||
"fallback_missing": "フォールバックがありません",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "ホスト名",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "{surveyTypeDerived} フォームのカードをどれくらいユニークにしますか",
|
||||
"if_you_need_more_please": "さらに必要な場合は、",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
|
||||
"ignore_global_waiting_time": "プロジェクト全体の待機時間を無視する",
|
||||
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "本当にその回答が欲しいなら、それを得るまで尋ねてください。",
|
||||
"ignore_waiting_time_between_surveys": "フォーム間の待機時間を無視する",
|
||||
"image": "画像",
|
||||
"includes_all_of": "のすべてを含む",
|
||||
"includes_one_of": "のいずれかを含む",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "オプション",
|
||||
"options": "オプション",
|
||||
"override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。",
|
||||
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
|
||||
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
|
||||
"overwrite_placement": "配置を上書き",
|
||||
"overwrite_the_global_placement_of_the_survey": "フォームのグローバルな配置を上書き",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "フォーム間の待機期間を {days} 日に上書きします。",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "ライブラリから背景を選択するか、独自にアップロードしてください。",
|
||||
"picture_idx": "写真 {idx}",
|
||||
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "範囲",
|
||||
"recall_data": "データを呼び出す",
|
||||
"recall_information_from": "... からの情報を呼び戻す",
|
||||
"recontact_options_section": "再接触オプション",
|
||||
"recontact_options_section_description": "待機時間が許可する場合、このフォームを一人の人にどれくらいの頻度で表示できるかを選択します。",
|
||||
"recontact_options": "再接触オプション",
|
||||
"redirect_thank_you_card": "サンクスクカードをリダイレクト",
|
||||
"redirect_to_url": "URLにリダイレクト",
|
||||
"remove_description": "説明を削除",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "必須",
|
||||
"reset_to_theme_styles": "テーマのスタイルにリセット",
|
||||
"reset_to_theme_styles_main_text": "スタイルをテーマのスタイルにリセットしてもよろしいですか?これにより、すべてのカスタムスタイルが削除されます。",
|
||||
"respect_global_waiting_time": "プロジェクト全体の待機時間を使用する",
|
||||
"respect_global_waiting_time_description": "このフォームはプロジェクト設定で設定された待機時間に従います。その期間中に他のフォームが表示されていない場合にのみ表示されます。",
|
||||
"response_limit_can_t_be_set_to_0": "回答数の上限を0に設定することはできません",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
|
||||
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "詳細設定を表示",
|
||||
"show_button": "ボタンを表示",
|
||||
"show_language_switch": "言語切り替えを表示",
|
||||
"show_multiple_times": "限られた回数表示する",
|
||||
"show_multiple_times": "複数回表示",
|
||||
"show_only_once": "一度だけ表示",
|
||||
"show_survey_maximum_of": "フォームの最大表示回数",
|
||||
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "始めるには多言語をオンにしてください 👉",
|
||||
"targeted": "ターゲット",
|
||||
"ten_points": "10点",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "指定された回数まで、または回答があるまで表示します(どちらか先に達した方)。",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答がなくても1回だけ表示します。",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "回答するまで複数回フォームが表示されます",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答しなくても、一度だけフォームが表示されます。",
|
||||
"then": "その後",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
|
||||
"this_extension_is_already_added": "この拡張機能はすでに追加されています。",
|
||||
"this_file_type_is_not_supported": "このファイルタイプはサポートされていません。",
|
||||
"this_setting_overwrites_your": "この設定はあなたの",
|
||||
"three_points": "3点",
|
||||
"times": "回",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "属性またはデバイス情報に基づいて、特定のユーザーグループをターゲットにします",
|
||||
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
|
||||
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
|
||||
"until_they_submit_a_response": "回答が提出されるまで質問する",
|
||||
"until_they_submit_a_response": "回答を送信するまで",
|
||||
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
|
||||
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
|
||||
"upload": "アップロード",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "上限ラベル",
|
||||
"url_filters": "URLフィルター",
|
||||
"url_not_supported": "URLはサポートされていません",
|
||||
"use_with_caution": "注意して使用",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
|
||||
"verify_email_before_submission": "送信前にメールアドレスを認証",
|
||||
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
|
||||
"visibility_and_recontact": "表示と再接触",
|
||||
"visibility_and_recontact_description": "このフォームがいつ表示され、どのくらいの頻度で再表示できるかをコントロールします。",
|
||||
"wait": "待つ",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "トリガーから数秒待ってからフォームを表示します",
|
||||
"waiting_time_across_surveys": "プロジェクト全体の待機時間",
|
||||
"waiting_time_across_surveys_description": "フォーム疲れを防ぐため、このフォームがプロジェクト全体の待機時間とどのように相互作用するかを選択します。",
|
||||
"waiting_period": "待機期間",
|
||||
"welcome_message": "ウェルカムメッセージ",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "条件が一致すると、待機時間は無視され、フォームが表示されます。",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "フィルターがなければ、すべてのユーザーがフォームに回答できます。",
|
||||
"you_have_not_created_a_segment_yet": "まだセグメントを作成していません",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "翻訳を操作するには、プロジェクトで2つ以上の言語を設定する必要があります。",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "住所1",
|
||||
"address_line_2": "住所2",
|
||||
"an_error_occurred_adding_the_tag": "タグの追加中にエラーが発生しました",
|
||||
"an_error_occurred_creating_the_tag": "タグの作成中にエラーが発生しました",
|
||||
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
|
||||
"browser": "ブラウザ",
|
||||
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "連携を設定",
|
||||
"share_survey": "フォームを共有",
|
||||
"show_all_responses_that_match": "一致するすべての回答を表示",
|
||||
"show_all_responses_where": "以下のすべての回答を表示...",
|
||||
"starts": "開始",
|
||||
"starts_tooltip": "フォームが開始された回数。",
|
||||
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Terminar",
|
||||
"follow_these": "Siga esses",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segmentos",
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_survey": "Selecionar Pesquisa",
|
||||
"select_teams": "Selecionar times",
|
||||
"selected": "Selecionado",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "ID do usuário",
|
||||
"user_not_found": "Usuário não encontrado",
|
||||
"variable": "variável",
|
||||
"variable_ids": "IDs de variáveis",
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email Verificado",
|
||||
"video": "vídeo",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "Projeto deletado com sucesso",
|
||||
"project_name_settings_description": "Mude o nome do seu projeto.",
|
||||
"project_name_updated_successfully": "Nome do projeto atualizado com sucesso",
|
||||
"recontact_waiting_time": "Tempo de espera entre pesquisas em todo o projeto",
|
||||
"recontact_waiting_time": "Tempo de Espera para Recontato",
|
||||
"recontact_waiting_time_settings_description": "Controle com que frequência os usuários podem ser pesquisados em todas as pesquisas do app.",
|
||||
"this_action_cannot_be_undone": "Essa ação não pode ser desfeita.",
|
||||
"wait_x_days_before_showing_next_survey": "Espere X dias antes de mostrar a próxima pesquisa:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
|
||||
"segments": {
|
||||
"add_filter_below": "Adicionar filtro abaixo",
|
||||
"add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "Gerenciar Assinatura",
|
||||
"monthly": "mensal",
|
||||
"monthly_identified_users": "Usuários Identificados Mensalmente",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"remove_branding": "Remover Marca",
|
||||
"startup": "startup",
|
||||
"startup_description": "Tudo no Grátis com recursos adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipe",
|
||||
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "Permitir seleção múltipla",
|
||||
"allow_multiple_files": "Permitir vários arquivos",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
|
||||
"always_show_survey": "Mostrar pesquisa sempre",
|
||||
"and_launch_surveys_in_your_website_or_app": "e lançar pesquisas no seu site ou app.",
|
||||
"animation": "animação",
|
||||
"app_survey_description": "Embuta uma pesquisa no seu app ou site para coletar respostas.",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "Hostname personalizado",
|
||||
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
|
||||
"date_format": "Formato de data",
|
||||
"days_before_showing_this_survey_again": "dias após qualquer pesquisa ser mostrada antes que esta pesquisa possa aparecer.",
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
|
||||
"delete_choice": "Deletar opção",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "É igual a um de",
|
||||
"error_publishing_survey": "Ocorreu um erro ao publicar a pesquisa.",
|
||||
"error_saving_changes": "Erro ao salvar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todo mundo",
|
||||
"external_urls_paywall_tooltip": "Por favor, faça upgrade para personalizar o URL externo. Prevenção de phishing.",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
|
||||
"ignore_global_waiting_time": "Ignorar tempo de espera do projeto",
|
||||
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas",
|
||||
"image": "imagem",
|
||||
"includes_all_of": "Inclui tudo de",
|
||||
"includes_one_of": "Inclui um de",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "Opcional",
|
||||
"options": "Opções",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.",
|
||||
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
|
||||
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
|
||||
"overwrite_placement": "Substituir posicionamento",
|
||||
"overwrite_the_global_placement_of_the_survey": "Substituir a posição global da pesquisa",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "Substitui o período de espera entre as pesquisas para {days} dia(s).",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou faça upload do seu próprio.",
|
||||
"picture_idx": "Imagem {idx}",
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "alcance",
|
||||
"recall_data": "Lembrar dados",
|
||||
"recall_information_from": "Recuperar informações de ...",
|
||||
"recontact_options_section": "Opções de recontato",
|
||||
"recontact_options_section_description": "Se o tempo de espera permitir, escolha com que frequência esta pesquisa pode ser mostrada a uma pessoa.",
|
||||
"recontact_options": "Opções de Recontato",
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para URL",
|
||||
"remove_description": "Remover descrição",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "Obrigatório",
|
||||
"reset_to_theme_styles": "Redefinir para estilos do tema",
|
||||
"reset_to_theme_styles_main_text": "Tem certeza de que quer redefinir o estilo para o tema padrão? Isso vai remover todas as personalizações.",
|
||||
"respect_global_waiting_time": "Usar tempo de espera do projeto",
|
||||
"respect_global_waiting_time_description": "Esta pesquisa segue o tempo de espera definido na configuração do projeto. Ela só é mostrada se nenhuma outra pesquisa tiver aparecido durante esse período.",
|
||||
"response_limit_can_t_be_set_to_0": "Limite de resposta não pode ser 0",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "Mostrar configurações avançadas",
|
||||
"show_button": "Mostrar Botão",
|
||||
"show_language_switch": "Mostrar troca de idioma",
|
||||
"show_multiple_times": "Mostrar um número limitado de vezes",
|
||||
"show_multiple_times": "Mostrar várias vezes",
|
||||
"show_only_once": "Mostrar só uma vez",
|
||||
"show_survey_maximum_of": "Mostrar no máximo",
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "Ative o modo multilíngue para começar 👉",
|
||||
"targeted": "direcionado",
|
||||
"ten_points": "10 pontos",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Mostrar no máximo o número especificado de vezes, ou até que respondam (o que ocorrer primeiro).",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "A pesquisa vai ser mostrada várias vezes até eles responderem",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "A pesquisa será mostrada uma vez, mesmo se a pessoa não responder.",
|
||||
"then": "Então",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.",
|
||||
"this_extension_is_already_added": "Essa extensão já foi adicionada.",
|
||||
"this_file_type_is_not_supported": "Esse tipo de arquivo não é suportado.",
|
||||
"this_setting_overwrites_your": "Essa configuração sobrescreve seu",
|
||||
"three_points": "3 pontos",
|
||||
"times": "times",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todas as pesquisas, você pode",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
|
||||
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
|
||||
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
|
||||
"until_they_submit_a_response": "Perguntar até que enviem uma resposta",
|
||||
"until_they_submit_a_response": "Até eles enviarem uma resposta",
|
||||
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
|
||||
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
|
||||
"upload": "Enviar",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportada",
|
||||
"use_with_caution": "Use com cuidado",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
|
||||
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
||||
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
|
||||
"visibility_and_recontact": "Visibilidade e recontato",
|
||||
"visibility_and_recontact_description": "Controle quando esta pesquisa pode aparecer e com que frequência pode reaparecer.",
|
||||
"wait": "Espera",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Espera alguns segundos depois do gatilho antes de mostrar a pesquisa",
|
||||
"waiting_time_across_surveys": "Tempo de espera em todo o projeto",
|
||||
"waiting_time_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o tempo de espera em todo o projeto.",
|
||||
"waiting_period": "período de espera",
|
||||
"welcome_message": "Mensagem de boas-vindas",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Quando as condições forem atendidas, o tempo de espera será ignorado e a pesquisa será exibida.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus usuários podem ser pesquisados.",
|
||||
"you_have_not_created_a_segment_yet": "Você ainda não criou um segmento.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Você precisa ter dois ou mais idiomas configurados no seu projeto para trabalhar com traduções.",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Complemento",
|
||||
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a tag",
|
||||
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a tag",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
|
||||
"browser": "navegador",
|
||||
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostre todas as respostas onde...",
|
||||
"starts": "começa",
|
||||
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
|
||||
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Concluir",
|
||||
"follow_these": "Siga estes",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segmentos",
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_survey": "Selecionar Inquérito",
|
||||
"select_teams": "Selecionar equipas",
|
||||
"selected": "Selecionado",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "ID do Utilizador",
|
||||
"user_not_found": "Utilizador não encontrado",
|
||||
"variable": "Variável",
|
||||
"variable_ids": "IDs de variáveis",
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email verificado",
|
||||
"video": "Vídeo",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "Projeto eliminado com sucesso",
|
||||
"project_name_settings_description": "Altere o nome dos seus projetos.",
|
||||
"project_name_updated_successfully": "Nome do projeto atualizado com sucesso",
|
||||
"recontact_waiting_time": "Tempo de Espera Entre Inquéritos em Todo o Projeto",
|
||||
"recontact_waiting_time": "Tempo de espera de recontacto",
|
||||
"recontact_waiting_time_settings_description": "Controle a regularidade com que os utilizadores podem ser inquiridos em todos os inquéritos da aplicação.",
|
||||
"this_action_cannot_be_undone": "Esta ação não pode ser desfeita.",
|
||||
"wait_x_days_before_showing_next_survey": "Dias de espera:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
|
||||
"segments": {
|
||||
"add_filter_below": "Adicionar filtro abaixo",
|
||||
"add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "Gerir Subscrição",
|
||||
"monthly": "Mensal",
|
||||
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"remove_branding": "Possibilidade de remover o logo",
|
||||
"startup": "Inicialização",
|
||||
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipa",
|
||||
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "Permitir seleção múltipla",
|
||||
"allow_multiple_files": "Permitir vários ficheiros",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
|
||||
"always_show_survey": "Mostrar sempre o inquérito",
|
||||
"and_launch_surveys_in_your_website_or_app": "e lance inquéritos no seu site ou aplicação.",
|
||||
"animation": "Animação",
|
||||
"app_survey_description": "Incorpore um inquérito na sua aplicação web ou site para recolher respostas.",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "Nome do host personalizado",
|
||||
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
|
||||
"date_format": "Formato da data",
|
||||
"days_before_showing_this_survey_again": "dias após qualquer inquérito ser mostrado antes que este inquérito possa aparecer.",
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
|
||||
"delete_choice": "Eliminar escolha",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "Igual a um de",
|
||||
"error_publishing_survey": "Ocorreu um erro ao publicar o questionário.",
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todos",
|
||||
"external_urls_paywall_tooltip": "Por favor, atualize para personalizar o URL externo. Prevenção contra phishing.",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "Nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
|
||||
"ignore_global_waiting_time": "Ignorar tempo de espera de todo o projeto",
|
||||
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos",
|
||||
"image": "Imagem",
|
||||
"includes_all_of": "Inclui todos de",
|
||||
"includes_one_of": "Inclui um de",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "Opcional",
|
||||
"options": "Opções",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.",
|
||||
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
|
||||
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
|
||||
"overwrite_placement": "Substituir colocação",
|
||||
"overwrite_the_global_placement_of_the_survey": "Substituir a colocação global do inquérito",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "Substitui o período de espera entre inquéritos para {days} dia(s).",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou carregue o seu próprio.",
|
||||
"picture_idx": "Imagem {idx}",
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "Intervalo",
|
||||
"recall_data": "Recuperar dados",
|
||||
"recall_information_from": "Recordar informação de ...",
|
||||
"recontact_options_section": "Opções de recontacto",
|
||||
"recontact_options_section_description": "Se o tempo de espera permitir, escolha com que frequência este inquérito pode ser mostrado a uma pessoa.",
|
||||
"recontact_options": "Opções de Recontacto",
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para Url",
|
||||
"remove_description": "Remover descrição",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "Obrigatório",
|
||||
"reset_to_theme_styles": "Repor para estilos do tema",
|
||||
"reset_to_theme_styles_main_text": "Tem a certeza de que deseja repor o estilo para os estilos do tema? Isto irá remover todos os estilos personalizados.",
|
||||
"respect_global_waiting_time": "Usar tempo de espera de todo o projeto",
|
||||
"respect_global_waiting_time_description": "Este inquérito segue o tempo de espera definido na configuração do projeto. Só é mostrado se nenhum outro inquérito tiver aparecido durante esse período.",
|
||||
"response_limit_can_t_be_set_to_0": "O limite de respostas não pode ser definido como 0",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "Mostrar definições avançadas",
|
||||
"show_button": "Mostrar Botão",
|
||||
"show_language_switch": "Mostrar alternador de idioma",
|
||||
"show_multiple_times": "Mostrar um número limitado de vezes",
|
||||
"show_multiple_times": "Mostrar várias vezes",
|
||||
"show_only_once": "Mostrar apenas uma vez",
|
||||
"show_survey_maximum_of": "Mostrar inquérito máximo de",
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "Ative o modo multilingue para começar 👉",
|
||||
"targeted": "Alvo",
|
||||
"ten_points": "10 pontos",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Mostrar no máximo o número especificado de vezes, ou até que respondam (o que ocorrer primeiro).",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "O inquérito será mostrado várias vezes até que respondam",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "O inquérito será mostrado uma vez, mesmo que a pessoa não responda.",
|
||||
"then": "Então",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.",
|
||||
"this_extension_is_already_added": "Esta extensão já está adicionada.",
|
||||
"this_file_type_is_not_supported": "Este tipo de ficheiro não é suportado.",
|
||||
"this_setting_overwrites_your": "Esta configuração substitui o seu",
|
||||
"three_points": "3 pontos",
|
||||
"times": "tempos",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
|
||||
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
|
||||
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
|
||||
"until_they_submit_a_response": "Perguntar até que submetam uma resposta",
|
||||
"until_they_submit_a_response": "Até que enviem uma resposta",
|
||||
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
|
||||
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
|
||||
"upload": "Carregar",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportado",
|
||||
"use_with_caution": "Usar com cautela",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
|
||||
"verify_email_before_submission": "Verificar email antes da submissão",
|
||||
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
|
||||
"visibility_and_recontact": "Visibilidade e Recontacto",
|
||||
"visibility_and_recontact_description": "Controlar quando este inquérito pode aparecer e com que frequência pode reaparecer.",
|
||||
"wait": "Aguardar",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Aguarde alguns segundos após o gatilho antes de mostrar o inquérito",
|
||||
"waiting_time_across_surveys": "Tempo de espera em todo o projeto",
|
||||
"waiting_time_across_surveys_description": "Para evitar a fadiga de inquéritos, escolha como este inquérito interage com o tempo de espera em todo o projeto.",
|
||||
"waiting_period": "período de espera",
|
||||
"welcome_message": "Mensagem de boas-vindas",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Quando as condições corresponderem, o tempo de espera será ignorado e o inquérito será mostrado.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus utilizadores podem ser pesquisados.",
|
||||
"you_have_not_created_a_segment_yet": "Ainda não criou um segmento",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Precisa de ter duas ou mais línguas configuradas no seu projeto para trabalhar com traduções.",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Endereço Linha 2",
|
||||
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a etiqueta",
|
||||
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a etiqueta",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
|
||||
"browser": "Navegador",
|
||||
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostrar todas as respostas onde...",
|
||||
"starts": "Começa",
|
||||
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
|
||||
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Editare",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
"environment_not_found": "Mediul nu a fost găsit",
|
||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
|
||||
"error_rate_limit_title": "Limită de cereri depășită",
|
||||
"expand_rows": "Extinde rândurile",
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_projects": "Nu s-a reușit încărcarea proiectelor",
|
||||
"finish": "Finalizează",
|
||||
"follow_these": "Urmați acestea",
|
||||
"formbricks_version": "Versiunea Formbricks",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segment",
|
||||
"select": "Selectați",
|
||||
"select_all": "Selectați toate",
|
||||
"select_filter": "Selectați filtrul",
|
||||
"select_survey": "Selectați chestionar",
|
||||
"select_teams": "Selectați echipele",
|
||||
"selected": "Selectat",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "ID Utilizator",
|
||||
"user_not_found": "Utilizatorul nu a fost găsit",
|
||||
"variable": "Variabilă",
|
||||
"variable_ids": "ID-uri variabile",
|
||||
"variables": "Variante",
|
||||
"verified_email": "Email verificat",
|
||||
"video": "Video",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "Proiect șters cu succes!",
|
||||
"project_name_settings_description": "Schimbați numele proiectului.",
|
||||
"project_name_updated_successfully": "Numele proiectului actualizat cu succes",
|
||||
"recontact_waiting_time": "Timp de așteptare la nivel de proiect între sondaje",
|
||||
"recontact_waiting_time": "Timp de așteptare până la recontactare",
|
||||
"recontact_waiting_time_settings_description": "Controlează cât de des pot fi utilizatorii chestionați în toate sondajele din aplicație.",
|
||||
"this_action_cannot_be_undone": "Această acțiune nu poate fi anulată.",
|
||||
"wait_x_days_before_showing_next_survey": "Așteaptă X zile înainte de a afișa următorul sondaj:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Proiecte, medii sau organizații nu găsite",
|
||||
"segments": {
|
||||
"add_filter_below": "Adăugați un filtru mai jos",
|
||||
"add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "Gestionați abonamentul",
|
||||
"monthly": "Lunar",
|
||||
"monthly_identified_users": "Utilizatori identificați lunar",
|
||||
"per_month": "pe lună",
|
||||
"per_year": "pe an",
|
||||
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
|
||||
"premium_support_with_slas": "Suport premium cu SLA-uri",
|
||||
"remove_branding": "Eliminare branding",
|
||||
"startup": "Pornire",
|
||||
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
|
||||
"switch_plan": "Schimbă planul",
|
||||
"switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.",
|
||||
"team_access_roles": "Roluri acces echipă",
|
||||
"unable_to_upgrade_plan": "Nu se poate upgrada planul",
|
||||
"unlimited_miu": "MIU Nelimitat",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "Permite selectare multiplă",
|
||||
"allow_multiple_files": "Permite fișiere multiple",
|
||||
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
|
||||
"always_show_survey": "Arată întotdeauna sondajul",
|
||||
"and_launch_surveys_in_your_website_or_app": "și lansați chestionare pe site-ul sau în aplicația dvs.",
|
||||
"animation": "Animație",
|
||||
"app_survey_description": "Incorporați un chestionar în aplicația web sau pe site-ul dvs. pentru a colecta răspunsuri.",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "Gazdă personalizată",
|
||||
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
|
||||
"date_format": "Format dată",
|
||||
"days_before_showing_this_survey_again": "zile după afișarea oricărui sondaj înainte ca acest sondaj să poată apărea din nou.",
|
||||
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
|
||||
"delete_choice": "Șterge alegerea",
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "Egal unu dintre",
|
||||
"error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.",
|
||||
"error_saving_changes": "Eroare la salvarea modificărilor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
|
||||
"everyone": "Toată lumea",
|
||||
"external_urls_paywall_tooltip": "Vă rugăm să faceți upgrade pentru a personaliza URL-ul extern. Prevenire phishing.",
|
||||
"fallback_missing": "Rezerva lipsă",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "Nume gazdă",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
|
||||
"ignore_global_waiting_time": "Ignoră timpul de așteptare la nivel de proiect",
|
||||
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Dacă într-adevăr îți dorești acel răspuns, întreabă până îl primești.",
|
||||
"ignore_waiting_time_between_surveys": "Ignoră perioada de așteptare între sondaje",
|
||||
"image": "Imagine",
|
||||
"includes_all_of": "Include toate\",\"contextDescription\":\"Part of a survey completion screen referencing conditions met when all items are included\"}",
|
||||
"includes_one_of": "Include una dintre",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "Opțional",
|
||||
"options": "Opțiuni",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.",
|
||||
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
|
||||
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
|
||||
"overwrite_placement": "Suprascriere amplasare",
|
||||
"overwrite_the_global_placement_of_the_survey": "Suprascrie amplasarea globală a sondajului",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "Suprascrie perioada de așteptare dintre sondaje la {days} zi(le).",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "Alege un fundal din biblioteca noastră sau încarcă unul propriu.",
|
||||
"picture_idx": "Poză {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "Interval",
|
||||
"recall_data": "Reamintiți datele",
|
||||
"recall_information_from": "Reamintiți informațiile din ...",
|
||||
"recontact_options_section": "Opțiuni de recontactare",
|
||||
"recontact_options_section_description": "Dacă timpul de așteptare permite, alege cât de des poate fi afișat acest sondaj unei persoane.",
|
||||
"recontact_options": "Opțiuni de recontactare",
|
||||
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
|
||||
"redirect_to_url": "Redirecționează către URL",
|
||||
"remove_description": "Eliminați descrierea",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "Obligatoriu",
|
||||
"reset_to_theme_styles": "Resetare la stilurile temei",
|
||||
"reset_to_theme_styles_main_text": "Sigur doriți să resetați stilul la stilurile de temă? Acest lucru va elimina toate stilizările personalizate.",
|
||||
"respect_global_waiting_time": "Folosește timpul de așteptare la nivel de proiect",
|
||||
"respect_global_waiting_time_description": "Acest sondaj respectă timpul de așteptare setat în configurația proiectului. Este afișat doar dacă niciun alt sondaj nu a apărut în acea perioadă.",
|
||||
"response_limit_can_t_be_set_to_0": "Limitul de răspunsuri nu poate fi setat la 0",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "Comutați pe modul multilingv pentru a începe 👉",
|
||||
"targeted": "Ţintite",
|
||||
"ten_points": "10 puncte",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Afișează de cel mult numărul specificat de ori sau până când răspund (oricare dintre acestea survine prima).",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afișează o singură dată, chiar dacă persoana nu răspunde.",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "Sondajul va fi afișat de mai multe ori până când vor răspunde",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Sondajul va fi afișat o singură dată, chiar dacă persoana nu răspunde.",
|
||||
"then": "Apoi",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.",
|
||||
"this_extension_is_already_added": "Această extensie este deja adăugată.",
|
||||
"this_file_type_is_not_supported": "Acest tip de fișier nu este acceptat.",
|
||||
"this_setting_overwrites_your": "Această setare suprascrie",
|
||||
"three_points": "3 puncte",
|
||||
"times": "ori",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
|
||||
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
|
||||
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
|
||||
"until_they_submit_a_response": "Întreabă până când trimit un răspuns",
|
||||
"until_they_submit_a_response": "Până când vor furniza un răspuns",
|
||||
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
|
||||
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
|
||||
"upload": "Încărcați",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "Etichetă superioară",
|
||||
"url_filters": "Filtre URL",
|
||||
"url_not_supported": "URL nesuportat",
|
||||
"use_with_caution": "Folosește cu precauție",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
|
||||
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
||||
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
|
||||
"visibility_and_recontact": "Vizibilitate și recontactare",
|
||||
"visibility_and_recontact_description": "Controlează când poate apărea acest sondaj și cât de des poate reapărea.",
|
||||
"wait": "Așteptați",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Așteptați câteva secunde după declanșare înainte de a afișa sondajul",
|
||||
"waiting_time_across_surveys": "Timp de așteptare la nivel de proiect",
|
||||
"waiting_time_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interacționează acest sondaj cu timpul de așteptare la nivel de proiect.",
|
||||
"waiting_period": "perioada de așteptare",
|
||||
"welcome_message": "Mesaj de bun venit",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Când condițiile se potrivesc, timpul de așteptare va fi ignorat și sondajul va fi afișat.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Fără un filtru, toți utilizatorii pot fi chestionați.",
|
||||
"you_have_not_created_a_segment_yet": "Nu ai creat încă un segment",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Trebuie să aveți două sau mai multe limbi configurate în proiectul dvs. pentru a lucra cu traducerile.",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresă Linie 1",
|
||||
"address_line_2": "Adresă Linie 2",
|
||||
"an_error_occurred_adding_the_tag": "A apărut o eroare la adăugarea etichetei",
|
||||
"an_error_occurred_creating_the_tag": "A apărut o eroare la crearea etichetei",
|
||||
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "Configurare integrare",
|
||||
"share_survey": "Distribuie chestionarul",
|
||||
"show_all_responses_that_match": "Afișează toate răspunsurile care corespund",
|
||||
"show_all_responses_where": "Afișează toate răspunsurile unde...",
|
||||
"starts": "Începuturi",
|
||||
"starts_tooltip": "Număr de ori când sondajul a fost început.",
|
||||
"survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "编辑",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
"enterprise_license": "企业 许可证",
|
||||
"environment_not_found": "环境 未找到",
|
||||
"environment_notice": "你 目前 位于 {environment} 环境。",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
|
||||
"error_rate_limit_title": "速率 限制 超过",
|
||||
"expand_rows": "展开 行",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_projects": "加载项目失败",
|
||||
"finish": "完成",
|
||||
"follow_these": "遵循 这些",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "细分",
|
||||
"select": "选择",
|
||||
"select_all": "选择 全部",
|
||||
"select_filter": "选择过滤器",
|
||||
"select_survey": "选择 调查",
|
||||
"select_teams": "选择 团队",
|
||||
"selected": "已选择",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "用户 ID",
|
||||
"user_not_found": "用户 不存在",
|
||||
"variable": "变量",
|
||||
"variable_ids": "变量 ID",
|
||||
"variables": "变量",
|
||||
"verified_email": "已验证 电子邮件",
|
||||
"video": "视频",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "项目 删除 成功",
|
||||
"project_name_settings_description": "更改 您 的 项目 名称。",
|
||||
"project_name_updated_successfully": "项目 名称 更新 成功",
|
||||
"recontact_waiting_time": "项目范围内的调查等待时间",
|
||||
"recontact_waiting_time": "再联系 等待 时间",
|
||||
"recontact_waiting_time_settings_description": "控制用户可以通过所有应用程序 调查 的 频率。",
|
||||
"this_action_cannot_be_undone": "此 操作 无法 撤消。",
|
||||
"wait_x_days_before_showing_next_survey": "等待 X 天后再显示下一个 调查:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "项目 、 环境 或 组织 未 找到",
|
||||
"segments": {
|
||||
"add_filter_below": "在下方添加 过滤器",
|
||||
"add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "管理 订阅",
|
||||
"monthly": "每月",
|
||||
"monthly_identified_users": "每月 已识别的 用户",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_upgraded_successfully": "计划 升级 成功",
|
||||
"premium_support_with_slas": "优质支持与 SLAs",
|
||||
"remove_branding": "移除 品牌",
|
||||
"startup": "初创企业",
|
||||
"startup_description": "包含免费版的所有功能以及附加功能.",
|
||||
"switch_plan": "切换 计划",
|
||||
"switch_plan_confirmation_text": "你确定要切换到 {plan} 计划吗?你将被收取 {price} {period} 。",
|
||||
"team_access_roles": "团队访问角色",
|
||||
"unable_to_upgrade_plan": "无法升级计划",
|
||||
"unlimited_miu": "无限 MIU",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "允许 多选",
|
||||
"allow_multiple_files": "允许 多 个 文件",
|
||||
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
|
||||
"always_show_survey": "始终 显示 调查",
|
||||
"and_launch_surveys_in_your_website_or_app": "并 在 你 的 网站 或 应用 中 启动 问卷 。",
|
||||
"animation": "动画",
|
||||
"app_survey_description": "在 你的 网络 应用 或 网站 中 嵌入 问卷 收集 反馈 。",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "自 定 义 主 机 名",
|
||||
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "在显示此调查之前,需等待的天数。",
|
||||
"days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。",
|
||||
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
|
||||
"delete_choice": "删除 选择",
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "等于 其中 一个",
|
||||
"error_publishing_survey": "发布调查时发生了错误",
|
||||
"error_saving_changes": "保存 更改 时 出错",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
|
||||
"everyone": "所有 人",
|
||||
"external_urls_paywall_tooltip": "请升级 以自定义 外部 URL 。 网络钓鱼 预防 。",
|
||||
"fallback_missing": "备用 缺失",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "主 机 名",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
|
||||
"if_you_need_more_please": "如果你需要更多,请",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
|
||||
"ignore_global_waiting_time": "忽略项目范围内的等待时间",
|
||||
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "如果 你 真想 要 那个 答案,就 不断 询问 直到 得到。",
|
||||
"ignore_waiting_time_between_surveys": "忽略 调查 之间 的 等待 时间",
|
||||
"image": "图片",
|
||||
"includes_all_of": "包括所有 ",
|
||||
"includes_one_of": "包括一 个",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "可选",
|
||||
"options": "选项",
|
||||
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
|
||||
"overwrite_global_waiting_time": "设置自定义等待时间",
|
||||
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
|
||||
"overwrite_placement": "覆盖 放置",
|
||||
"overwrite_the_global_placement_of_the_survey": "覆盖 全局 调查 放置",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "将 调查 之间 的 等待期 覆盖 为 {days} 天。",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "从我们的库中选择一种 背景 或 上传您自己的。",
|
||||
"picture_idx": "图片 {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "范围",
|
||||
"recall_data": "调用 数据",
|
||||
"recall_information_from": "从 ... 召回信息",
|
||||
"recontact_options_section": "重新联系选项",
|
||||
"recontact_options_section_description": "如果等待时间允许,请选择此调查可以向某人显示的频率。",
|
||||
"recontact_options": "重新 联系 选项",
|
||||
"redirect_thank_you_card": "重定向感谢卡",
|
||||
"redirect_to_url": "重定向到 URL",
|
||||
"remove_description": "移除 描述",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "必需的",
|
||||
"reset_to_theme_styles": "重置 为 主题 风格",
|
||||
"reset_to_theme_styles_main_text": "您 确定 要 将 样式 重置 为 主题 样式吗?这 将 删除 所有 自定义 样式。",
|
||||
"respect_global_waiting_time": "使用项目范围内的等待时间",
|
||||
"respect_global_waiting_time_description": "此调查遵循项目配置中设置的等待时间。仅在该期间未显示其他调查时才会显示。",
|
||||
"response_limit_can_t_be_set_to_0": "不 能 将 响应 限制 设置 为 0",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 ({responseCount})。",
|
||||
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "显示 高级设置",
|
||||
"show_button": "显示 按钮",
|
||||
"show_language_switch": "显示 语言 切换",
|
||||
"show_multiple_times": "显示有限次数",
|
||||
"show_multiple_times": "显示 多次",
|
||||
"show_only_once": "仅 显示 一次",
|
||||
"show_survey_maximum_of": "显示 调查 最大 一次",
|
||||
"show_survey_to_users": "显示 问卷 给 % 的 用户",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "打开多语言以开始 👉",
|
||||
"targeted": "定位",
|
||||
"ten_points": "10 分",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "最多显示指定次数,或直到他们回应(以先到者为准)。",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "仅显示一次,即使他们未回应。",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "调查 将 显示 多次 直到 他们 回复",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "调查 将 显示 一次,即使 你 不 回复。",
|
||||
"then": "然后",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
|
||||
"this_extension_is_already_added": "此扩展已经添加。",
|
||||
"this_file_type_is_not_supported": "此 文件 类型 不 支持。",
|
||||
"this_setting_overwrites_your": "此 设置 覆盖 你的",
|
||||
"three_points": "3 分",
|
||||
"times": "次数",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "根据 属性 或 设备信息 定位 特定 用户组",
|
||||
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
|
||||
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
|
||||
"until_they_submit_a_response": "持续显示直到提交回应",
|
||||
"until_they_submit_a_response": "直到 他们 提交 回复",
|
||||
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
|
||||
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
|
||||
"upload": "上传",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "上限标签",
|
||||
"url_filters": "URL 过滤器",
|
||||
"url_not_supported": "URL 不支持",
|
||||
"use_with_caution": "谨慎 使用",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
|
||||
"verify_email_before_submission": "提交 之前 验证电子邮件",
|
||||
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
|
||||
"visibility_and_recontact": "可见性与重新联系",
|
||||
"visibility_and_recontact_description": "控制此调查何时可以显示以及可以重新显示的频率。",
|
||||
"wait": "等待",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "触发后等待几秒再显示问卷",
|
||||
"waiting_time_across_surveys": "项目范围内的等待时间",
|
||||
"waiting_time_across_surveys_description": "为防止调查疲劳,请选择此调查如何与项目范围内的等待时间交互。",
|
||||
"waiting_period": "等待期",
|
||||
"welcome_message": "欢迎 信息",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "当 条件 匹配 时,等待 时间 将 被 忽略 并 显示 调查。",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "没有 过滤器 时 ,所有 用户 都可以 被 调查 。",
|
||||
"you_have_not_created_a_segment_yet": "您 还没有 创建 段落",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "您 需要在您的项目中设置两种或更多语言才能进行翻译。",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "地址 第1行",
|
||||
"address_line_2": "地址 第2行",
|
||||
"an_error_occurred_adding_the_tag": "添加标签时发生错误",
|
||||
"an_error_occurred_creating_the_tag": "创建标签时发生错误",
|
||||
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
|
||||
"browser": "浏览器",
|
||||
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "设置 集成",
|
||||
"share_survey": "分享 问卷调查",
|
||||
"show_all_responses_that_match": "显示所有匹配的响应",
|
||||
"show_all_responses_where": "显示所有的响应,条件为...",
|
||||
"starts": "开始",
|
||||
"starts_tooltip": "调查 被 开始 的 次数",
|
||||
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "編輯",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
||||
"error_rate_limit_title": "限流超過",
|
||||
"expand_rows": "展開列",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_projects": "無法載入專案",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "區隔",
|
||||
"select": "選擇",
|
||||
"select_all": "全選",
|
||||
"select_filter": "選擇篩選器",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_teams": "選擇 團隊",
|
||||
"selected": "已選取",
|
||||
@@ -425,7 +421,6 @@
|
||||
"user_id": "使用者 ID",
|
||||
"user_not_found": "找不到使用者",
|
||||
"variable": "變數",
|
||||
"variable_ids": "變數 ID",
|
||||
"variables": "變數",
|
||||
"verified_email": "已驗證的電子郵件",
|
||||
"video": "影片",
|
||||
@@ -801,7 +796,7 @@
|
||||
"project_deleted_successfully": "專案已成功刪除",
|
||||
"project_name_settings_description": "變更您的專案名稱。",
|
||||
"project_name_updated_successfully": "專案名稱已成功更新",
|
||||
"recontact_waiting_time": "專案範圍內的問卷等待時間",
|
||||
"recontact_waiting_time": "重新聯絡等待時間",
|
||||
"recontact_waiting_time_settings_description": "控制使用者在所有應用程式問卷中可以被調查的頻率。",
|
||||
"this_action_cannot_be_undone": "此操作無法復原。",
|
||||
"wait_x_days_before_showing_next_survey": "在顯示下一個問卷之前等待 X 天:",
|
||||
@@ -891,6 +886,7 @@
|
||||
"team_settings_description": "查看哪些團隊可以存取此專案。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "找不到專案、環境或組織",
|
||||
"segments": {
|
||||
"add_filter_below": "在下方新增篩選器",
|
||||
"add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用",
|
||||
@@ -987,12 +983,15 @@
|
||||
"manage_subscription": "管理訂閱",
|
||||
"monthly": "每月",
|
||||
"monthly_identified_users": "每月識別使用者",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_upgraded_successfully": "方案已成功升級",
|
||||
"premium_support_with_slas": "具有 SLA 的頂級支援",
|
||||
"remove_branding": "移除品牌",
|
||||
"startup": "啟動版",
|
||||
"startup_description": "免費方案中的所有功能以及其他功能。",
|
||||
"switch_plan": "切換方案",
|
||||
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
|
||||
"team_access_roles": "團隊存取角色",
|
||||
"unable_to_upgrade_plan": "無法升級方案",
|
||||
"unlimited_miu": "無限 MIU",
|
||||
@@ -1227,6 +1226,7 @@
|
||||
"allow_multi_select": "允許多重選取",
|
||||
"allow_multiple_files": "允許上傳多個檔案",
|
||||
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
|
||||
"always_show_survey": "始終顯示問卷",
|
||||
"and_launch_surveys_in_your_website_or_app": "並在您的網站或應用程式中啟動問卷。",
|
||||
"animation": "動畫",
|
||||
"app_survey_description": "將問卷嵌入您的 Web 應用程式或網站中以收集回應。",
|
||||
@@ -1309,7 +1309,8 @@
|
||||
"custom_hostname": "自訂主機名稱",
|
||||
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "在顯示此問卷之前,需等待其他問卷顯示後的天數。",
|
||||
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
|
||||
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
|
||||
"delete_choice": "刪除選項",
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
@@ -1337,7 +1338,7 @@
|
||||
"equals_one_of": "等於其中之一",
|
||||
"error_publishing_survey": "發布問卷時發生錯誤。",
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
|
||||
"everyone": "所有人",
|
||||
"external_urls_paywall_tooltip": "請升級以自訂 external URL 。 Phishing 預防。",
|
||||
"fallback_missing": "遺失的回退",
|
||||
@@ -1408,9 +1409,8 @@
|
||||
"hostname": "主機名稱",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
|
||||
"ignore_global_waiting_time": "忽略專案範圍內的等待時間",
|
||||
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。",
|
||||
"ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間",
|
||||
"image": "圖片",
|
||||
"includes_all_of": "包含全部",
|
||||
"includes_one_of": "包含其中之一",
|
||||
@@ -1477,10 +1477,9 @@
|
||||
"optional": "選填",
|
||||
"options": "選項",
|
||||
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
|
||||
"overwrite_global_waiting_time": "設定自訂等待時間",
|
||||
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
|
||||
"overwrite_placement": "覆寫位置",
|
||||
"overwrite_the_global_placement_of_the_survey": "覆寫問卷的整體位置",
|
||||
"overwrites_waiting_period_between_surveys_to_x_days": "將問卷之間的等待時間覆寫為 '{'days'}' 天。",
|
||||
"pick_a_background_from_our_library_or_upload_your_own": "從我們的媒體庫中選取背景或上傳您自己的背景。",
|
||||
"picture_idx": "圖片 '{'idx'}'",
|
||||
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
|
||||
@@ -1539,8 +1538,7 @@
|
||||
"range": "範圍",
|
||||
"recall_data": "回憶數據",
|
||||
"recall_information_from": "從 ... 獲取 信息",
|
||||
"recontact_options_section": "重新聯絡選項",
|
||||
"recontact_options_section_description": "如果等待時間允許,選擇此問卷可以向同一人顯示的頻率。",
|
||||
"recontact_options": "重新聯絡選項",
|
||||
"redirect_thank_you_card": "重新導向感謝卡片",
|
||||
"redirect_to_url": "重新導向至網址",
|
||||
"remove_description": "移除描述",
|
||||
@@ -1549,8 +1547,6 @@
|
||||
"required": "必填",
|
||||
"reset_to_theme_styles": "重設為主題樣式",
|
||||
"reset_to_theme_styles_main_text": "您確定要將樣式重設為主題樣式嗎?這將移除所有自訂樣式。",
|
||||
"respect_global_waiting_time": "使用專案範圍內的等待時間",
|
||||
"respect_global_waiting_time_description": "此問卷遵循專案設定的等待時間。僅在該期間內未顯示其他問卷時才會顯示。",
|
||||
"response_limit_can_t_be_set_to_0": "回應限制不能設定為 0",
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。",
|
||||
"response_limits_redirections_and_more": "回應限制、重新導向等。",
|
||||
@@ -1575,7 +1571,7 @@
|
||||
"show_advanced_settings": "顯示進階設定",
|
||||
"show_button": "顯示按鈕",
|
||||
"show_language_switch": "顯示語言切換",
|
||||
"show_multiple_times": "顯示有限次數",
|
||||
"show_multiple_times": "多次顯示",
|
||||
"show_only_once": "僅顯示一次",
|
||||
"show_survey_maximum_of": "最多顯示問卷",
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
@@ -1605,12 +1601,13 @@
|
||||
"switch_multi_lanugage_on_to_get_started": "開啟多語言以開始使用 👉",
|
||||
"targeted": "目標",
|
||||
"ten_points": "10 分",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "最多顯示指定次數,或直到他們回應(以先達成者為準)。",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "僅顯示一次,即使他們未回應。",
|
||||
"the_survey_will_be_shown_multiple_times_until_they_respond": "將多次顯示問卷,直到他們回應",
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "即使使用者沒有回應,也只會顯示一次問卷。",
|
||||
"then": "然後",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
|
||||
"this_extension_is_already_added": "已新增此擴充功能。",
|
||||
"this_file_type_is_not_supported": "不支援此檔案類型。",
|
||||
"this_setting_overwrites_your": "此設定會覆寫您的",
|
||||
"three_points": "3 分",
|
||||
"times": "次",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
|
||||
@@ -1621,7 +1618,7 @@
|
||||
"unlock_targeting_description": "根據屬性或裝置資訊鎖定特定使用者群組",
|
||||
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
|
||||
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
|
||||
"until_they_submit_a_response": "持續詢問直到提交回應",
|
||||
"until_they_submit_a_response": "直到他們提交回應",
|
||||
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
|
||||
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
|
||||
"upload": "上傳",
|
||||
@@ -1629,6 +1626,7 @@
|
||||
"upper_label": "上標籤",
|
||||
"url_filters": "網址篩選器",
|
||||
"url_not_supported": "不支援網址",
|
||||
"use_with_caution": "謹慎使用",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
|
||||
@@ -1638,13 +1636,11 @@
|
||||
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
|
||||
"verify_email_before_submission": "提交前驗證電子郵件",
|
||||
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
|
||||
"visibility_and_recontact": "可見性與重新聯絡",
|
||||
"visibility_and_recontact_description": "控制此問卷何時可以顯示以及可以重新顯示的頻率。",
|
||||
"wait": "等待",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷",
|
||||
"waiting_time_across_surveys": "專案範圍內的等待時間",
|
||||
"waiting_time_across_surveys_description": "為避免問卷疲勞,選擇此問卷如何與專案範圍內的等待時間互動。",
|
||||
"waiting_period": "等待時間",
|
||||
"welcome_message": "歡迎訊息",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "當條件符合時,等待時間將被忽略且顯示問卷。",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "如果沒有篩選器,則可以調查您的所有使用者。",
|
||||
"you_have_not_created_a_segment_yet": "您尚未建立區隔",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "您需要在您的專案中設定兩個或更多語言,才能使用翻譯。",
|
||||
@@ -1668,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "地址 1",
|
||||
"address_line_2": "地址 2",
|
||||
"an_error_occurred_adding_the_tag": "新增標籤時發生錯誤",
|
||||
"an_error_occurred_creating_the_tag": "建立標籤時發生錯誤",
|
||||
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
|
||||
"browser": "瀏覽器",
|
||||
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
|
||||
@@ -1886,6 +1880,7 @@
|
||||
"setup_integrations": "設定整合",
|
||||
"share_survey": "分享問卷",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
"show_all_responses_where": "顯示所有回應,其中...",
|
||||
"starts": "開始次數",
|
||||
"starts_tooltip": "問卷已開始的次數。",
|
||||
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { AlertCircleIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
@@ -40,19 +39,14 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [tagsState, setTagsState] = useState(tags);
|
||||
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
|
||||
const [isLoadingTagOperation, setIsLoadingTagOperation] = useState(false);
|
||||
|
||||
const onDelete = async (tagId: string) => {
|
||||
setIsLoadingTagOperation(true);
|
||||
const deleteTagResponse = await deleteTagOnResponseAction({ responseId, tagId });
|
||||
if (deleteTagResponse?.data) {
|
||||
try {
|
||||
await deleteTagOnResponseAction({ responseId, tagId });
|
||||
updateFetchedResponses();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
|
||||
logger.error({ errorMessage }, "Error deleting tag");
|
||||
} catch (e) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_deleting_the_tag"));
|
||||
}
|
||||
setIsLoadingTagOperation(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,70 +60,72 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
}, [tagIdToHighlight]);
|
||||
|
||||
const handleCreateTag = async (tagName: string) => {
|
||||
setIsLoadingTagOperation(true);
|
||||
const newTagResponse = await createTagAction({ environmentId, tagName });
|
||||
setOpen(false);
|
||||
|
||||
if (!newTagResponse?.data) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
|
||||
return;
|
||||
}
|
||||
const createTagResponse = await createTagAction({
|
||||
environmentId,
|
||||
tagName: tagName?.trim() ?? "",
|
||||
});
|
||||
|
||||
if (!newTagResponse.data.ok) {
|
||||
const errorMessage = newTagResponse.data.error;
|
||||
if (errorMessage?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <SettingsIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
if (createTagResponse?.data?.ok) {
|
||||
const tag = createTagResponse.data.data;
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
},
|
||||
]);
|
||||
|
||||
const createTagToResponseActionResponse = await createTagToResponseAction({
|
||||
responseId,
|
||||
tagId: tag.id,
|
||||
});
|
||||
|
||||
if (createTagToResponseActionResponse?.data) {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
} else {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
|
||||
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newTag = newTagResponse.data.data;
|
||||
const createTagToResponseResponse = await createTagToResponseAction({ responseId, tagId: newTag.id });
|
||||
if (createTagToResponseResponse?.data) {
|
||||
setTagsState((prevTags) => [...prevTags, { tagId: newTag.id, tagName: newTag.name }]);
|
||||
setTagIdToHighlight(newTag.id);
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createTagToResponseResponse);
|
||||
logger.error({ errorMessage });
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsLoadingTagOperation(false);
|
||||
};
|
||||
if (
|
||||
createTagResponse?.data?.ok === false &&
|
||||
createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS
|
||||
) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
|
||||
const handleAddTag = async (tagId: string) => {
|
||||
setIsLoadingTagOperation(true);
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId,
|
||||
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
await createTagToResponseAction({ responseId, tagId });
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_adding_the_tag"));
|
||||
console.error("Error adding tag:", error);
|
||||
// Revert the tag if the action failed
|
||||
setTagsState((prevTags) => prevTags.filter((tag) => tag.tagId !== tagId));
|
||||
} finally {
|
||||
setIsLoadingTagOperation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = getFormattedErrorMessage(createTagResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
|
||||
duration: 2000,
|
||||
});
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-3">
|
||||
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer p-0"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environmentId}/project/tags`);
|
||||
}}>
|
||||
<SettingsIcon className="h-5 w-5 text-slate-500 hover:text-slate-600" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tagsState?.map((tag) => (
|
||||
<Tag
|
||||
@@ -140,35 +136,37 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
tags={tagsState}
|
||||
setTagsState={setTagsState}
|
||||
highlight={tagIdToHighlight === tag.tagId}
|
||||
allowDelete={!isReadOnly && !isLoadingTagOperation}
|
||||
allowDelete={!isReadOnly}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isReadOnly && (
|
||||
<TagsCombobox
|
||||
open={open && !isLoadingTagOperation}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
|
||||
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
|
||||
createTag={handleCreateTag}
|
||||
addTag={handleAddTag}
|
||||
addTag={(tagId) => {
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId,
|
||||
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
createTagToResponseAction({ responseId, tagId }).then(() => {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environmentId}/project/tags`);
|
||||
}}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -41,58 +42,46 @@ export const SingleResponseCard = ({
|
||||
setSelectedResponseId,
|
||||
locale,
|
||||
}: SingleResponseCardProps) => {
|
||||
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
|
||||
const hasQuotas = (response.quotas && response.quotas.length > 0) ?? false;
|
||||
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
|
||||
const { t } = useTranslation();
|
||||
const environmentId = survey.environmentId;
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const skippedQuestions: string[][] = useMemo(() => {
|
||||
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
|
||||
if (temp.length > 0) {
|
||||
if (shouldReverse) temp.reverse();
|
||||
result.push([...temp]);
|
||||
temp.length = 0;
|
||||
let skippedQuestions: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
if (response.finished) {
|
||||
survey.questions.forEach((question) => {
|
||||
if (!isValidValue(response.data[question.id])) {
|
||||
temp.push(question.id);
|
||||
} else if (temp.length > 0) {
|
||||
skippedQuestions.push([...temp]);
|
||||
temp = [];
|
||||
}
|
||||
};
|
||||
|
||||
const processFinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
if (isValidValue(response.data[question.id])) {
|
||||
flushTemp(temp, result);
|
||||
} else {
|
||||
temp.push(question.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for (let index = survey.questions.length - 1; index >= 0; index--) {
|
||||
const question = survey.questions[index];
|
||||
if (
|
||||
!response.data[question.id] &&
|
||||
(skippedQuestions.length === 0 ||
|
||||
(skippedQuestions.length > 0 && !isValidValue(response.data[question.id])))
|
||||
) {
|
||||
temp.push(question.id);
|
||||
} else if (temp.length > 0) {
|
||||
temp.reverse();
|
||||
skippedQuestions.push([...temp]);
|
||||
temp = [];
|
||||
}
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const processUnfinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (let index = survey.questions.length - 1; index >= 0; index--) {
|
||||
const question = survey.questions[index];
|
||||
const hasNoData = !response.data[question.id];
|
||||
const shouldSkip = hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
|
||||
|
||||
if (shouldSkip) {
|
||||
temp.push(question.id);
|
||||
} else {
|
||||
flushTemp(temp, result, true);
|
||||
}
|
||||
}
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return response.finished ? processFinishedResponse() : processUnfinishedResponse();
|
||||
}, [response.id, response.finished, response.data, survey.questions]);
|
||||
}
|
||||
}
|
||||
// Handle the case where the last entries are empty
|
||||
if (temp.length > 0) {
|
||||
skippedQuestions.push(temp);
|
||||
}
|
||||
|
||||
const handleDeleteResponse = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -102,6 +91,7 @@ export const SingleResponseCard = ({
|
||||
}
|
||||
await deleteResponseAction({ responseId: response.id, decrementQuotas });
|
||||
updateResponseList?.([response.id]);
|
||||
router.refresh();
|
||||
if (setSelectedResponseId) setSelectedResponseId(null);
|
||||
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
|
||||
setDeleteDialogOpen(false);
|
||||
|
||||
@@ -1,67 +1,43 @@
|
||||
import Stripe from "stripe";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
if (!checkoutSession.metadata?.organizationId)
|
||||
if (!checkoutSession.metadata || !checkoutSession.metadata.organizationId)
|
||||
throw new ResourceNotFoundError("No organizationId found in checkout session", checkoutSession.id);
|
||||
|
||||
const organization = await getOrganization(checkoutSession.metadata.organizationId);
|
||||
const stripeSubscriptionObject = await stripe.subscriptions.retrieve(
|
||||
checkoutSession.subscription as string
|
||||
);
|
||||
const { customer: stripeCustomer } = (await stripe.checkout.sessions.retrieve(checkoutSession.id, {
|
||||
expand: ["customer"],
|
||||
})) as { customer: Stripe.Customer };
|
||||
|
||||
const organization = await getOrganization(checkoutSession.metadata!.organizationId);
|
||||
if (!organization)
|
||||
throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId);
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string, {
|
||||
expand: ["items.data.price"],
|
||||
});
|
||||
|
||||
let period: "monthly" | "yearly" = "monthly";
|
||||
|
||||
if (subscription.items?.data && subscription.items.data.length > 0) {
|
||||
const firstItem = subscription.items.data[0];
|
||||
const interval = firstItem.price?.recurring?.interval;
|
||||
period = interval === "year" ? "yearly" : "monthly";
|
||||
}
|
||||
|
||||
await updateOrganization(checkoutSession.metadata.organizationId, {
|
||||
billing: {
|
||||
...organization.billing,
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
plan: PROJECT_FEATURE_KEYS.STARTUP,
|
||||
period,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.STARTUP.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.STARTUP.RESPONSES,
|
||||
miu: BILLING_LIMITS.STARTUP.MIU,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
await stripe.subscriptions.update(stripeSubscriptionObject.id, {
|
||||
metadata: {
|
||||
organizationId: organization.id,
|
||||
responses: checkoutSession.metadata.responses,
|
||||
miu: checkoutSession.metadata.miu,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
organizationId: checkoutSession.metadata.organizationId,
|
||||
plan: PROJECT_FEATURE_KEYS.STARTUP,
|
||||
period,
|
||||
checkoutSessionId: checkoutSession.id,
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
name: organization.name,
|
||||
metadata: { organizationId: organization.id },
|
||||
invoice_settings: {
|
||||
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
|
||||
},
|
||||
"Subscription activated"
|
||||
);
|
||||
|
||||
const stripeCustomer = await stripe.customers.retrieve(checkoutSession.customer as string);
|
||||
if (stripeCustomer && !stripeCustomer.deleted) {
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
name: organization.name,
|
||||
metadata: { organizationId: organization.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -52,12 +52,13 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
|
||||
t("environments.settings.billing.7500_contacts"),
|
||||
t("environments.settings.billing.3_projects"),
|
||||
t("environments.settings.billing.remove_branding"),
|
||||
t("environments.settings.billing.email_follow_ups"),
|
||||
t("environments.settings.billing.attribute_based_targeting"),
|
||||
],
|
||||
};
|
||||
|
||||
const customPlan: TPricingPlan = {
|
||||
id: "custom",
|
||||
id: "enterprise",
|
||||
name: t("environments.settings.billing.custom"),
|
||||
featured: false,
|
||||
CTA: t("common.request_pricing"),
|
||||
@@ -68,7 +69,6 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
|
||||
},
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_startup"),
|
||||
t("environments.settings.billing.email_follow_ups"),
|
||||
t("environments.settings.billing.custom_response_limit"),
|
||||
t("environments.settings.billing.custom_contacts_limit"),
|
||||
t("environments.settings.billing.custom_project_limit"),
|
||||
|
||||
@@ -16,17 +16,22 @@ export const createSubscription = async (
|
||||
try {
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
let isNewOrganization =
|
||||
!organization.billing.stripeCustomerId ||
|
||||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
|
||||
|
||||
const priceObject = (
|
||||
await stripe.prices.list({
|
||||
lookup_keys: [priceLookupKey],
|
||||
expand: ["data.product"],
|
||||
})
|
||||
).data[0];
|
||||
|
||||
if (!priceObject) throw new Error("Price not found");
|
||||
const responses = parseInt((priceObject.product as Stripe.Product).metadata.responses);
|
||||
const miu = parseInt((priceObject.product as Stripe.Product).metadata.miu);
|
||||
|
||||
// Always create a checkout session - let Stripe handle existing customers
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||
mode: "subscription",
|
||||
line_items: [
|
||||
{
|
||||
@@ -36,20 +41,63 @@ export const createSubscription = async (
|
||||
],
|
||||
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
|
||||
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
|
||||
customer: organization.billing.stripeCustomerId ?? undefined,
|
||||
allow_promotion_codes: true,
|
||||
subscription_data: {
|
||||
metadata: { organizationId },
|
||||
trial_period_days: 15,
|
||||
trial_period_days: 30,
|
||||
},
|
||||
metadata: { organizationId },
|
||||
metadata: { organizationId, responses, miu },
|
||||
billing_address_collection: "required",
|
||||
automatic_tax: { enabled: true },
|
||||
tax_id_collection: { enabled: true },
|
||||
payment_method_data: { allow_redisplay: "always" },
|
||||
...(!isNewOrganization && {
|
||||
customer: organization.billing.stripeCustomerId ?? undefined,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// if the organization has never purchased a plan then we just create a new session and store their stripe customer id
|
||||
if (isNewOrganization) {
|
||||
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
}
|
||||
|
||||
const existingSubscription = await stripe.subscriptions.list({
|
||||
customer: organization.billing.stripeCustomerId as string,
|
||||
});
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
if (existingSubscription.data?.length > 0) {
|
||||
const existingSubscriptionItem = existingSubscription.data[0].items.data[0];
|
||||
|
||||
await stripe.subscriptions.update(existingSubscription.data[0].id, {
|
||||
items: [
|
||||
{
|
||||
id: existingSubscriptionItem.id,
|
||||
deleted: true,
|
||||
},
|
||||
{
|
||||
price: priceObject.id,
|
||||
},
|
||||
],
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
} else {
|
||||
// Create a new checkout again if there is no active subscription
|
||||
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: "Congrats! Added to your existing subscription!",
|
||||
newPlan: false,
|
||||
url: "",
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(err, "Error creating subscription");
|
||||
return {
|
||||
|
||||
@@ -1,68 +1,32 @@
|
||||
import Stripe from "stripe";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
|
||||
export const handleInvoiceFinalized = async (event: Stripe.Event) => {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const subscriptionId = invoice.subscription as string;
|
||||
if (!subscriptionId) {
|
||||
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
|
||||
return { status: 400, message: "No subscription ID found in invoice" };
|
||||
const stripeSubscriptionDetails = invoice.subscription_details;
|
||||
const organizationId = stripeSubscriptionDetails?.metadata?.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new Error("No organizationId found in subscription");
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const organizationId = subscription.metadata?.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
logger.warn(
|
||||
{
|
||||
subscriptionId,
|
||||
invoiceId: invoice.id,
|
||||
},
|
||||
"No organizationId found in subscription metadata"
|
||||
);
|
||||
return { status: 400, message: "No organizationId found in subscription" };
|
||||
}
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const periodStartTimestamp = invoice.lines.data[0]?.period?.start;
|
||||
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
|
||||
|
||||
await updateOrganization(organizationId, {
|
||||
billing: {
|
||||
...organization.billing,
|
||||
periodStart,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
organizationId,
|
||||
periodStart,
|
||||
invoiceId: invoice.id,
|
||||
},
|
||||
"Billing period updated successfully"
|
||||
);
|
||||
|
||||
return { status: 200, message: "Billing period updated successfully" };
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating billing period", {
|
||||
invoiceId: invoice.id,
|
||||
subscriptionId,
|
||||
});
|
||||
return { status: 500, message: "Error updating billing period" };
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const periodStartTimestamp = invoice.lines.data[0].period.start;
|
||||
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
|
||||
|
||||
await updateOrganization(organizationId, {
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
stripeCustomerId: invoice.customer as string,
|
||||
periodStart,
|
||||
},
|
||||
});
|
||||
|
||||
return { status: 200, message: "success" };
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
|
||||
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized";
|
||||
import { handleSubscriptionCreatedOrUpdated } from "@/modules/ee/billing/api/lib/subscription-created-or-updated";
|
||||
import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
@@ -19,7 +20,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
if (err instanceof Error) logger.error(err, "Error in Stripe webhook handler");
|
||||
if (err! instanceof Error) logger.error(err, "Error in Stripe webhook handler");
|
||||
return { status: 400, message: `Webhook Error: ${errorMessage}` };
|
||||
}
|
||||
|
||||
@@ -27,6 +28,11 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
await handleCheckoutSessionCompleted(event);
|
||||
} else if (event.type === "invoice.finalized") {
|
||||
await handleInvoiceFinalized(event);
|
||||
} else if (
|
||||
event.type === "customer.subscription.created" ||
|
||||
event.type === "customer.subscription.updated"
|
||||
) {
|
||||
await handleSubscriptionCreatedOrUpdated(event);
|
||||
} else if (event.type === "customer.subscription.deleted") {
|
||||
await handleSubscriptionDeleted(event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Stripe from "stripe";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TOrganizationBillingPeriod,
|
||||
TOrganizationBillingPlan,
|
||||
ZOrganizationBillingPeriod,
|
||||
ZOrganizationBillingPlan,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) => {
|
||||
const stripeSubscriptionObject = event.data.object as Stripe.Subscription;
|
||||
const organizationId = stripeSubscriptionObject.metadata.organizationId;
|
||||
|
||||
if (
|
||||
!["active", "trialing"].includes(stripeSubscriptionObject.status) ||
|
||||
stripeSubscriptionObject.cancel_at_period_end
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!organizationId) {
|
||||
logger.error({ event, organizationId }, "No organizationId found in subscription");
|
||||
return { status: 400, message: "skipping, no organizationId found" };
|
||||
}
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
|
||||
const subscriptionPrice = stripeSubscriptionObject.items.data[0].price;
|
||||
const product = await stripe.products.retrieve(subscriptionPrice.product as string);
|
||||
|
||||
if (!product)
|
||||
throw new ResourceNotFoundError(
|
||||
"Product not found",
|
||||
stripeSubscriptionObject.items.data[0].price.product.toString()
|
||||
);
|
||||
|
||||
let period: TOrganizationBillingPeriod = "monthly";
|
||||
const periodParsed = ZOrganizationBillingPeriod.safeParse(subscriptionPrice.metadata.period);
|
||||
if (periodParsed.success) {
|
||||
period = periodParsed.data;
|
||||
}
|
||||
|
||||
let updatedBillingPlan: TOrganizationBillingPlan = organization.billing.plan;
|
||||
|
||||
let responses: number | null = null;
|
||||
let miu: number | null = null;
|
||||
let projects: number | null = null;
|
||||
|
||||
if (product.metadata.responses === "unlimited") {
|
||||
responses = null;
|
||||
} else if (parseInt(product.metadata.responses) > 0) {
|
||||
responses = parseInt(product.metadata.responses);
|
||||
} else {
|
||||
logger.error({ responses: product.metadata.responses }, "Invalid responses metadata in product");
|
||||
throw new Error("Invalid responses metadata in product");
|
||||
}
|
||||
|
||||
if (product.metadata.miu === "unlimited") {
|
||||
miu = null;
|
||||
} else if (parseInt(product.metadata.miu) > 0) {
|
||||
miu = parseInt(product.metadata.miu);
|
||||
} else {
|
||||
logger.error({ miu: product.metadata.miu }, "Invalid miu metadata in product");
|
||||
throw new Error("Invalid miu metadata in product");
|
||||
}
|
||||
|
||||
if (product.metadata.projects === "unlimited") {
|
||||
projects = null;
|
||||
} else if (parseInt(product.metadata.projects) > 0) {
|
||||
projects = parseInt(product.metadata.projects);
|
||||
} else {
|
||||
logger.error({ projects: product.metadata.projects }, "Invalid projects metadata in product");
|
||||
throw new Error("Invalid projects metadata in product");
|
||||
}
|
||||
|
||||
const plan = ZOrganizationBillingPlan.parse(product.metadata.plan);
|
||||
|
||||
switch (plan) {
|
||||
case PROJECT_FEATURE_KEYS.FREE:
|
||||
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
|
||||
break;
|
||||
|
||||
case PROJECT_FEATURE_KEYS.STARTUP:
|
||||
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
|
||||
break;
|
||||
|
||||
case PROJECT_FEATURE_KEYS.ENTERPRISE:
|
||||
updatedBillingPlan = PROJECT_FEATURE_KEYS.ENTERPRISE;
|
||||
break;
|
||||
}
|
||||
|
||||
await updateOrganization(organizationId, {
|
||||
billing: {
|
||||
...organization.billing,
|
||||
stripeCustomerId: stripeSubscriptionObject.customer as string,
|
||||
plan: updatedBillingPlan,
|
||||
period,
|
||||
limits: {
|
||||
projects,
|
||||
monthly: {
|
||||
responses,
|
||||
miu,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await stripe.customers.update(stripeSubscriptionObject.customer as string, {
|
||||
name: organization.name,
|
||||
metadata: { organizationId: organization.id },
|
||||
invoice_settings: {
|
||||
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -30,12 +30,4 @@ export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
|
||||
period: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
organizationId,
|
||||
subscriptionId: stripeSubscriptionObject.id,
|
||||
},
|
||||
"Subscription cancelled - downgraded to FREE plan"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ interface PricingCardProps {
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
CUSTOM: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,21 +33,17 @@ export const PricingCard = ({
|
||||
}: PricingCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
|
||||
const displayPrice = (() => {
|
||||
if (plan.id === projectFeatureKeys.CUSTOM) {
|
||||
return plan.price.monthly;
|
||||
}
|
||||
return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
})();
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
|
||||
const isCurrentPlan = useMemo(() => {
|
||||
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) {
|
||||
if (
|
||||
organization.billing.plan === projectFeatureKeys.ENTERPRISE &&
|
||||
plan.id === projectFeatureKeys.ENTERPRISE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -57,7 +53,7 @@ export const PricingCard = ({
|
||||
organization.billing.plan,
|
||||
plan.id,
|
||||
planPeriod,
|
||||
projectFeatureKeys.CUSTOM,
|
||||
projectFeatureKeys.ENTERPRISE,
|
||||
projectFeatureKeys.FREE,
|
||||
]);
|
||||
|
||||
@@ -66,7 +62,7 @@ export const PricingCard = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.id === projectFeatureKeys.CUSTOM) {
|
||||
if (plan.id === projectFeatureKeys.ENTERPRISE) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -101,7 +97,7 @@ export const PricingCard = ({
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setContactModalOpen(true);
|
||||
setUpgradeModalOpen(true);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{t("environments.settings.billing.switch_plan")}
|
||||
@@ -119,7 +115,7 @@ export const PricingCard = ({
|
||||
plan.featured,
|
||||
plan.href,
|
||||
plan.id,
|
||||
projectFeatureKeys.CUSTOM,
|
||||
projectFeatureKeys.ENTERPRISE,
|
||||
projectFeatureKeys.FREE,
|
||||
projectFeatureKeys.STARTUP,
|
||||
t,
|
||||
@@ -155,9 +151,13 @@ export const PricingCard = ({
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-4xl font-bold tracking-tight"
|
||||
)}>
|
||||
{displayPrice}
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE
|
||||
? planPeriod === "monthly"
|
||||
? plan.price.monthly
|
||||
: plan.price.yearly
|
||||
: plan.price.monthly}
|
||||
</p>
|
||||
{plan.id !== projectFeatureKeys.CUSTOM && (
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE && (
|
||||
<div className="text-sm leading-5">
|
||||
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
|
||||
/ {planPeriod === "monthly" ? "Month" : "Year"}
|
||||
@@ -203,13 +203,28 @@ export const PricingCard = ({
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
title="Please reach out to us"
|
||||
open={contactModalOpen}
|
||||
setOpen={setContactModalOpen}
|
||||
onConfirm={() => setContactModalOpen(false)}
|
||||
buttonText="Close"
|
||||
title={t("environments.settings.billing.switch_plan")}
|
||||
buttonText={t("common.confirm")}
|
||||
onConfirm={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
setUpgradeModalOpen(false);
|
||||
}}
|
||||
open={upgradeModalOpen}
|
||||
setOpen={setUpgradeModalOpen}
|
||||
body={t("environments.settings.billing.switch_plan_confirmation_text", {
|
||||
plan: plan.name,
|
||||
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
|
||||
period:
|
||||
planPeriod === "monthly"
|
||||
? t("environments.settings.billing.per_month")
|
||||
: t("environments.settings.billing.per_year"),
|
||||
})}
|
||||
buttonVariant="default"
|
||||
body="To switch your billing rhythm, please reach out to hola@formbricks.com"
|
||||
buttonLoading={loading}
|
||||
closeOnOutsideClick={false}
|
||||
hideCloseButton
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ interface PricingTableProps {
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
CUSTOM: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
hasBillingRights: boolean;
|
||||
}
|
||||
@@ -127,11 +127,11 @@ export const PricingTable = ({
|
||||
};
|
||||
|
||||
const responsesUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null;
|
||||
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.responses === null;
|
||||
const peopleUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null;
|
||||
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.miu === null;
|
||||
const projectsUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.projects === null;
|
||||
organization.billing.plan === "enterprise" && organization.billing.limits.projects === null;
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const UploadContactsAttributeCombobox = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="border-0">
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{keys.map((tag) => {
|
||||
return (
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("License Utils", () => {
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("License Utils", () => {
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -154,17 +154,27 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, accessControl enabled and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, accessControl: true },
|
||||
});
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, accessControl enabled but plan is not CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, accessControl enabled and plan is ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, accessControl: true },
|
||||
});
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, accessControl enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
@@ -174,16 +184,6 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, accessControl: true },
|
||||
});
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getAccessControlPermission(mockOrganization.billing.plan);
|
||||
@@ -211,7 +211,7 @@ describe("License Utils", () => {
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -243,17 +243,27 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, multiLanguageSurveys: true },
|
||||
});
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, multiLanguageSurveys enabled but plan is not CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, multiLanguageSurveys: true },
|
||||
});
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, multiLanguageSurveys enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
@@ -263,16 +273,6 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, multiLanguageSurveys: true },
|
||||
});
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
|
||||
@@ -420,17 +420,17 @@ describe("License Utils", () => {
|
||||
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true; // reset for other tests
|
||||
});
|
||||
|
||||
test("should return true if license active, feature enabled, and plan is CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, feature enabled, and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, spamProtection: true },
|
||||
});
|
||||
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, feature enabled, but plan is not CUSTOM (cloud)", async () => {
|
||||
test("should return false if license active, feature enabled, but plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
|
||||
@@ -111,7 +111,9 @@ export const getIsSpamProtectionEnabled = async (
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return (
|
||||
license.active && !!license.features?.spamProtection && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM
|
||||
license.active &&
|
||||
!!license.features?.spamProtection &&
|
||||
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +122,11 @@ export const getIsSpamProtectionEnabled = async (
|
||||
|
||||
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
|
||||
const license = await getEnterpriseLicense();
|
||||
if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
|
||||
if (IS_FORMBRICKS_CLOUD)
|
||||
return (
|
||||
license.active &&
|
||||
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return license.active;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { HandshakeIcon, Undo2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyEndings } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import {
|
||||
Select,
|
||||
@@ -42,7 +41,7 @@ export const EndingCardSelector = ({ endings, value, onChange }: EndingCardSelec
|
||||
{/* Custom endings */}
|
||||
{endingCards.map((ending) => (
|
||||
<SelectItem key={ending.id} value={ending.id}>
|
||||
{getTextContent(getLocalizedValue(ending.headline, "default"))}
|
||||
{getLocalizedValue(ending.headline, "default")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Pull in the mocked implementations to configure them in tests
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
@@ -13,24 +12,12 @@ import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
// Pull in the mocked implementations to configure them in tests
|
||||
import {
|
||||
environmentIdLayoutChecks,
|
||||
getEnvironmentAuth,
|
||||
getEnvironmentLayoutData,
|
||||
getEnvironmentWithRelations,
|
||||
} from "./utils";
|
||||
import { environmentIdLayoutChecks, getEnvironmentAuth } from "./utils";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
@@ -71,8 +58,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
@@ -83,36 +68,12 @@ vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getAccessControlPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/errors", () => ({
|
||||
AuthorizationError: class AuthorizationError extends Error {},
|
||||
DatabaseError: class DatabaseError extends Error {},
|
||||
}));
|
||||
|
||||
describe("utils.ts", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Provide default mocks for successful scenario
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } });
|
||||
vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment);
|
||||
@@ -135,16 +96,6 @@ describe("utils.ts", () => {
|
||||
});
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user123" } as TUser);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "none",
|
||||
} as any);
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(0);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("getEnvironmentAuth", () => {
|
||||
@@ -219,434 +170,4 @@ describe("utils.ts", () => {
|
||||
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironmentWithRelations", () => {
|
||||
const mockPrismaData = {
|
||||
id: "env123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
type: "production" as const,
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight" as const,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
{
|
||||
id: "env123",
|
||||
type: "production" as const,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
{
|
||||
id: "env456",
|
||||
type: "development" as const,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
],
|
||||
organization: {
|
||||
id: "org123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { plan: "free" },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockPrismaData as any);
|
||||
});
|
||||
|
||||
test("returns combined environment, project, organization, and membership data", async () => {
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.environment.id).toBe("env123");
|
||||
expect(result!.environment.type).toBe("production");
|
||||
expect(result!.project.id).toBe("proj123");
|
||||
expect(result!.project.name).toBe("Test Project");
|
||||
expect(result!.organization.id).toBe("org123");
|
||||
expect(result!.organization.name).toBe("Test Organization");
|
||||
expect(result!.environments).toHaveLength(2);
|
||||
expect(result!.membership).toEqual({
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches only current user's membership using database-level filtering", async () => {
|
||||
await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "env123" },
|
||||
select: expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
organization: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
memberships: expect.objectContaining({
|
||||
where: { userId: "user123" },
|
||||
take: 1,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when environment not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null membership when user has no membership", async () => {
|
||||
const dataWithoutMembership = {
|
||||
...mockPrismaData,
|
||||
project: {
|
||||
...mockPrismaData.project,
|
||||
organization: {
|
||||
...mockPrismaData.project.organization,
|
||||
memberships: [], // No memberships
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(dataWithoutMembership as any);
|
||||
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result!.membership).toBeNull();
|
||||
});
|
||||
|
||||
test("throws error on database failure", async () => {
|
||||
// Mock a database error
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.environment.findUnique).mockRejectedValueOnce(dbError);
|
||||
|
||||
// Verify function throws (specific error type depends on Prisma error detection)
|
||||
await expect(getEnvironmentWithRelations("env123", "user123")).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Note: Input validation for environmentId and userId is handled by
|
||||
// getEnvironmentLayoutData (the parent function), not here.
|
||||
// See getEnvironmentLayoutData tests for validation coverage.
|
||||
});
|
||||
|
||||
describe("getEnvironmentLayoutData", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
||||
id: "env123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
{
|
||||
id: "env123",
|
||||
type: "production",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
],
|
||||
organization: {
|
||||
id: "org123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
test("returns complete layout data on success", async () => {
|
||||
const result = await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.session).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.environment).toBeDefined();
|
||||
expect(result.project).toBeDefined();
|
||||
expect(result.organization).toBeDefined();
|
||||
expect(result.environments).toBeDefined();
|
||||
expect(result.membership).toBeDefined();
|
||||
expect(result.isAccessControlAllowed).toBeDefined();
|
||||
expect(result.projectPermission).toBeDefined();
|
||||
expect(result.license).toBeDefined();
|
||||
expect(result.peopleCount).toBe(0);
|
||||
expect(result.responseCount).toBe(0);
|
||||
});
|
||||
|
||||
test("validates environmentId input", async () => {
|
||||
await expect(getEnvironmentLayoutData("", "user123")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("validates userId input", async () => {
|
||||
await expect(getEnvironmentLayoutData("env123", "")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("throws error if session not found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.session_not_found");
|
||||
});
|
||||
|
||||
test("throws error if userId doesn't match session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "different-user" } } as any);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("User ID mismatch");
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.user_not_found");
|
||||
});
|
||||
|
||||
test("throws error if environment data not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user has no environment access", async () => {
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
test("throws error if membership not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org123",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [], // No membership
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
|
||||
"common.membership_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches user before auth check, then environment data after authorization", async () => {
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
// User is fetched first (needed for auth check)
|
||||
expect(getUser).toHaveBeenCalledWith("user123");
|
||||
// Environment data is fetched after authorization passes
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "env123" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches permissions and license data in parallel", async () => {
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(getAccessControlPermission).toHaveBeenCalled();
|
||||
expect(getProjectPermissionByUserId).toHaveBeenCalledWith("user123", "proj123");
|
||||
expect(getEnterpriseLicense).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fetches cloud metrics when IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
// Mock IS_FORMBRICKS_CLOUD to be true
|
||||
const constantsMock = await import("@/lib/constants");
|
||||
vi.mocked(constantsMock).IS_FORMBRICKS_CLOUD = true;
|
||||
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(getMonthlyActiveOrganizationPeopleCount).toHaveBeenCalledWith("org123");
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith("org123");
|
||||
});
|
||||
|
||||
test("caches results per environmentId and userId", async () => {
|
||||
// Call twice with same parameters
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
// Due to React.cache, database should only be queried once
|
||||
// Note: React.cache behavior is per-request in production, but in tests
|
||||
// we can verify the function was called multiple times
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns different data for different environmentIds", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
name: "Project 1",
|
||||
organizationId: "org123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org123",
|
||||
name: "Org 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user123", organizationId: "org123", role: "owner", accepted: true }],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result1 = await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env456",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "proj456",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj456",
|
||||
name: "Project 2",
|
||||
organizationId: "org456",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org456",
|
||||
name: "Org 2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "pro", limits: {} },
|
||||
isAIEnabled: true,
|
||||
whitelabel: true,
|
||||
memberships: [{ userId: "user123", organizationId: "org456", role: "member", accepted: true }],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result2 = await getEnvironmentLayoutData("env456", "user123");
|
||||
|
||||
expect(result1.environment.id).not.toBe(result2.environment.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError, DatabaseError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { TEnvironmentAuth, TEnvironmentLayoutData } from "../types/environment-auth";
|
||||
import { TEnvironmentAuth } from "../types/environment-auth";
|
||||
|
||||
/**
|
||||
* Common utility to fetch environment data and perform authorization checks
|
||||
@@ -115,215 +103,3 @@ export const environmentIdLayoutChecks = async (environmentId: string) => {
|
||||
|
||||
return { t, session, user, organization };
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches environment with related project, organization, environments, and current user's membership
|
||||
* in a single optimized database query.
|
||||
* Returns data with proper types matching TEnvironment, TProject, TOrganization.
|
||||
*
|
||||
* Note: Validation is handled by parent function (getEnvironmentLayoutData)
|
||||
*/
|
||||
export const getEnvironmentWithRelations = reactCache(async (environmentId: string, userId: string) => {
|
||||
try {
|
||||
const data = await prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
// Environment fields
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
type: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
// Project via relation (nested select)
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
languages: true,
|
||||
recontactDays: true,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
// All project environments
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
},
|
||||
// Organization via relation
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
billing: true,
|
||||
isAIEnabled: true,
|
||||
whitelabel: true,
|
||||
// Current user's membership only (filtered at DB level)
|
||||
memberships: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
accepted: true,
|
||||
role: true,
|
||||
},
|
||||
take: 1, // Only need one result
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Extract and return properly typed data
|
||||
return {
|
||||
environment: {
|
||||
id: data.id,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
type: data.type,
|
||||
projectId: data.projectId,
|
||||
appSetupCompleted: data.appSetupCompleted,
|
||||
},
|
||||
project: {
|
||||
id: data.project.id,
|
||||
createdAt: data.project.createdAt,
|
||||
updatedAt: data.project.updatedAt,
|
||||
name: data.project.name,
|
||||
organizationId: data.project.organizationId,
|
||||
languages: data.project.languages,
|
||||
recontactDays: data.project.recontactDays,
|
||||
linkSurveyBranding: data.project.linkSurveyBranding,
|
||||
inAppSurveyBranding: data.project.inAppSurveyBranding,
|
||||
config: data.project.config,
|
||||
placement: data.project.placement,
|
||||
clickOutsideClose: data.project.clickOutsideClose,
|
||||
darkOverlay: data.project.darkOverlay,
|
||||
styling: data.project.styling,
|
||||
logo: data.project.logo,
|
||||
environments: data.project.environments,
|
||||
},
|
||||
organization: {
|
||||
id: data.project.organization.id,
|
||||
createdAt: data.project.organization.createdAt,
|
||||
updatedAt: data.project.organization.updatedAt,
|
||||
name: data.project.organization.name,
|
||||
billing: data.project.organization.billing,
|
||||
isAIEnabled: data.project.organization.isAIEnabled,
|
||||
whitelabel: data.project.organization.whitelabel,
|
||||
},
|
||||
environments: data.project.environments,
|
||||
membership: data.project.organization.memberships[0] || null, // First (and only) membership or null
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting environment with relations");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches all data required for environment layout rendering.
|
||||
* Consolidates multiple queries and eliminates duplicates.
|
||||
* Does NOT fetch switcher data (organizations/projects lists) - those are lazy-loaded.
|
||||
*
|
||||
* Note: userId is included in cache key to make it explicit that results are user-specific,
|
||||
* even though React.cache() is per-request and doesn't leak across users.
|
||||
*/
|
||||
export const getEnvironmentLayoutData = reactCache(
|
||||
async (environmentId: string, userId: string): Promise<TEnvironmentLayoutData> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
validateInputs([userId, ZId]);
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
// Verify userId matches session (safety check)
|
||||
if (session.user.id !== userId) {
|
||||
throw new Error("User ID mismatch with session");
|
||||
}
|
||||
|
||||
// Get user first (lightweight query needed for subsequent checks)
|
||||
const user = await getUser(userId); // 1 DB query
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
// Authorization check before expensive data fetching
|
||||
const hasAccess = await hasUserEnvironmentAccess(userId, environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError(t("common.not_authorized"));
|
||||
}
|
||||
|
||||
const relationData = await getEnvironmentWithRelations(environmentId, userId);
|
||||
if (!relationData) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const { environment, project, organization, environments, membership } = relationData;
|
||||
|
||||
// Validate user's membership was found
|
||||
if (!membership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
|
||||
// Fetch remaining data in parallel
|
||||
const [isAccessControlAllowed, projectPermission, license] = await Promise.all([
|
||||
getAccessControlPermission(organization.billing.plan), // No DB query (logic only)
|
||||
getProjectPermissionByUserId(userId, environment.projectId), // 1 DB query
|
||||
getEnterpriseLicense(), // Externally cached
|
||||
]);
|
||||
|
||||
// Conditional queries for Formbricks Cloud
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
environments,
|
||||
membership,
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { Session } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { TEnvironment, ZEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject, ZProject } from "@formbricks/types/project";
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
// Type for the enterprise license returned by getEnterpriseLicense()
|
||||
type TEnterpriseLicense = {
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
};
|
||||
import { ZEnvironment } from "@formbricks/types/environment";
|
||||
import { ZMembership } from "@formbricks/types/memberships";
|
||||
import { ZOrganization } from "@formbricks/types/organizations";
|
||||
import { ZProject } from "@formbricks/types/project";
|
||||
import { ZUser } from "@formbricks/types/user";
|
||||
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
environment: ZEnvironment,
|
||||
@@ -38,25 +27,3 @@ export const ZEnvironmentAuth = z.object({
|
||||
});
|
||||
|
||||
export type TEnvironmentAuth = z.infer<typeof ZEnvironmentAuth>;
|
||||
|
||||
/**
|
||||
* Complete layout data type for environment pages.
|
||||
* Includes all data needed for layout rendering.
|
||||
*
|
||||
* Note: organizations and projects lists are NOT included - they are lazy-loaded
|
||||
* in switcher dropdowns only when needed.
|
||||
*/
|
||||
export type TEnvironmentLayoutData = {
|
||||
session: Session;
|
||||
user: TUser;
|
||||
environment: TEnvironment;
|
||||
project: TProject; // Current project with full details
|
||||
organization: TOrganization;
|
||||
environments: TEnvironment[]; // All project environments for switcher
|
||||
membership: TMembership;
|
||||
isAccessControlAllowed: boolean;
|
||||
projectPermission: TTeamPermission | null;
|
||||
license: TEnterpriseLicense;
|
||||
peopleCount: number;
|
||||
responseCount: number;
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) =>
|
||||
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="border-0">
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -40,9 +40,7 @@ export const AdvancedSettings = ({
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
|
||||
{showOptionIds && (
|
||||
<OptionIds type="question" question={question} selectedLanguageCode={selectedLanguageCode} />
|
||||
)}
|
||||
{showOptionIds && <OptionIds question={question} selectedLanguageCode={selectedLanguageCode} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { Hand } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -37,6 +38,8 @@ export const EditWelcomeCard = ({
|
||||
}: EditWelcomeCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
const path = usePathname();
|
||||
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
|
||||
|
||||
@@ -135,6 +138,8 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
@@ -150,6 +155,8 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +177,8 @@ export const EditWelcomeCard = ({
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
@@ -49,7 +48,7 @@ export function LogicEditor({
|
||||
const ques = localSurvey.questions[i];
|
||||
options.push({
|
||||
icon: QUESTIONS_ICON_MAP[ques.type],
|
||||
label: getTextContent(getLocalizedValue(ques.headline, "default")),
|
||||
label: getLocalizedValue(ques.headline, "default"),
|
||||
value: ques.id,
|
||||
});
|
||||
}
|
||||
@@ -58,8 +57,7 @@ export function LogicEditor({
|
||||
options.push({
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
|
||||
t("environments.surveys.edit.end_screen_card")
|
||||
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
|
||||
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
||||
value: ending.id,
|
||||
});
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface OptionIdsQuestionProps {
|
||||
type: "question";
|
||||
interface OptionIdsProps {
|
||||
question: TSurveyQuestion;
|
||||
selectedLanguageCode: string;
|
||||
}
|
||||
|
||||
interface OptionIdsVariablesProps {
|
||||
type: "variables";
|
||||
variables: TSurveyVariable[];
|
||||
}
|
||||
|
||||
type OptionIdsProps = OptionIdsQuestionProps | OptionIdsVariablesProps;
|
||||
|
||||
export const OptionIds = (props: OptionIdsProps) => {
|
||||
export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderChoiceIds = (question: TSurveyQuestion, selectedLanguageCode: string) => {
|
||||
const renderChoiceIds = () => {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
@@ -67,31 +59,10 @@ export const OptionIds = (props: OptionIdsProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderVariableIds = (variables: TSurveyVariable[]) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{variables.map((variable) => (
|
||||
<div key={variable.id}>
|
||||
<IdBadge id={variable.id} label={variable.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (props.type === "variables") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-gray-700">{t("common.variable_ids")}</Label>
|
||||
<div className="w-full">{renderVariableIds(props.variables)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-gray-700">{t("common.option_ids")}</Label>
|
||||
<div className="w-full">{renderChoiceIds(props.question, props.selectedLanguageCode)}</div>
|
||||
<div className="w-full">{renderChoiceIds()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
|
||||
@@ -16,45 +18,19 @@ interface DisplayOption {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface WaitingTimeOption {
|
||||
id: "respect" | "ignore" | "overwrite";
|
||||
value: number | null;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RecontactOptionsCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactOptionsCardProps) => {
|
||||
export const RecontactOptionsCard = ({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
}: RecontactOptionsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const waitingTimeOptions: WaitingTimeOption[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "respect",
|
||||
value: null,
|
||||
name: t("environments.surveys.edit.respect_global_waiting_time"),
|
||||
description: t("environments.surveys.edit.respect_global_waiting_time_description"),
|
||||
},
|
||||
{
|
||||
id: "ignore",
|
||||
value: 0,
|
||||
name: t("environments.surveys.edit.ignore_global_waiting_time"),
|
||||
description: t("environments.surveys.edit.ignore_global_waiting_time_description"),
|
||||
},
|
||||
{
|
||||
id: "overwrite",
|
||||
value: 1,
|
||||
name: t("environments.surveys.edit.overwrite_global_waiting_time"),
|
||||
description: t("environments.surveys.edit.overwrite_global_waiting_time_description"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const displayOptions: DisplayOption[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -86,34 +62,26 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const ignoreWaiting = localSurvey.recontactDays !== null;
|
||||
const [inputDays, setInputDays] = useState(
|
||||
localSurvey.recontactDays !== null && localSurvey.recontactDays > 0 ? localSurvey.recontactDays : 1
|
||||
localSurvey.recontactDays !== null ? localSurvey.recontactDays : 1
|
||||
);
|
||||
const [displayLimit, setDisplayLimit] = useState(localSurvey.displayLimit ?? 1);
|
||||
|
||||
// Determine current waiting time option
|
||||
const getWaitingTimeOption = (): "respect" | "ignore" | "overwrite" => {
|
||||
if (localSurvey.recontactDays === null) return "respect";
|
||||
if (localSurvey.recontactDays === 0) return "ignore";
|
||||
return "overwrite";
|
||||
};
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const handleWaitingTimeChange = (optionId: string) => {
|
||||
const option = waitingTimeOptions.find((opt) => opt.id === optionId);
|
||||
if (option) {
|
||||
let newRecontactDays = option.value;
|
||||
if (optionId === "overwrite") {
|
||||
newRecontactDays = inputDays;
|
||||
}
|
||||
const updatedSurvey = { ...localSurvey, recontactDays: newRecontactDays };
|
||||
const handleCheckMark = () => {
|
||||
if (ignoreWaiting) {
|
||||
const updatedSurvey = { ...localSurvey, recontactDays: null };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
} else {
|
||||
const updatedSurvey = { ...localSurvey, recontactDays: 0 };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverwriteDaysChange = (event) => {
|
||||
const handleRecontactDaysChange = (event) => {
|
||||
const value = Number(event.target.value);
|
||||
setInputDays(value);
|
||||
|
||||
@@ -121,7 +89,7 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const handleDisplayLimitChange = (event) => {
|
||||
const handleRecontactSessionDaysChange = (event) => {
|
||||
const value = Number(event.target.value);
|
||||
setDisplayLimit(value);
|
||||
|
||||
@@ -160,11 +128,9 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">
|
||||
{t("environments.surveys.edit.visibility_and_recontact")}
|
||||
</p>
|
||||
<p className="font-semibold text-slate-800">{t("environments.surveys.edit.recontact_options")}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t("environments.surveys.edit.visibility_and_recontact_description")}
|
||||
{t("environments.surveys.edit.decide_how_often_people_can_answer_this_survey")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,72 +138,6 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col ${open && "pb-3"}`} ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<div className="p-3">
|
||||
{/* Waiting Time Section */}
|
||||
<div className="mb-4 space-y-1 px-1">
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
{t("environments.surveys.edit.waiting_time_across_surveys")}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.surveys.edit.waiting_time_across_surveys_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={getWaitingTimeOption()}
|
||||
className="flex flex-col space-y-3"
|
||||
onValueChange={handleWaitingTimeChange}>
|
||||
{waitingTimeOptions.map((option) => (
|
||||
<div key={option.id}>
|
||||
<Label
|
||||
htmlFor={`waiting-time-${option.id}`}
|
||||
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4"
|
||||
data-testid={`waiting-time-option-${option.id}`}>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={`waiting-time-${option.id}`}
|
||||
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700">{option.name}</p>
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
|
||||
</div>
|
||||
</Label>
|
||||
{option.id === "overwrite" && getWaitingTimeOption() === "overwrite" && (
|
||||
<div className="border-t-none -mt-1.5 w-full rounded-b-lg border bg-slate-50 p-4">
|
||||
<label htmlFor="overwriteDays">
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.surveys.edit.wait")}
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
id="overwriteDays"
|
||||
value={inputDays}
|
||||
onChange={handleOverwriteDaysChange}
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.days_before_showing_this_survey_again")}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<hr className="my-3 text-slate-600" />
|
||||
|
||||
<div className="p-3">
|
||||
{/* Recontact Options Section */}
|
||||
<div className="mb-4 space-y-1 px-1">
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
{t("environments.surveys.edit.recontact_options_section")}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.surveys.edit.recontact_options_section_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={localSurvey.displayOption}
|
||||
className="flex flex-col space-y-3"
|
||||
@@ -258,12 +158,11 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
<div key={option.id}>
|
||||
<Label
|
||||
key={option.name}
|
||||
htmlFor={`recontact-option-${option.id}`}
|
||||
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4"
|
||||
data-testid={`recontact-option-${option.id}`}>
|
||||
htmlFor={option.name}
|
||||
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={`recontact-option-${option.id}`}
|
||||
id={option.name}
|
||||
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
|
||||
/>
|
||||
<div>
|
||||
@@ -273,27 +172,105 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
</div>
|
||||
</Label>
|
||||
{option.id === "displaySome" && localSurvey.displayOption === "displaySome" && (
|
||||
<div className="border-t-none -mt-1.5 w-full rounded-b-lg border bg-slate-50 p-4">
|
||||
<label htmlFor="displayLimit">
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.surveys.edit.show_survey_maximum_of")}
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
id="displayLimit"
|
||||
value={displayLimit.toString()}
|
||||
onChange={(e) => handleDisplayLimitChange(e)}
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.times")}.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<label htmlFor="displayLimit" className="cursor-pointer p-4">
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.surveys.edit.show_survey_maximum_of")}
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
id="displayLimit"
|
||||
value={displayLimit.toString()}
|
||||
onChange={(e) => handleRecontactSessionDaysChange(e)}
|
||||
className="mx-2 inline w-16 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.times")}.
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
htmlId="recontactDays"
|
||||
isChecked={ignoreWaiting}
|
||||
onToggle={handleCheckMark}
|
||||
title={t("environments.surveys.edit.ignore_waiting_time_between_surveys")}
|
||||
childBorder={false}
|
||||
description={
|
||||
<>
|
||||
{t("environments.surveys.edit.this_setting_overwrites_your")}{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark underline"
|
||||
href={`/environments/${environmentId}/project/general`}
|
||||
target="_blank">
|
||||
{t("environments.surveys.edit.waiting_period")}
|
||||
</Link>
|
||||
. {t("environments.surveys.edit.use_with_caution")}
|
||||
</>
|
||||
}>
|
||||
{localSurvey.recontactDays !== null && (
|
||||
<RadioGroup
|
||||
value={localSurvey.recontactDays.toString()}
|
||||
className="flex w-full flex-col space-y-3 bg-white"
|
||||
onValueChange={(v) => {
|
||||
const updatedSurvey = { ...localSurvey, recontactDays: v === "null" ? null : Number(v) };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}}>
|
||||
<Label
|
||||
htmlFor="ignore"
|
||||
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
|
||||
<RadioGroupItem
|
||||
value="0"
|
||||
id="ignore"
|
||||
className="aria-checked:border-brand-dark mx-4 text-sm disabled:border-slate-400 aria-checked:border-2"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700">
|
||||
{t("environments.surveys.edit.always_show_survey")}
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">
|
||||
{t(
|
||||
"environments.surveys.edit.when_conditions_match_waiting_time_will_be_ignored_and_survey_shown"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<label
|
||||
htmlFor="newDays"
|
||||
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
|
||||
<RadioGroupItem
|
||||
value={inputDays === 0 ? "1" : inputDays.toString()} //Fixes that both radio buttons are checked when inputDays is 0
|
||||
id="newDays"
|
||||
className="aria-checked:border-brand-dark mx-4 disabled:border-slate-400 aria-checked:border-2"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
{t("environments.surveys.edit.wait")}
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
id="inputDays"
|
||||
value={inputDays === 0 ? 1 : inputDays}
|
||||
onChange={handleRecontactDaysChange}
|
||||
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.days_before_showing_this_survey_again")}.
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">
|
||||
{t("environments.surveys.edit.overwrites_waiting_period_between_surveys_to_x_days", {
|
||||
days: inputDays === 0 ? 1 : inputDays,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</AdvancedOptionToggle>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
|
||||
@@ -97,7 +97,11 @@ export const SettingsView = ({
|
||||
isSpamProtectionAllowed={isSpamProtectionAllowed}
|
||||
/>
|
||||
|
||||
<RecontactOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
<RecontactOptionsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
|
||||
{isAppSurvey && (
|
||||
<SurveyPlacementCard
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Project } from "@prisma/client";
|
||||
import { isEqual } from "lodash";
|
||||
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
@@ -67,7 +67,6 @@ export const SurveyMenuBar = ({
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const isSuccessfullySavedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
@@ -79,21 +78,9 @@ export const SurveyMenuBar = ({
|
||||
setIsLinkSurvey(localSurvey.type === "link");
|
||||
}, [localSurvey.type]);
|
||||
|
||||
// Reset the successfully saved flag when survey prop updates (page refresh complete)
|
||||
useEffect(() => {
|
||||
if (isSuccessfullySavedRef.current) {
|
||||
isSuccessfullySavedRef.current = false;
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
useEffect(() => {
|
||||
const warningText = t("environments.surveys.edit.unsaved_changes_warning");
|
||||
const handleWindowClose = (e: BeforeUnloadEvent) => {
|
||||
// Skip warning if we just successfully saved
|
||||
if (isSuccessfullySavedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEqual(localSurvey, survey)) {
|
||||
e.preventDefault();
|
||||
return (e.returnValue = warningText);
|
||||
@@ -262,8 +249,6 @@ export const SurveyMenuBar = ({
|
||||
if (updatedSurveyResponse?.data) {
|
||||
setLocalSurvey(updatedSurveyResponse.data);
|
||||
toast.success(t("environments.surveys.edit.changes_saved"));
|
||||
// Set flag to prevent beforeunload warning during router.refresh()
|
||||
isSuccessfullySavedRef.current = true;
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
|
||||
@@ -313,8 +298,6 @@ export const SurveyMenuBar = ({
|
||||
segment,
|
||||
});
|
||||
setIsSurveyPublishing(false);
|
||||
// Set flag to prevent beforeunload warning during navigation
|
||||
isSuccessfullySavedRef.current = true;
|
||||
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { OptionIds } from "@/modules/survey/editor/components/option-ids";
|
||||
import { SurveyVariablesCardItem } from "@/modules/survey/editor/components/survey-variables-card-item";
|
||||
|
||||
interface SurveyVariablesCardProps {
|
||||
@@ -92,12 +91,6 @@ export const SurveyVariablesCard = ({
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
quotas={quotas}
|
||||
/>
|
||||
|
||||
{localSurvey.variables.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<OptionIds type="variables" variables={localSurvey.variables} />
|
||||
</div>
|
||||
)}
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
|
||||
@@ -594,7 +594,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = openTextQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -691,7 +691,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -765,7 +765,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -845,7 +845,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
|
||||
@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } 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 { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -141,9 +140,9 @@ export const FollowUpModal = ({
|
||||
|
||||
return [
|
||||
...openTextAndContactQuestions.map((question) => ({
|
||||
label: getTextContent(
|
||||
recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[selectedLanguageCode]
|
||||
),
|
||||
label: recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
],
|
||||
id: question.id,
|
||||
type:
|
||||
question.type === TSurveyQuestionTypeEnum.OpenText
|
||||
@@ -518,9 +517,7 @@ export const FollowUpModal = ({
|
||||
const getEndingLabel = (): string => {
|
||||
if (ending.type === "endScreen") {
|
||||
return (
|
||||
getTextContent(
|
||||
getLocalizedValue(ending.headline, selectedLanguageCode)
|
||||
) || "Ending"
|
||||
getLocalizedValue(ending.headline, selectedLanguageCode) || "Ending"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ vi.mock("@/lib/constants", async () => {
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
STARTUP: "startup",
|
||||
CUSTOM: "custom",
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -26,13 +24,8 @@ describe("getSurveyFollowUpsPermission", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for startup plan on Formbricks Cloud", async () => {
|
||||
test("should return true for non-free plan on Formbricks Cloud", async () => {
|
||||
const result = await getSurveyFollowUpsPermission("startup" as TOrganizationBillingPlan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for custom plan on Formbricks Cloud", async () => {
|
||||
const result = await getSurveyFollowUpsPermission("custom" as TOrganizationBillingPlan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@/lib/constants";
|
||||
export const getSurveyFollowUpsPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
|
||||
type TBasicSurveyMetadata = {
|
||||
@@ -50,9 +48,7 @@ export const getBasicSurveyMetadata = async (
|
||||
const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
|
||||
const titleFromWelcome =
|
||||
welcomeCard?.enabled && welcomeCard.headline
|
||||
? getTextContent(
|
||||
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
|
||||
) || ""
|
||||
? getLocalizedValue(welcomeCard.headline, langCode) || ""
|
||||
: undefined;
|
||||
let title = titleFromMetadata || titleFromWelcome || survey.name;
|
||||
|
||||
|
||||
11
apps/web/modules/survey/link/loading.tsx
Normal file
11
apps/web/modules/survey/link/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
export const LinkSurveyLoading = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex h-1/2 w-3/4 flex-col sm:w-1/2 lg:w-1/4">
|
||||
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
|
||||
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-slate-200 text-slate-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -35,10 +35,9 @@ const BreadcrumbItem = React.forwardRef<
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1",
|
||||
!isHighlighted && "hover:bg-white hover:outline hover:outline-slate-300",
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
|
||||
isActive && "bg-slate-100 outline outline-slate-300",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:bg-red-700 hover:outline-red-800",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -59,14 +60,17 @@ function CommandInput({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input> & { hidden?: boolean }) {
|
||||
return (
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"outline-hidden flex h-9 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div data-slot="command-input-wrapper" className={cn("flex items-center")}>
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,10 +78,7 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden rounded-md border border-slate-300 bg-white",
|
||||
className
|
||||
)}
|
||||
className={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -115,7 +116,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[selected=true]:cursor-pointer data-[selected=true]:bg-slate-100 data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -136,11 +137,11 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const DataTableToolbar = <T,>({
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||
<div className="sticky top-0 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
|
||||
<SelectedRowSettings
|
||||
table={table}
|
||||
|
||||
@@ -262,27 +262,22 @@ export const ToolbarPlugin = (
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
root.append(...nodes);
|
||||
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const textInHtml = $generateHtmlFromNodes(editor)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/white-space:\s*pre-wrap;?/g, "");
|
||||
setText.current(textInHtml);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Register text-saving update listener - always active for each editor instance
|
||||
useEffect(() => {
|
||||
const unregister = editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const textInHtml = $generateHtmlFromNodes(editor)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/white-space:\s*pre-wrap;?/g, "");
|
||||
setText.current(textInHtml);
|
||||
});
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
|
||||
@@ -265,7 +265,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
||||
<button autoFocus className="sr-only" aria-hidden type="button" />
|
||||
)}
|
||||
|
||||
<CommandList className="border-0 p-1">
|
||||
<CommandList className="p-1">
|
||||
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
|
||||
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -128,8 +128,8 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
</div>
|
||||
{open && selectableOptions.length > 0 && !disabled && (
|
||||
<div className="relative mt-2">
|
||||
<CommandList className="border-0">
|
||||
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandList>
|
||||
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md border bg-white shadow-md outline-none">
|
||||
<CommandGroup className="h-full overflow-auto">
|
||||
{selectableOptions.map((option) => (
|
||||
<CommandItem
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const TagsCombobox = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="border-0">
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tagsToSearch?.map((tag) => {
|
||||
return (
|
||||
|
||||
@@ -87,8 +87,8 @@ test.describe("JS Package Test", async () => {
|
||||
await page.getByRole("button", { name: "Create action" }).click();
|
||||
|
||||
await page.locator("#recontactOptionsCardTrigger").click();
|
||||
await page.locator('[data-testid="recontact-option-respondMultiple"]').click();
|
||||
await page.locator('[data-testid="waiting-time-option-ignore"]').click();
|
||||
await page.locator("label").filter({ hasText: "Keep showing while conditions" }).click();
|
||||
await page.locator("#recontactDays").check();
|
||||
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -211,18 +211,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SESSION_MAX_AGE: 1000,
|
||||
MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT: 100,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
AVAILABLE_LOCALES: [
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"fr-FR",
|
||||
"zh-Hant-TW",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
],
|
||||
AVAILABLE_LOCALES: ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"],
|
||||
DEFAULT_LOCALE: "en-US",
|
||||
BREVO_API_KEY: "mock-brevo-api-key",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOrganizationBillingPlan = z.enum(["free", "startup", "custom"]);
|
||||
export const ZOrganizationBillingPlan = z.enum(["free", "startup", "scale", "enterprise"]);
|
||||
export type TOrganizationBillingPlan = z.infer<typeof ZOrganizationBillingPlan>;
|
||||
|
||||
export const ZOrganizationBillingPeriod = z.enum(["monthly", "yearly"]);
|
||||
|
||||
@@ -10,7 +10,6 @@ export const ZUserLocale = z.enum([
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
]);
|
||||
|
||||
export type TUserLocale = z.infer<typeof ZUserLocale>;
|
||||
|
||||
Reference in New Issue
Block a user