Compare commits

..

2 Commits

Author SHA1 Message Date
pandeymangg 37e5246cc5 testing oidc bug 2025-10-27 17:21:55 +05:30
pandeymangg 72767e9336 testing oidc bug 2025-10-27 16:07:28 +05:30
86 changed files with 1527 additions and 3024 deletions
@@ -4,6 +4,7 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -23,6 +24,8 @@ const Page = async (props) => {
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) return notFound(); if (!user) return notFound();
const organizations = await getOrganizationsByUserId(session.user.id);
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
@@ -34,10 +37,11 @@ const Page = async (props) => {
<div className="flex-1"> <div className="flex-1">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="p-6"> <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 <ProjectAndOrgSwitch
currentOrganizationId={organization.id} currentOrganizationId={organization.id}
currentOrganizationName={organization.name} organizations={organizations}
projects={[]}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0} organizationProjectsLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project"; import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service"; import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
@@ -17,8 +16,6 @@ import {
getOrganizationProjectsLimit, getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project"; import { createProject } from "@/modules/projects/settings/lib/project";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
const ZCreateProjectAction = z.object({ const ZCreateProjectAction = z.object({
organizationId: ZId, 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 { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; 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 { 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 { 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 { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth"; 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 { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
interface EnvironmentLayoutProps { interface EnvironmentLayoutProps {
layoutData: TEnvironmentLayoutData; environmentId: string;
session: Session;
children?: React.ReactNode; children?: React.ReactNode;
} }
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => { export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
const t = await getTranslate(); 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) if (!user) {
const { throw new Error(t("common.user_not_found"));
user, }
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
} = layoutData;
// Calculate derived values (no queries) if (!organization) {
const { isMember, isOwner, isManager } = getAccessFlags(membership.role); throw new Error(t("common.organization_not_found"));
}
const { features, lastChecked, isPendingDowngrade, active } = license; if (!environment) {
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; throw new Error(t("common.environment_not_found"));
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); }
const isOwnerOrManager = isOwner || isManager;
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) { if (isMember && !projectPermission) {
throw new Error(t("common.project_permission_not_found")); 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 ( return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden"> <div className="flex h-screen min-h-screen flex-col overflow-hidden">
{IS_FORMBRICKS_CLOUD && ( {IS_FORMBRICKS_CLOUD && (
@@ -67,24 +122,26 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<MainNavigation <MainNavigation
environment={environment} environment={environment}
organization={organization} organization={organization}
projects={projects}
user={user} user={user}
project={{ id: project.id, name: project.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role} membershipRole={membershipRole}
/> />
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50"> <div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar <TopControlBar
environments={environments} environments={environments}
currentOrganizationId={organization.id} currentOrganizationId={organization.id}
organizations={organizations}
currentProjectId={project.id} currentProjectId={project.id}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active} isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
membershipRole={membership.role} membershipRole={membershipRole}
/> />
<div className="flex-1 overflow-y-auto">{children}</div> <div className="flex-1 overflow-y-auto">{children}</div>
</div> </div>
@@ -42,7 +42,7 @@ interface NavigationProps {
environment: TEnvironment; environment: TEnvironment;
user: TUser; user: TUser;
organization: TOrganization; organization: TOrganization;
project: { id: string; name: string }; projects: { id: string; name: string }[];
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isDevelopment: boolean; isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
@@ -52,7 +52,7 @@ export const MainNavigation = ({
environment, environment,
organization, organization,
user, user,
project, projects,
membershipRole, membershipRole,
isFormbricksCloud, isFormbricksCloud,
isDevelopment, isDevelopment,
@@ -65,6 +65,7 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState(""); const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); 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 { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
@@ -9,7 +9,9 @@ import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps { interface TopControlBarProps {
environments: TEnvironment[]; environments: TEnvironment[];
currentOrganizationId: string; currentOrganizationId: string;
organizations: { id: string; name: string }[];
currentProjectId: string; currentProjectId: string;
projects: { id: string; name: string }[];
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
organizationProjectsLimit: number; organizationProjectsLimit: number;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
@@ -22,7 +24,9 @@ interface TopControlBarProps {
export const TopControlBar = ({ export const TopControlBar = ({
environments, environments,
currentOrganizationId, currentOrganizationId,
organizations,
currentProjectId, currentProjectId,
projects,
isMultiOrgEnabled, isMultiOrgEnabled,
organizationProjectsLimit, organizationProjectsLimit,
isFormbricksCloud, isFormbricksCloud,
@@ -42,7 +46,9 @@ export const TopControlBar = ({
currentEnvironmentId={environment.id} currentEnvironmentId={environment.id}
environments={environments} environments={environments}
currentOrganizationId={currentOrganizationId} currentOrganizationId={currentOrganizationId}
organizations={organizations}
currentProjectId={currentProjectId} currentProjectId={currentProjectId}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
@@ -10,11 +10,9 @@ import {
SettingsIcon, SettingsIcon,
} from "lucide-react"; } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger"; 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 { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb"; import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import { import {
@@ -25,11 +23,10 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps { interface OrganizationBreadcrumbProps {
currentOrganizationId: string; currentOrganizationId: string;
currentOrganizationName?: string; // Optional: pass directly if context not available organizations: { id: string; name: string }[];
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
currentEnvironmentId?: string; currentEnvironmentId?: string;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
@@ -50,7 +47,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
export const OrganizationBreadcrumb = ({ export const OrganizationBreadcrumb = ({
currentOrganizationId, currentOrganizationId,
currentOrganizationName, organizations,
isMultiOrgEnabled, isMultiOrgEnabled,
currentEnvironmentId, currentEnvironmentId,
isFormbricksCloud, isFormbricksCloud,
@@ -63,45 +60,7 @@ export const OrganizationBreadcrumb = ({
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false); const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
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,
]);
if (!currentOrganization) { if (!currentOrganization) {
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`; const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
@@ -167,7 +126,7 @@ export const OrganizationBreadcrumb = ({
asChild> asChild>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} /> <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} />} {isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? ( {isOrganizationDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} /> <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" /> <BuildingIcon className="mr-2 inline h-4 w-4" />
{t("common.choose_organization")} {t("common.choose_organization")}
</div> </div>
{isLoadingOrganizations && ( <DropdownMenuGroup>
<div className="flex items-center justify-center py-2"> {organizations.map((org) => (
<Loader2 className="h-4 w-4 animate-spin" /> <DropdownMenuCheckboxItem
</div> key={org.id}
)} checked={org.id === currentOrganization.id}
{!isLoadingOrganizations && loadError && ( onClick={() => handleOrganizationChange(org.id)}
<div className="px-2 py-4"> className="cursor-pointer">
<p className="mb-2 text-sm text-red-600">{loadError}</p> {org.name}
<button </DropdownMenuCheckboxItem>
onClick={() => { ))}
setLoadError(null); </DropdownMenuGroup>
setOrganizations([]); {isMultiOrgEnabled && (
}} <DropdownMenuCheckboxItem
className="text-xs text-slate-600 underline hover:text-slate-800"> onClick={() => setOpenCreateOrganizationModal(true)}
{t("common.try_again")} className="cursor-pointer">
</button> <span>{t("common.create_new_organization")}</span>
</div> <PlusIcon className="ml-2 h-4 w-4" />
)} </DropdownMenuCheckboxItem>
{!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>
)}
</>
)} )}
</> </>
)} )}
{currentEnvironmentId && ( {currentEnvironmentId && (
<div> <div>
{showOrganizationDropdown && <DropdownMenuSeparator />} <DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500"> <div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" /> <SettingsIcon className="mr-2 inline h-4 w-4" />
{t("common.organization_settings")} {t("common.organization_settings")}
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useMemo } from "react";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb"; import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb"; import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-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 { interface ProjectAndOrgSwitchProps {
currentOrganizationId: string; currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context organizations: { id: string; name: string }[];
currentProjectId?: string; currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context projects: { id: string; name: string }[];
currentEnvironmentId?: string; currentEnvironmentId?: string;
environments: { id: string; type: string }[]; environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
@@ -17,15 +18,15 @@ interface ProjectAndOrgSwitchProps {
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isLicenseActive: boolean; isLicenseActive: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
isMember: boolean;
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
isMember: boolean;
} }
export const ProjectAndOrgSwitch = ({ export const ProjectAndOrgSwitch = ({
currentOrganizationId, currentOrganizationId,
currentOrganizationName, organizations,
currentProjectId, currentProjectId,
currentProjectName, projects,
currentEnvironmentId, currentEnvironmentId,
environments, environments,
isMultiOrgEnabled, isMultiOrgEnabled,
@@ -36,6 +37,11 @@ export const ProjectAndOrgSwitch = ({
isAccessControlAllowed, isAccessControlAllowed,
isMember, isMember,
}: ProjectAndOrgSwitchProps) => { }: 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 currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development"; const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -44,9 +50,9 @@ export const ProjectAndOrgSwitch = ({
<BreadcrumbList className="gap-0"> <BreadcrumbList className="gap-0">
<OrganizationBreadcrumb <OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId} currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName} organizations={sortedOrganizations}
currentEnvironmentId={currentEnvironmentId}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isMember={isMember} isMember={isMember}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
@@ -54,9 +60,9 @@ export const ProjectAndOrgSwitch = ({
{currentProjectId && currentEnvironmentId && ( {currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb <ProjectBreadcrumb
currentProjectId={currentProjectId} currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId} currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId} currentEnvironmentId={currentEnvironmentId}
projects={sortedProjects}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
@@ -3,11 +3,9 @@
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react"; import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger"; 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 { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal"; import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb"; import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
@@ -20,11 +18,10 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt"; import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
interface ProjectBreadcrumbProps { interface ProjectBreadcrumbProps {
currentProjectId: string; currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available projects: { id: string; name: string }[];
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
organizationProjectsLimit: number; organizationProjectsLimit: number;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
@@ -47,7 +44,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
export const ProjectBreadcrumb = ({ export const ProjectBreadcrumb = ({
currentProjectId, currentProjectId,
currentProjectName, projects,
isOwnerOrManager, isOwnerOrManager,
organizationProjectsLimit, organizationProjectsLimit,
isFormbricksCloud, isFormbricksCloud,
@@ -62,41 +59,9 @@ export const ProjectBreadcrumb = ({
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false); const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false); const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter(); 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 [isPending, startTransition] = useTransition();
const pathname = usePathname(); 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 = [ const projectSettings = [
{ {
id: "general", id: "general",
@@ -135,6 +100,8 @@ export const ProjectBreadcrumb = ({
}, },
]; ];
const currentProject = projects.find((project) => project.id === currentProjectId);
if (!currentProject) { if (!currentProject) {
const errorMessage = `Project not found for project id: ${currentProjectId}`; const errorMessage = `Project not found for project id: ${currentProjectId}`;
logger.error(errorMessage); logger.error(errorMessage);
@@ -199,7 +166,7 @@ export const ProjectBreadcrumb = ({
asChild> asChild>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} /> <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} />} {isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isProjectDropdownOpen ? ( {isProjectDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} /> <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} /> <FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_project")} {t("common.choose_project")}
</div> </div>
{isLoadingProjects && ( <DropdownMenuGroup>
<div className="flex items-center justify-center py-2"> {projects.map((proj) => (
<Loader2 className="h-4 w-4 animate-spin" /> <DropdownMenuCheckboxItem
</div> key={proj.id}
)} checked={proj.id === currentProject.id}
{!isLoadingProjects && loadError && ( onClick={() => handleProjectChange(proj.id)}
<div className="px-2 py-4"> className="cursor-pointer">
<p className="mb-2 text-sm text-red-600">{loadError}</p> <div className="flex items-center gap-2">
<button <span>{proj.name}</span>
onClick={() => { </div>
setLoadError(null); </DropdownMenuCheckboxItem>
setProjects([]); ))}
}} </DropdownMenuGroup>
className="text-xs text-slate-600 underline hover:text-slate-800"> {isOwnerOrManager && (
{t("common.try_again")} <DropdownMenuCheckboxItem
</button> onClick={handleAddProject}
</div> className="w-full cursor-pointer justify-between">
)} <span>{t("common.add_new_project")}</span>
{!isLoadingProjects && !loadError && ( <PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
<> </DropdownMenuCheckboxItem>
<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> <DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -2,13 +2,11 @@
import { createContext, useContext, useMemo } from "react"; import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType { export interface EnvironmentContextType {
environment: TEnvironment; environment: TEnvironment;
project: TProject; project: TProject;
organization: TOrganization;
organizationId: string; organizationId: string;
} }
@@ -22,44 +20,25 @@ export const useEnvironment = () => {
return context; 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 // Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps { interface EnvironmentContextWrapperProps {
environment: TEnvironment; environment: TEnvironment;
project: TProject; project: TProject;
organization: TOrganization;
children: React.ReactNode; children: React.ReactNode;
} }
export const EnvironmentContextWrapper = ({ export const EnvironmentContextWrapper = ({
environment, environment,
project, project,
organization,
children, children,
}: EnvironmentContextWrapperProps) => { }: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo( const environmentContextValue = useMemo(
() => ({ () => ({
environment, environment,
project, project,
organization,
organizationId: project.organizationId, organizationId: project.organizationId,
}), }),
[environment, project, organization] [environment, project]
); );
return ( return (
@@ -1,9 +1,10 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"; import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironment } from "@/lib/environment/service";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils"; 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 { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler"; import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -14,27 +15,46 @@ const EnvLayout = async (props: {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
// Check session first (required for userId) const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
const session = await getServerSession(authOptions);
if (!session?.user) { if (!session) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
// Single consolidated data fetch (replaces ~12 individual fetches) if (!user) {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id); 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 ( return (
<EnvironmentIdBaseLayout <EnvironmentIdBaseLayout
environmentId={params.environmentId} environmentId={params.environmentId}
session={layoutData.session} session={session}
user={layoutData.user} user={user}
organization={layoutData.organization}> organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper <EnvironmentContextWrapper environment={environment} project={project}>
environment={layoutData.environment} <EnvironmentLayout environmentId={params.environmentId} session={session}>
project={layoutData.project} {children}
organization={layoutData.organization}> </EnvironmentLayout>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper> </EnvironmentContextWrapper>
</EnvironmentIdBaseLayout> </EnvironmentIdBaseLayout>
); );
@@ -1,6 +1,6 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; 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 { TUser, TUserLocale } from "@formbricks/types/user";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface ResponseCardModalProps { interface ResponseCardModalProps {
responses: TResponse[]; responses: TResponse[];
@@ -49,37 +42,25 @@ export const ResponseCardModal = ({
locale, locale,
}: ResponseCardModalProps) => { }: ResponseCardModalProps) => {
const [currentIndex, setCurrentIndex] = useState<number | null>(null); 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(() => { useEffect(() => {
if (selectedResponseId) { if (selectedResponseId) {
setOpen(true); setOpen(true);
const index = idToIndexMap.get(selectedResponseId) ?? -1; const index = responses.findIndex((response) => response.id === selectedResponseId);
setCurrentIndex(index); setCurrentIndex(index);
setIsNavigating(false);
} else { } else {
setOpen(false); setOpen(false);
} }
}, [selectedResponseId, idToIndexMap, setOpen]); }, [selectedResponseId, responses, setOpen]);
const handleNext = () => { const handleNext = () => {
if (currentIndex !== null && currentIndex < responses.length - 1) { if (currentIndex !== null && currentIndex < responses.length - 1) {
setIsNavigating(true);
setSelectedResponseId(responses[currentIndex + 1].id); setSelectedResponseId(responses[currentIndex + 1].id);
} }
}; };
const handleBack = () => { const handleBack = () => {
if (currentIndex !== null && currentIndex > 0) { if (currentIndex !== null && currentIndex > 0) {
setIsNavigating(true);
setSelectedResponseId(responses[currentIndex - 1].id); 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 no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null; if (selectedResponseId === null || currentIndex === null) return null;
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
@@ -100,11 +81,6 @@ export const ResponseCardModal = ({
<VisuallyHidden asChild> <VisuallyHidden asChild>
<DialogTitle>Survey Response Details</DialogTitle> <DialogTitle>Survey Response Details</DialogTitle>
</VisuallyHidden> </VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>
Response {currentIndex + 1} of {responses.length}
</DialogDescription>
</VisuallyHidden>
<DialogBody> <DialogBody>
<SingleResponseCard <SingleResponseCard
survey={survey} survey={survey}
@@ -120,16 +96,12 @@ export const ResponseCardModal = ({
/> />
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<Button <Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
onClick={handleBack}
disabled={currentIndex === 0 || isNavigating}
variant="outline"
size="icon">
<ChevronLeft /> <ChevronLeft />
</Button> </Button>
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={currentIndex === responses.length - 1 || isNavigating} disabled={currentIndex === responses.length - 1}
variant="outline" variant="outline"
size="icon"> size="icon">
<ChevronRight /> <ChevronRight />
@@ -28,63 +28,60 @@ interface ResponseDataViewProps {
quotas: TSurveyQuota[]; 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 for testing
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => { export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"]; 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 for testing
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => { export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"]; const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
return formatArrayToRecord(responseValue, contactInfoKeys); 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 for testing
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => { 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]; const responseValue = response.data[question.id];
switch (question.type) { switch (question.type) {
case "matrix": case "matrix":
if (typeof responseValue === "object") { if (typeof responseValue === "object") {
Object.assign(responseData, responseValue); responseData = { ...responseData, ...responseValue };
} }
break; break;
case "address": case "address":
Object.assign(responseData, formatAddressData(responseValue)); responseData = { ...responseData, ...formatAddressData(responseValue) };
break; break;
case "contactInfo": case "contactInfo":
Object.assign(responseData, formatContactInfoData(responseValue)); responseData = { ...responseData, ...formatContactInfoData(responseValue) };
break; break;
default: default:
responseData[question.id] = responseValue; responseData[question.id] = responseValue;
} }
} });
if (survey.hiddenFields.fieldIds) { survey.hiddenFields.fieldIds?.forEach((fieldId) => {
for (const fieldId of survey.hiddenFields.fieldIds) { responseData[fieldId] = response.data[fieldId];
responseData[fieldId] = response.data[fieldId]; });
}
}
return responseData; return responseData;
}; };
// Export for testing // Export for testing
const mapResponsesToTableData = ( export const mapResponsesToTableData = (
responses: TResponseWithQuotas[], responses: TResponseWithQuotas[],
survey: TSurvey, survey: TSurvey,
t: TFunction t: TFunction
@@ -96,7 +93,6 @@ const mapResponsesToTableData = (
? t("environments.surveys.responses.completed") ? t("environments.surveys.responses.completed")
: t("environments.surveys.responses.not_completed"), : t("environments.surveys.responses.not_completed"),
responseId: response.id, responseId: response.id,
singleUseId: response.singleUseId,
tags: response.tags, tags: response.tags,
variables: survey.variables.reduce( variables: survey.variables.reduce(
(acc, curr) => { (acc, curr) => {
@@ -130,10 +126,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
quotas, quotas,
}) => { }) => {
const { t } = useTranslation(); 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); const data = mapResponsesToTableData(responses, survey, t);
return ( return (
@@ -154,8 +146,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
locale={locale} locale={locale}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
quotas={quotas} quotas={quotas}
selectedResponseId={selectedResponseId}
setSelectedResponseId={setSelectedResponseIdTransition}
/> />
</div> </div>
); );
@@ -122,11 +122,12 @@ export const ResponsePage = ({
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setResponses([]);
}, [filters]); }, [filters]);
return ( return (
<> <>
<div className="flex h-9 gap-1.5"> <div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} /> <CustomFilter survey={surveyMemoized} />
</div> </div>
<ResponseDataView <ResponseDataView
@@ -39,12 +39,6 @@ import {
import { Skeleton } from "@/modules/ui/components/skeleton"; import { Skeleton } from "@/modules/ui/components/skeleton";
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table"; 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 { interface ResponseTableProps {
data: TResponseTableData[]; data: TResponseTableData[];
survey: TSurvey; survey: TSurvey;
@@ -61,8 +55,6 @@ interface ResponseTableProps {
locale: TUserLocale; locale: TUserLocale;
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
quotas: TSurveyQuota[]; quotas: TSurveyQuota[];
selectedResponseId: string | null;
setSelectedResponseId: (id: string | null) => void;
} }
export const ResponseTable = ({ export const ResponseTable = ({
@@ -81,13 +73,12 @@ export const ResponseTable = ({
locale, locale,
isQuotasAllowed, isQuotasAllowed,
quotas, quotas,
selectedResponseId,
setSelectedResponseId,
}: ResponseTableProps) => { }: ResponseTableProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false); const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null; const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
const [isExpanded, setIsExpanded] = useState<boolean | null>(null); const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>([]); const [columnOrder, setColumnOrder] = useState<string[]>([]);
@@ -95,10 +86,7 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0; const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns // Generate columns
const columns = useMemo( const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
);
// Save settings to localStorage when they change // Save settings to localStorage when they change
useEffect(() => { useEffect(() => {
@@ -122,13 +110,7 @@ export const ResponseTable = ({
// Memoize table data and columns // Memoize table data and columns
const tableData: TResponseTableData[] = useMemo( const tableData: TResponseTableData[] = useMemo(
() => () => (isFetchingFirstPage ? Array(10).fill({}) : data),
isFetchingFirstPage
? Array.from(
{ length: 10 },
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
)
: data,
[data, isFetchingFirstPage] [data, isFetchingFirstPage]
); );
@@ -137,7 +119,11 @@ export const ResponseTable = ({
isFetchingFirstPage isFetchingFirstPage
? columns.map((column) => ({ ? columns.map((column) => ({
...column, ...column,
cell: SkeletonCell, cell: () => (
<Skeleton className="w-full">
<div className="h-6"></div>
</Skeleton>
),
})) }))
: columns, : columns,
[columns, isFetchingFirstPage] [columns, isFetchingFirstPage]
@@ -261,8 +247,8 @@ export const ResponseTable = ({
</TableRow> </TableRow>
))} ))}
</TableHeader> </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) => ( {table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
@@ -275,6 +261,7 @@ export const ResponseTable = ({
row={row} row={row}
isExpanded={isExpanded ?? false} isExpanded={isExpanded ?? false}
setSelectedResponseId={setSelectedResponseId} setSelectedResponseId={setSelectedResponseId}
responses={responses}
/> />
))} ))}
</TableRow> </TableRow>
@@ -1,7 +1,6 @@
import { Cell, Row, flexRender } from "@tanstack/react-table"; import { Cell, Row, flexRender } from "@tanstack/react-table";
import { Maximize2Icon } from "lucide-react"; import { Maximize2Icon } from "lucide-react";
import React from "react"; import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TResponseTableData } from "@formbricks/types/responses";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils"; import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
import { TableCell } from "@/modules/ui/components/table"; import { TableCell } from "@/modules/ui/components/table";
@@ -11,18 +10,21 @@ interface ResponseTableCellProps {
row: Row<TResponseTableData>; row: Row<TResponseTableData>;
isExpanded: boolean; isExpanded: boolean;
setSelectedResponseId: (responseId: string | null) => void; setSelectedResponseId: (responseId: string | null) => void;
responses: TResponse[] | null;
} }
const ResponseTableCellComponent = ({ export const ResponseTableCell = ({
cell, cell,
row, row,
isExpanded, isExpanded,
setSelectedResponseId, setSelectedResponseId,
responses,
}: ResponseTableCellProps) => { }: ResponseTableCellProps) => {
// Function to handle cell click // Function to handle cell click
const handleCellClick = () => { const handleCellClick = () => {
if (cell.column.id !== "select") { 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> </TableCell>
); );
}; };
export const ResponseTableCell = React.memo(ResponseTableCellComponent);
@@ -312,14 +312,6 @@ export const generateResponseTableColumns = (
}, },
}; };
const singleUseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "singleUseId",
header: () => <div className="gap-x-1.5">{t("environments.surveys.responses.single_use_id")}</div>,
cell: ({ row }) => {
return <p className="truncate text-slate-900">{row.original.singleUseId}</p>;
},
};
const quotasColumn: ColumnDef<TResponseTableData> = { const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota", accessorKey: "quota",
header: t("common.quota"), header: t("common.quota"),
@@ -417,7 +409,6 @@ export const generateResponseTableColumns = (
// Combine the selection column with the dynamic question columns // Combine the selection column with the dynamic question columns
const baseColumns = [ const baseColumns = [
personColumn, personColumn,
singleUseIdColumn,
dateColumn, dateColumn,
...(showQuotasColumn ? [quotasColumn] : []), ...(showQuotasColumn ? [quotasColumn] : []),
statusColumn, statusColumn,
@@ -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"> <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); const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return ( return (
<Fragment key={result.value}> <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="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"> <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"> <p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{result.value} {results.length - resultsIdx} - {result.value}
</p> </p>
{choiceId && <IdBadge id={choiceId} />} {choiceId && <IdBadge id={choiceId} />}
</div> </div>
@@ -17,7 +17,7 @@ import {
subYears, subYears,
} from "date-fns"; } from "date-fns";
import { TFunction } from "i18next"; 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -37,7 +37,8 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter"; import { cn } from "@/modules/ui/lib/utils";
import { ResponseFilter } from "./ResponseFilter";
enum DateSelected { enum DateSelected {
FROM = "common.from", FROM = "common.from",
@@ -136,7 +137,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM); const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false); const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false); const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null); const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false); const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -270,179 +270,201 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
useClickOutside(datePickerRef, () => handleDatePickerClose()); useClickOutside(datePickerRef, () => handleDatePickerClose());
return ( return (
<div className="relative flex justify-between"> <>
<div className="flex justify-stretch gap-x-1.5"> <div className="relative flex justify-between">
<ResponseFilter survey={survey} /> <div className="flex justify-stretch gap-x-1.5">
<DropdownMenu <ResponseFilter survey={survey} />
onOpenChange={(value) => { <DropdownMenu
value && handleDatePickerClose(); onOpenChange={(value) => {
setIsFilterDropDownOpen(value); value && handleDatePickerClose();
}}> setIsFilterDropDownOpen(value);
<DropdownMenuTrigger asChild> }}>
<PopoverTriggerButton isOpen={isFilterDropDownOpen}> <DropdownMenuTrigger>
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE <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">
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${ <span className="text-sm text-slate-700">
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date" {filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
}` ? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
: filterRange} dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
</PopoverTriggerButton> }`
</DropdownMenuTrigger> : filterRange}
<DropdownMenuContent> </span>
<DropdownMenuItem {isFilterDropDownOpen ? (
onClick={() => { <ChevronUp className="ml-2 h-4 w-4 opacity-50" />
setFilterRange(getFilterDropDownLabels(t).ALL_TIME); ) : (
setDateRange({ from: undefined, to: getTodayDate() }); <ChevronDown className="ml-2 h-4 w-4 opacity-50" />
}}> )}
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p> </div>
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuItem <DropdownMenuContent>
onClick={() => { <DropdownMenuItem
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS); onClick={() => {
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() }); setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
}}> setDateRange({ from: undefined, to: getTodayDate() });
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p> }}>
</DropdownMenuItem> <p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS); onClick={() => {
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() }); setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
}}> setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p> }}>
</DropdownMenuItem> <p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH); onClick={() => {
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() }); setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
}}> setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p> }}>
</DropdownMenuItem> <p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH); onClick={() => {
setDateRange({ setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
from: startOfMonth(subMonths(new Date(), 1)), setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
to: endOfMonth(subMonths(getTodayDate(), 1)), }}>
}); <p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
}}> </DropdownMenuItem>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p> <DropdownMenuItem
</DropdownMenuItem> onClick={() => {
<DropdownMenuItem setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
onClick={() => { setDateRange({
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER); from: startOfMonth(subMonths(new Date(), 1)),
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) }); to: endOfMonth(subMonths(getTodayDate(), 1)),
}}> });
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p> }}>
</DropdownMenuItem> <p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER); onClick={() => {
setDateRange({ setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
from: startOfQuarter(subQuarters(new Date(), 1)), setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
to: endOfQuarter(subQuarters(getTodayDate(), 1)), }}>
}); <p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
}}> </DropdownMenuItem>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p> <DropdownMenuItem
</DropdownMenuItem> onClick={() => {
<DropdownMenuItem setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
onClick={() => { setDateRange({
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS); from: startOfQuarter(subQuarters(new Date(), 1)),
setDateRange({ to: endOfQuarter(subQuarters(getTodayDate(), 1)),
from: startOfMonth(subMonths(new Date(), 6)), });
to: endOfMonth(getTodayDate()), }}>
}); <p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
}}> </DropdownMenuItem>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p> <DropdownMenuItem
</DropdownMenuItem> onClick={() => {
<DropdownMenuItem setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
onClick={() => { setDateRange({
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR); from: startOfMonth(subMonths(new Date(), 6)),
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) }); to: endOfMonth(getTodayDate()),
}}> });
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p> }}>
</DropdownMenuItem> <p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR); onClick={() => {
setDateRange({ setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
from: startOfYear(subYears(new Date(), 1)), setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
to: endOfYear(subYears(getTodayDate(), 1)), }}>
}); <p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
}}> </DropdownMenuItem>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p> <DropdownMenuItem
</DropdownMenuItem> onClick={() => {
<DropdownMenuItem setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
onClick={() => { setDateRange({
setIsDatePickerOpen(true); from: startOfYear(subYears(new Date(), 1)),
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE); to: endOfYear(subYears(getTodayDate(), 1)),
setSelectingDate(DateSelected.FROM); });
}}> }}>
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p> <p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> <DropdownMenuItem
</DropdownMenu> onClick={() => {
<DropdownMenu setIsDatePickerOpen(true);
onOpenChange={(value) => { setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
value && handleDatePickerClose(); setSelectingDate(DateSelected.FROM);
setIsDownloadDropDownOpen(value); }}>
}}> <p className="text-sm text-slate-700 hover:ring-0">
<DropdownMenuTrigger asChild> {getFilterDropDownLabels(t).CUSTOM_RANGE}
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}> </p>
<span className="flex items-center gap-2"> </DropdownMenuItem>
{t("common.download")} </DropdownMenuContent>
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />} </DropdownMenu>
</span> <DropdownMenu
</PopoverTriggerButton> onOpenChange={(value) => {
</DropdownMenuTrigger> 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"> <DropdownMenuContent align="start">
<DropdownMenuItem <DropdownMenuItem
data-testid="fb__custom-filter-download-all-csv" data-testid="fb__custom-filter-download-all-csv"
onClick={async () => { onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv"); await handleDownloadResponses(FilterDownload.ALL, "csv");
}}> }}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p> <p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
data-testid="fb__custom-filter-download-all-xlsx" data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => { onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx"); await handleDownloadResponses(FilterDownload.ALL, "xlsx");
}}> }}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p> <p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-csv" data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => { onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv"); await handleDownloadResponses(FilterDownload.FILTER, "csv");
}}> }}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p> <p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-xlsx" data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => { onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx"); await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
}}> }}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p> <p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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",
}}
/>
</div> </div>
)} {isDatePickerOpen && (
</div> <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 clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react"; import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useMemo, useRef, useState } from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/modules/ui/components/command"; } from "@/modules/ui/components/command";
@@ -50,160 +48,117 @@ export const QuestionFilterComboBox = ({
disabled = false, disabled = false,
fieldId, fieldId,
}: QuestionFilterComboBoxProps) => { }: QuestionFilterComboBoxProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = React.useState(false);
const commandRef = useRef(null); const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState(""); const commandRef = React.useRef(null);
const { t } = useTranslation(); const [searchQuery, setSearchQuery] = React.useState<string>("");
useClickOutside(commandRef, () => setOpen(false));
const defaultLanguageCode = "default"; 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 // when question type is multi selection so we remove the option from the options which has been already selected
const isMultiple = useMemo( const options = isMultiple
() => ? filterComboBoxOptions?.filter(
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti || (o) =>
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || !filterComboBoxValue?.includes(
type === TSurveyQuestionTypeEnum.PictureSelection || typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either"), )
[type, filterValue] )
); : filterComboBoxOptions;
// Filter out already selected options for multi-select // disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
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
const isDisabledComboBox = const isDisabledComboBox =
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) && (type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped"); (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"; const isTextInputField = type === OptionsType.META && fieldId === "url";
// Filter options based on search query const filteredOptions = options?.filter((o) =>
const filteredOptions = useMemo( (typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
() => .toLowerCase()
options?.filter((o) => { .includes(searchQuery.toLowerCase())
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery, defaultLanguageCode]
); );
const handleCommandItemSelect = (o: string) => { const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o; <p className="text-slate-600">{filterComboBoxValue}</p>
) : (
if (isMultiple) { <div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value]; {typeof filterComboBoxValue !== "string" &&
onChangeFilterComboBoxValue(newValue); filterComboBoxValue?.map((o, index) => (
return; <button
} key={`${o}-${index}`}
type="button"
onChangeFilterComboBoxValue(value); onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
setOpen(false); 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" />
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue; </button>
))}
const handleOpenDropdown = () => { </div>
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>
); );
// Render multi-select tags const commandItemOnSelect = (o: string) => {
const renderMultiSelectTags = () => { if (!isMultiple) {
if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) { onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
return null; } else {
} onChangeFilterComboBoxValue(
Array.isArray(filterComboBoxValue)
return ( ? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
<div className="no-scrollbar flex grow gap-2 overflow-auto"> : [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
{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>
); );
} }
if (!isMultiple) {
if (Array.isArray(filterComboBoxValue)) { setOpen(false);
return renderMultiSelectTags();
} }
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
}; };
return ( return (
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400"> <div className="inline-flex w-full flex-row">
{filterOptions && filterOptions.length <= 1 ? ( {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"> <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 sm:max-w-[100px]">{filterValue}</p> <p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
</div> </div>
) : ( ) : (
<DropdownMenu <DropdownMenu
onOpenChange={(value) => { onOpenChange={(value) => {
if (value) setOpen(false); value && setOpen(false);
setOpenFilterValue(value);
}}> }}>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled} disabled={disabled}
className={clsx( 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", "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 ? "opacity-50" : "cursor-pointer hover:bg-slate-50" !disabled ? "cursor-pointer" : "opacity-50"
)}> )}>
{filterValue ? ( <div className="flex items-center justify-between">
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p> {!filterValue ? (
) : ( <p className="text-slate-400">{t("common.select")}...</p>
<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 && ( )}
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" /> {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> </DropdownMenuTrigger>
<DropdownMenuContent className="bg-white"> <DropdownMenuContent className="bg-white p-2">
{filterOptions?.map((o, index) => ( {filterOptions?.map((o, index) => (
<DropdownMenuItem <DropdownMenuItem
key={`${o}-${index}`} 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)}> onClick={() => onChangeFilterValue(o)}>
{o} {o}
</DropdownMenuItem> </DropdownMenuItem>
@@ -211,78 +166,78 @@ export const QuestionFilterComboBox = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
{isTextInputField ? ( {isTextInputField ? (
<Input <Input
type="text" type="text"
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""} value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)} onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
disabled={isComboBoxDisabled} disabled={disabled || !filterValue}
placeholder={t("common.enter_url")}
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0" 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"> <Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
<div <div
role="button"
tabIndex={isComboBoxDisabled ? -1 : 0}
className={clsx( className={clsx(
"flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2", "group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50" )}>
{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} <button
onKeyDown={(e) => { type="button"
const isActivationKey = e.key === "Enter" || e.key === " "; onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
if (isActivationKey && !isComboBoxDisabled) { disabled={disabled || isDisabledComboBox || !filterValue}
e.preventDefault(); className={clsx(
handleOpenDropdown(); "ml-2 flex items-center justify-center",
} disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
}}> )}>
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div> {open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
<Button ) : (
onClick={(e) => { <ChevronDown className="h-4 w-4 opacity-50" />
e.stopPropagation(); )}
if (isComboBoxDisabled) return; </button>
setOpen(!open);
}}
disabled={isComboBoxDisabled}
variant="secondary"
size="icon"
className="flex-shrink-0"
aria-expanded={open}
aria-label={t("common.select")}>
<ChevronIcon />
</Button>
</div> </div>
<div className="relative mt-2 h-full">
{open && ( {open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none"> <div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList className="max-h-52"> <CommandList>
<CommandInput <div className="p-2">
value={searchQuery} <Input
onValueChange={setSearchQuery} type="text"
placeholder={`${t("common.search")}...`} autoFocus
className="border-none" placeholder={t("common.search") + "..."}
/> value={searchQuery}
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty> onChange={(e) => setSearchQuery(e.target.value)}
<CommandGroup> className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
{filteredOptions?.map((o) => { />
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o; </div>
return ( <CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem <CommandItem
key={optionValue} key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => handleCommandItemSelect(o)} onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer"> className="cursor-pointer">
{optionValue} {typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem> </CommandItem>
); ))}
})} </CommandGroup>
</CommandGroup> </CommandList>
</CommandList> </div>
</div> )}
)} </div>
</Command> </Command>
)} )}
</div> </div>
@@ -32,7 +32,6 @@ import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -112,46 +111,51 @@ const questionIcons = {
const getIcon = (type: string) => { const getIcon = (type: string) => {
const IconComponent = questionIcons[type]; const IconComponent = questionIcons[type];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null; return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : 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";
}; };
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => { export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getDisplayIcon = () => { const getIconType = () => {
if (!type) return null; if (type) {
if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType); if (type === OptionsType.QUESTIONS && questionType) {
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES); return getIcon(questionType);
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS); } else if (type === OptionsType.ATTRIBUTES) {
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label); return getIcon(OptionsType.ATTRIBUTES);
if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS); } else if (type === OptionsType.HIDDEN_FIELDS) {
if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS); return getIcon(OptionsType.HIDDEN_FIELDS);
return null; } 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 ( return (
<div className="flex h-full min-w-0 items-center gap-2"> <div className="flex h-5 w-[12rem] items-center sm:w-4/5">
<span <span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
className={clsx( <p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
"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))}>
{typeof label === "string" ? label : getLocalizedValue(label, "default")} {typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p> </p>
</div> </div>
@@ -165,74 +169,64 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
useClickOutside(commandRef, () => setOpen(false)); useClickOutside(commandRef, () => setOpen(false));
const hasSelection = selected.hasOwnProperty("label");
const ChevronIcon = open ? ChevronUp : ChevronDown;
return ( return (
<Command <Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
ref={commandRef} <button
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400"> onClick={() => setOpen(true)}
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */} className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
<div {!open && selected.hasOwnProperty("label") && (
role="button" <SelectedCommandItem
tabIndex={0} label={selected?.label}
className="flex cursor-pointer items-center justify-between" type={selected?.type}
onClick={() => !open && setOpen(true)} questionType={selected?.questionType}
onKeyDown={(e) => { />
if (e.key === "Enter" || e.key === " ") { )}
e.preventDefault(); {(open || !selected.hasOwnProperty("label")) && (
!open && setOpen(true);
}
}}>
{!open && hasSelection && <SelectedCommandItem {...selected} />}
{(open || !hasSelection) && (
<CommandInput <CommandInput
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")} placeholder={t("common.search") + "..."}
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" 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 <div>
onClick={(e) => { {open ? (
e.stopPropagation(); <ChevronUp className="ml-2 h-4 w-4 opacity-50" />
setOpen(!open); ) : (
}} <ChevronDown className="ml-2 h-4 w-4 opacity-50" />
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> </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> </Command>
); );
}; };
@@ -31,32 +31,6 @@ export type QuestionFilterOptions = {
id: string; 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 { interface ResponseFilterProps {
survey: TSurvey; survey: TSurvey;
} }
@@ -134,6 +108,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
clearItem(); clearItem();
handleApplyFilters();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]); }, [isOpen]);
@@ -152,9 +127,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}; };
const handleClearAllFilters = () => { const handleClearAllFilters = () => {
const clearedFilters = { filter: [], responseStatus: "all" as const }; setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
setFilterValue(clearedFilters); setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
setSelectedFilter(clearedFilters);
setIsOpen(false); setIsOpen(false);
}; };
@@ -210,6 +184,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}; };
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (!open) {
handleApplyFilters();
}
setIsOpen(open); setIsOpen(open);
}; };
@@ -219,26 +196,36 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
return ( return (
<Popover open={isOpen} onOpenChange={handleOpenChange}> <Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <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">
<PopoverTriggerButton isOpen={isOpen}> <span>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b> 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> </PopoverTrigger>
<PopoverContent <PopoverContent
align="start" 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()}> onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-6 flex flex-wrap items-start justify-between gap-2"> <div className="mb-8 flex flex-wrap items-start justify-between gap-2">
<p className="font-semibold text-slate-800"> <p className="text-slate800 hidden text-lg font-semibold sm:block">
{t("environments.surveys.summary.show_all_responses_that_match")} {t("environments.surveys.summary.show_all_responses_that_match")}
</p> </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"> <div className="flex items-center space-x-2">
<Select <Select
value={filterValue.responseStatus ?? "all"}
onValueChange={(val) => { onValueChange={(val) => {
handleResponseStatusChange(val as TResponseStatus); handleResponseStatusChange(val as TResponseStatus);
}}> }}
<SelectTrigger className="w-full bg-white text-slate-700"> defaultValue={filterValue.responseStatus}>
<SelectTrigger className="w-full bg-white">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
@@ -298,38 +285,35 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
/> />
</div> </div>
<div className="flex w-full items-center justify-end gap-1 md:w-auto"> <div className="flex w-full items-center justify-end gap-1 md:w-auto">
<Button <p className="block font-light text-slate-500 md:hidden">Delete</p>
variant="secondary" <TrashIcon
size="icon" className="w-4 cursor-pointer text-slate-500 md:text-black"
onClick={() => handleDeleteFilter(i)} onClick={() => handleDeleteFilter(i)}
aria-label={t("common.delete")}> />
<TrashIcon />
</Button>
</div> </div>
</div> </div>
{i !== filterValue.filter.length - 1 && ( {i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center"> <div className="my-6 flex items-center">
<p className="mr-4 font-semibold text-slate-800">and</p> <p className="mr-6 text-base text-slate-600">And</p>
<hr className="w-full text-slate-600" /> <hr className="w-full text-slate-600" />
</div> </div>
)} )}
</React.Fragment> </React.Fragment>
))} ))}
</div> </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"> <div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
{t("common.add_filter")}
<Plus />
</Button>
<Button size="sm" onClick={handleApplyFilters}> <Button size="sm" onClick={handleApplyFilters}>
{t("common.apply_filters")} {t("common.apply_filters")}
</Button> </Button>
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
{t("common.clear_all")}
</Button>
</div> </div>
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
{t("common.clear_all")}
<TrashIcon />
</Button>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -5,7 +5,6 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service"; import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -152,23 +151,6 @@ export const PUT = withV1ApiWrapper({
const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data); const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data);
auditLog.newObject = updated; auditLog.newObject = updated;
sendToPipeline({
event: "responseUpdated",
environmentId: result.survey.environmentId,
surveyId: result.survey.id,
response: updated,
});
if (updated.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: result.survey.environmentId,
surveyId: result.survey.id,
response: updated,
});
}
return { return {
response: responses.successResponse(updated), response: responses.successResponse(updated),
}; };
@@ -5,7 +5,6 @@ import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/res
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils"; import { validateFileUploads } from "@/modules/storage/utils";
@@ -157,23 +156,6 @@ export const POST = withV1ApiWrapper({
const response = await createResponseWithQuotaEvaluation(responseInput); const response = await createResponseWithQuotaEvaluation(responseInput);
auditLog.targetId = response.id; auditLog.targetId = response.id;
auditLog.newObject = response; auditLog.newObject = response;
sendToPipeline({
event: "responseCreated",
environmentId: surveyResult.survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (response.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: surveyResult.survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
return { return {
response: responses.successResponse(response, true), response: responses.successResponse(response, true),
}; };
+3
View File
@@ -0,0 +1,3 @@
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
export default LinkSurveyLoading;
+2 -6
View File
@@ -173,7 +173,6 @@ checksums:
common/edit: eee7f39ff90b18852afc1671f21fbaa9 common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/email: e7f34943a0c2fb849db1839ff6ef5cb5 common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22 common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5 common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5 common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
@@ -183,8 +182,6 @@ checksums:
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51 common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0 common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad common/expand_rows: b6e06327cb8718dfd6651720843e4dad
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
common/finish: ffa7a10f71182b48fefed7135bee24fa common/finish: ffa7a10f71182b48fefed7135bee24fa
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885 common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
@@ -331,7 +328,6 @@ checksums:
common/segments: 271db72d5b973fbc5fadab216177eaae common/segments: 271db72d5b973fbc5fadab216177eaae
common/select: 5ac04c47a98deb85906bc02e0de91ab0 common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2 common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_survey: bac52e59c7847417bef6fe7b7096b475 common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9 common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56 common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -825,6 +821,7 @@ checksums:
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02 environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5 environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
environments/projects_environments_organizations_not_found: 9d450087c4035083f93bda9aa1889c43
environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905 environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905
environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a
environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093 environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093
@@ -1576,8 +1573,6 @@ checksums:
environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127 environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127
environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5 environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5
environments/surveys/responses/address_line_2: fc4b5a87de46ac4a28a6616f47a34135 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/an_error_occurred_deleting_the_tag: c63f28ac2a4cda558423ea7f975d5b8b
environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f
environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70 environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70
@@ -1774,6 +1769,7 @@ checksums:
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53 environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515 environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515
environments/surveys/summary/show_all_responses_that_match: c199f03983d7fcdd5972cc2759558c68 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: 3153990a4ade414f501a7e63ab771362
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6 environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd
+11 -6
View File
@@ -49,6 +49,7 @@ export const AZUREAD_TENANT_ID = env.AZUREAD_TENANT_ID;
export const OIDC_CLIENT_ID = env.OIDC_CLIENT_ID; export const OIDC_CLIENT_ID = env.OIDC_CLIENT_ID;
export const OIDC_CLIENT_SECRET = env.OIDC_CLIENT_SECRET; export const OIDC_CLIENT_SECRET = env.OIDC_CLIENT_SECRET;
export const OIDC_ISSUER = env.OIDC_ISSUER; export const OIDC_ISSUER = env.OIDC_ISSUER;
export const OIDC_ISSUER_INTERNAL = env.OIDC_ISSUER_INTERNAL;
export const OIDC_DISPLAY_NAME = env.OIDC_DISPLAY_NAME; export const OIDC_DISPLAY_NAME = env.OIDC_DISPLAY_NAME;
export const OIDC_SIGNING_ALGORITHM = env.OIDC_SIGNING_ALGORITHM; export const OIDC_SIGNING_ALGORITHM = env.OIDC_SIGNING_ALGORITHM;
@@ -182,17 +183,21 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
export enum PROJECT_FEATURE_KEYS { export enum PROJECT_FEATURE_KEYS {
FREE = "free", FREE = "free",
STARTUP = "startup", STARTUP = "startup",
CUSTOM = "custom", SCALE = "scale",
ENTERPRISE = "enterprise",
} }
export enum STRIPE_PROJECT_NAMES { export enum STRIPE_PROJECT_NAMES {
STARTUP = "Formbricks Startup", STARTUP = "Formbricks Startup",
CUSTOM = "Formbricks Custom", SCALE = "Formbricks Scale",
ENTERPRISE = "Formbricks Enterprise",
} }
export enum STRIPE_PRICE_LOOKUP_KEYS { export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY", STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY", STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
SCALE_MONTHLY = "formbricks_scale_monthly",
SCALE_YEARLY = "formbricks_scale_yearly",
} }
export const BILLING_LIMITS = { export const BILLING_LIMITS = {
@@ -206,10 +211,10 @@ export const BILLING_LIMITS = {
RESPONSES: 5000, RESPONSES: 5000,
MIU: 7500, MIU: 7500,
}, },
CUSTOM: { SCALE: {
PROJECTS: null, PROJECTS: 5,
RESPONSES: null, RESPONSES: 10000,
MIU: null, MIU: 30000,
}, },
} as const; } as const;
+2
View File
@@ -52,6 +52,7 @@ export const env = createEnv({
OIDC_CLIENT_SECRET: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_DISPLAY_NAME: z.string().optional(), OIDC_DISPLAY_NAME: z.string().optional(),
OIDC_ISSUER: z.string().optional(), OIDC_ISSUER: z.string().optional(),
OIDC_ISSUER_INTERNAL: z.string().optional(),
OIDC_SIGNING_ALGORITHM: z.string().optional(), OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(), OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL: REDIS_URL:
@@ -182,6 +183,7 @@ export const env = createEnv({
OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET, OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET,
OIDC_DISPLAY_NAME: process.env.OIDC_DISPLAY_NAME, OIDC_DISPLAY_NAME: process.env.OIDC_DISPLAY_NAME,
OIDC_ISSUER: process.env.OIDC_ISSUER, OIDC_ISSUER: process.env.OIDC_ISSUER,
OIDC_ISSUER_INTERNAL: process.env.OIDC_ISSUER_INTERNAL,
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM, OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL, REDIS_URL: process.env.REDIS_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED, PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
+2 -2
View File
@@ -53,9 +53,9 @@ export const I18nProvider = ({ children, language, defaultLanguage }: I18nProvid
initializeI18n(); initializeI18n();
}, [locale, defaultLanguage]); }, [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) { if (!isReady) {
return null; return <div style={{ visibility: "hidden" }}>{children}</div>;
} }
return ( return (
+5 -6
View File
@@ -200,7 +200,6 @@
"edit": "Bearbeiten", "edit": "Bearbeiten",
"email": "E-Mail", "email": "E-Mail",
"ending_card": "Abschluss-Karte", "ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz", "enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden", "environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.", "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_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten", "error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern", "expand_rows": "Zeilen erweitern",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_projects": "Fehler beim Laden der Projekte",
"finish": "Fertigstellen", "finish": "Fertigstellen",
"follow_these": "Folge diesen", "follow_these": "Folge diesen",
"formbricks_version": "Formbricks Version", "formbricks_version": "Formbricks Version",
@@ -358,7 +355,6 @@
"segments": "Segmente", "segments": "Segmente",
"select": "Auswählen", "select": "Auswählen",
"select_all": "Alles auswählen", "select_all": "Alles auswählen",
"select_filter": "Filter auswählen",
"select_survey": "Umfrage auswählen", "select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen", "select_teams": "Teams auswählen",
"selected": "Ausgewählt", "selected": "Ausgewählt",
@@ -890,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." "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": { "segments": {
"add_filter_below": "Filter unten hinzufügen", "add_filter_below": "Filter unten hinzufügen",
"add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen", "add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen",
@@ -986,12 +983,15 @@
"manage_subscription": "Abonnement verwalten", "manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich", "monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer", "monthly_identified_users": "Monatlich identifizierte Nutzer",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert", "plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs", "premium_support_with_slas": "Premium-Support mit SLAs",
"remove_branding": "Branding entfernen", "remove_branding": "Branding entfernen",
"startup": "Start-up", "startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.", "startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln", "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", "team_access_roles": "Rollen für Teammitglieder",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden", "unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_miu": "Unbegrenzte MIU", "unlimited_miu": "Unbegrenzte MIU",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "Adresszeile 1", "address_line_1": "Adresszeile 1",
"address_line_2": "Adresszeile 2", "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", "an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
"browser": "Browser", "browser": "Browser",
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?", "bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
@@ -1882,6 +1880,7 @@
"setup_integrations": "Integrationen einrichten", "setup_integrations": "Integrationen einrichten",
"share_survey": "Umfrage teilen", "share_survey": "Umfrage teilen",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen", "show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
"starts": "Startet", "starts": "Startet",
"starts_tooltip": "So oft wurde die Umfrage gestartet.", "starts_tooltip": "So oft wurde die Umfrage gestartet.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.", "survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
+5 -6
View File
@@ -200,7 +200,6 @@
"edit": "Edit", "edit": "Edit",
"email": "Email", "email": "Email",
"ending_card": "Ending card", "ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License", "enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found", "environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.", "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_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded", "error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows", "expand_rows": "Expand rows",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_projects": "Failed to load projects",
"finish": "Finish", "finish": "Finish",
"follow_these": "Follow these", "follow_these": "Follow these",
"formbricks_version": "Formbricks Version", "formbricks_version": "Formbricks Version",
@@ -358,7 +355,6 @@
"segments": "Segments", "segments": "Segments",
"select": "Select", "select": "Select",
"select_all": "Select all", "select_all": "Select all",
"select_filter": "Select filter",
"select_survey": "Select Survey", "select_survey": "Select Survey",
"select_teams": "Select teams", "select_teams": "Select teams",
"selected": "Selected", "selected": "Selected",
@@ -890,6 +886,7 @@
"team_settings_description": "See which teams can access this project." "team_settings_description": "See which teams can access this project."
} }
}, },
"projects_environments_organizations_not_found": "Projects, environments or organizations not found",
"segments": { "segments": {
"add_filter_below": "Add filter below", "add_filter_below": "Add filter below",
"add_your_first_filter_to_get_started": "Add your first filter to get started", "add_your_first_filter_to_get_started": "Add your first filter to get started",
@@ -986,12 +983,15 @@
"manage_subscription": "Manage Subscription", "manage_subscription": "Manage Subscription",
"monthly": "Monthly", "monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users", "monthly_identified_users": "Monthly Identified Users",
"per_month": "per month",
"per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully", "plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs", "premium_support_with_slas": "Premium support with SLAs",
"remove_branding": "Remove Branding", "remove_branding": "Remove Branding",
"startup": "Startup", "startup": "Startup",
"startup_description": "Everything in Free with additional features.", "startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan", "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", "team_access_roles": "Team Access Roles",
"unable_to_upgrade_plan": "Unable to upgrade plan", "unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_miu": "Unlimited MIU", "unlimited_miu": "Unlimited MIU",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "Address Line 1", "address_line_1": "Address Line 1",
"address_line_2": "Address Line 2", "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", "an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
"browser": "Browser", "browser": "Browser",
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?", "bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
@@ -1882,6 +1880,7 @@
"setup_integrations": "Setup integrations", "setup_integrations": "Setup integrations",
"share_survey": "Share survey", "share_survey": "Share survey",
"show_all_responses_that_match": "Show all responses that match", "show_all_responses_that_match": "Show all responses that match",
"show_all_responses_where": "Show all responses where...",
"starts": "Starts", "starts": "Starts",
"starts_tooltip": "Number of times the survey has been started.", "starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.", "survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
+157 -158
View File
@@ -113,7 +113,7 @@
"account_settings": "Paramètres du compte", "account_settings": "Paramètres du compte",
"action": "Action", "action": "Action",
"actions": "Actions", "actions": "Actions",
"actions_description": "Les actions avec et sans code permettent de déclencher des enquêtes dans des applications et sur des sites Web.", "actions_description": "Les actions avec ou sans code sont utilisées pour déclencher des enquêtes d'interception dans les applications et sur les sites Web.",
"active_surveys": "Sondages actifs", "active_surveys": "Sondages actifs",
"activity": "Activité", "activity": "Activité",
"add": "Ajouter", "add": "Ajouter",
@@ -127,7 +127,7 @@
"all": "Tout", "all": "Tout",
"all_questions": " toutes les questions", "all_questions": " toutes les questions",
"allow": "Autoriser", "allow": "Autoriser",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête", "allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de sortir en cliquant en dehors de l'enquête",
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s", "an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
"and": "Et", "and": "Et",
"and_response_limit_of": "et limite de réponse de", "and_response_limit_of": "et limite de réponse de",
@@ -144,7 +144,7 @@
"bottom_left": "En bas à gauche", "bottom_left": "En bas à gauche",
"bottom_right": "En bas à droite", "bottom_right": "En bas à droite",
"cancel": "Annuler", "cancel": "Annuler",
"centered_modal": "Au centre", "centered_modal": "Modal centré",
"choices": "Choix", "choices": "Choix",
"choose_environment": "Choisir l'environnement", "choose_environment": "Choisir l'environnement",
"choose_organization": "Choisir l'organisation", "choose_organization": "Choisir l'organisation",
@@ -180,7 +180,7 @@
"created_at": "Créé le", "created_at": "Créé le",
"created_by": "Créé par", "created_by": "Créé par",
"customer_success": "Succès Client", "customer_success": "Succès Client",
"dark_overlay": "Foncée", "dark_overlay": "Superposition sombre",
"date": "Date", "date": "Date",
"default": "Par défaut", "default": "Par défaut",
"delete": "Supprimer", "delete": "Supprimer",
@@ -188,7 +188,7 @@
"dev_env": "Environnement de développement", "dev_env": "Environnement de développement",
"development_environment_banner": "Vous êtes dans un environnement de développement. Configurez-le pour tester des enquêtes, des actions et des attributs.", "development_environment_banner": "Vous êtes dans un environnement de développement. Configurez-le pour tester des enquêtes, des actions et des attributs.",
"disable": "Désactiver", "disable": "Désactiver",
"disallow": "Ne pas autoriser", "disallow": "Ne pas permettre",
"discard": "Annuler", "discard": "Annuler",
"dismissed": "Rejeté", "dismissed": "Rejeté",
"docs": "Documentation", "docs": "Documentation",
@@ -200,7 +200,6 @@
"edit": "Modifier", "edit": "Modifier",
"email": "Email", "email": "Email",
"ending_card": "Carte de fin", "ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise", "enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé", "environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.", "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_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée", "error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes", "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", "finish": "Terminer",
"follow_these": "Suivez ceci", "follow_these": "Suivez ceci",
"formbricks_version": "Version de Formbricks", "formbricks_version": "Version de Formbricks",
@@ -243,7 +240,7 @@
"label": "Étiquette", "label": "Étiquette",
"language": "Langue", "language": "Langue",
"learn_more": "En savoir plus", "learn_more": "En savoir plus",
"light_overlay": "Claire", "light_overlay": "Superposition légère",
"limits_reached": "Limites atteints", "limits_reached": "Limites atteints",
"link": "Lien", "link": "Lien",
"link_survey": "Enquête de lien", "link_survey": "Enquête de lien",
@@ -252,7 +249,7 @@
"loading": "Chargement", "loading": "Chargement",
"logo": "Logo", "logo": "Logo",
"logout": "Déconnexion", "logout": "Déconnexion",
"look_and_feel": "Apparence", "look_and_feel": "Apparence et sensation",
"manage": "Gérer", "manage": "Gérer",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Max", "maximum": "Max",
@@ -272,7 +269,7 @@
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !", "new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
"next": "Suivant", "next": "Suivant",
"no_background_image_found": "Aucune image de fond trouvée.", "no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Sans code", "no_code": "Pas de code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.", "no_files_uploaded": "Aucun fichier n'a été téléchargé.",
"no_quotas_found": "Aucun quota trouvé", "no_quotas_found": "Aucun quota trouvé",
"no_result_found": "Aucun résultat trouvé", "no_result_found": "Aucun résultat trouvé",
@@ -293,7 +290,7 @@
"option_ids": "Identifiants des options", "option_ids": "Identifiants des options",
"or": "ou", "or": "ou",
"organization": "Organisation", "organization": "Organisation",
"organization_id": "Identifiant de l'organisation", "organization_id": "ID de l'organisation",
"organization_not_found": "Organisation non trouvée", "organization_not_found": "Organisation non trouvée",
"organization_settings": "Paramètres de l'organisation", "organization_settings": "Paramètres de l'organisation",
"organization_teams_not_found": "Équipes d'organisation non trouvées", "organization_teams_not_found": "Équipes d'organisation non trouvées",
@@ -339,12 +336,12 @@
"remove": "Retirer", "remove": "Retirer",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes", "reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête", "report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif", "request_pricing": "Demander la tarification",
"request_trial_license": "Demander une licence d'essai", "request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut", "reset_to_default": "Réinitialiser par défaut",
"response": "Réponse", "response": "Réponse",
"responses": "Réponses", "responses": "Réponses",
"restart": "Recommencer", "restart": "Redémarrer",
"role": "Rôle", "role": "Rôle",
"role_organization": "Rôle (Organisation)", "role_organization": "Rôle (Organisation)",
"saas": "SaaS", "saas": "SaaS",
@@ -358,7 +355,6 @@
"segments": "Segments", "segments": "Segments",
"select": "Sélectionner", "select": "Sélectionner",
"select_all": "Sélectionner tout", "select_all": "Sélectionner tout",
"select_filter": "Sélectionner un filtre",
"select_survey": "Sélectionner l'enquête", "select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes", "select_teams": "Sélectionner les équipes",
"selected": "Sélectionné", "selected": "Sélectionné",
@@ -368,7 +364,7 @@
"send_test_email": "Envoyer un e-mail de test", "send_test_email": "Envoyer un e-mail de test",
"session_not_found": "Session non trouvée", "session_not_found": "Session non trouvée",
"settings": "Paramètres", "settings": "Paramètres",
"share_feedback": "Partager des commentaires", "share_feedback": "Partager des retours",
"show": "Montrer", "show": "Montrer",
"show_response_count": "Afficher le nombre de réponses", "show_response_count": "Afficher le nombre de réponses",
"shown": "Montré", "shown": "Montré",
@@ -379,7 +375,7 @@
"something_went_wrong": "Quelque chose s'est mal passé.", "something_went_wrong": "Quelque chose s'est mal passé.",
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.", "something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
"sort_by": "Trier par", "sort_by": "Trier par",
"start_free_trial": "Essayer gratuitement", "start_free_trial": "Commencer l'essai gratuit",
"status": "Statut", "status": "Statut",
"step_by_step_manual": "Manuel étape par étape", "step_by_step_manual": "Manuel étape par étape",
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer", "storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
@@ -397,12 +393,12 @@
"surveys": "Enquêtes", "surveys": "Enquêtes",
"switch_to": "Passer à {environment}", "switch_to": "Passer à {environment}",
"table_items_deleted_successfully": "{type}s supprimés avec succès", "table_items_deleted_successfully": "{type}s supprimés avec succès",
"table_settings": "Paramètres du tableau", "table_settings": "Réglages de table",
"tags": "Balises", "tags": "Étiquettes",
"targeting": "Ciblage", "targeting": "Ciblage",
"team": "Équipe", "team": "Équipe",
"team_access": "Accès", "team_access": "Accès Équipe",
"team_id": "Identifiant de l'équipe", "team_id": "Équipe ID",
"team_name": "Nom de l'équipe", "team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès", "teams": "Contrôle d'accès",
"teams_not_found": "Équipes non trouvées", "teams_not_found": "Équipes non trouvées",
@@ -410,8 +406,8 @@
"time": "Temps", "time": "Temps",
"time_to_finish": "Temps de finir", "time_to_finish": "Temps de finir",
"title": "Titre", "title": "Titre",
"top_left": "En haut à gauche", "top_left": "Haut Gauche",
"top_right": "En haut à droite", "top_right": "Haut Droit",
"try_again": "Réessayer", "try_again": "Réessayer",
"type": "Type", "type": "Type",
"unlock_more_projects_with_a_higher_plan": "Débloquez plus de projets avec un plan supérieur.", "unlock_more_projects_with_a_higher_plan": "Débloquez plus de projets avec un plan supérieur.",
@@ -419,7 +415,7 @@
"updated": "Mise à jour", "updated": "Mise à jour",
"updated_at": "Mis à jour à", "updated_at": "Mis à jour à",
"upload": "Télécharger", "upload": "Télécharger",
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.", "upload_input_description": "Cliquez ou faites glisser pour télécharger des fichiers.",
"url": "URL", "url": "URL",
"user": "Utilisateur", "user": "Utilisateur",
"user_id": "Identifiant d'utilisateur", "user_id": "Identifiant d'utilisateur",
@@ -432,7 +428,7 @@
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.", "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.",
"webhook": "Webhook", "webhook": "Webhook",
"webhooks": "Webhooks", "webhooks": "Webhooks",
"website_and_app_connection": "Connexion de sites Web et d'applications", "website_and_app_connection": "Connexion Site Web & Application",
"website_app_survey": "Sondage sur le site Web et l'application", "website_app_survey": "Sondage sur le site Web et l'application",
"website_survey": "Sondage de site web", "website_survey": "Sondage de site web",
"welcome_card": "Carte de bienvenue", "welcome_card": "Carte de bienvenue",
@@ -446,7 +442,7 @@
}, },
"emails": { "emails": {
"accept": "Accepter", "accept": "Accepter",
"click_or_drag_to_upload_files": "Cliquez ou faites glisser pour charger un fichier.", "click_or_drag_to_upload_files": "Cliquez ou faites glisser pour télécharger des fichiers.",
"email_customization_preview_email_heading": "Salut {userName}", "email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_subject": "Aperçu de la personnalisation des e-mails Formbricks", "email_customization_preview_email_subject": "Aperçu de la personnalisation des e-mails Formbricks",
"email_customization_preview_email_text": "C'est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.", "email_customization_preview_email_text": "C'est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
@@ -521,7 +517,7 @@
"action_with_key_already_exists": "L'action avec la clé '{'key'}' existe déjà", "action_with_key_already_exists": "L'action avec la clé '{'key'}' existe déjà",
"action_with_name_already_exists": "L'action avec le nom '{'name'}' existe déjà", "action_with_name_already_exists": "L'action avec le nom '{'name'}' existe déjà",
"add_css_class_or_id": "Ajouter une classe ou un identifiant CSS", "add_css_class_or_id": "Ajouter une classe ou un identifiant CSS",
"add_regular_expression_here": "Ajouter une expression régulière", "add_regular_expression_here": "Ajoutez une expression régulière ici",
"add_url": "Ajouter une URL", "add_url": "Ajouter une URL",
"click": "Cliquez", "click": "Cliquez",
"contains": "Contient", "contains": "Contient",
@@ -529,19 +525,19 @@
"css_selector": "Sélecteur CSS", "css_selector": "Sélecteur CSS",
"delete_action_text": "Êtes-vous sûr de vouloir supprimer cette action ? Cela supprime également cette action en tant que déclencheur de toutes vos enquêtes.", "delete_action_text": "Êtes-vous sûr de vouloir supprimer cette action ? Cela supprime également cette action en tant que déclencheur de toutes vos enquêtes.",
"does_not_contain": "Ne contient pas", "does_not_contain": "Ne contient pas",
"does_not_exactly_match": "Ne correspond pas exactement à", "does_not_exactly_match": "Ne correspond pas exactement",
"eg_clicked_download": "Exemple : Cliqué sur Télécharger", "eg_clicked_download": "Par exemple, cliqué sur Télécharger",
"eg_download_cta_click_on_home": "Exemple : Cliquer sur le CTA de téléchargement sur la page d'accueil", "eg_download_cta_click_on_home": "Par exemple, cliquez sur le CTA de téléchargement sur la page d'accueil",
"eg_install_app": "Exemple : Installer l'application", "eg_install_app": "Par exemple, installer l'application",
"ends_with": "Se termine par", "ends_with": "Se termine par",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Saisissez une URL pour savoir si les actions d'un utilisateur consultant la page correspondante seront suivies.", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Saisissez une URL pour voir si un utilisateur la visitant serait suivi.",
"enter_url": "Exemple : https://app.com/dashboard", "enter_url": "par exemple https://app.com/dashboard",
"exactly_matches": "Correspond exactement à", "exactly_matches": "Correspondance exacte",
"exit_intent": "Intention de sortie", "exit_intent": "Intention de sortie",
"fifty_percent_scroll": "Consultation à 50 %", "fifty_percent_scroll": "50% Défilement",
"how_do_code_actions_work": "Comment fonctionnent les actions de code ?", "how_do_code_actions_work": "Comment fonctionnent les actions de code ?",
"if_a_user_clicks_a_button_with_a_specific_css_class_or_id": "Si l'utilisateur clique sur un bouton associé à une classe ou un identifiant CSS spécifique", "if_a_user_clicks_a_button_with_a_specific_css_class_or_id": "Si un utilisateur clique sur un bouton avec une classe ou un identifiant CSS spécifique",
"if_a_user_clicks_a_button_with_a_specific_text": "Si l'utilisateur clique sur un bouton comportant du texte", "if_a_user_clicks_a_button_with_a_specific_text": "Si un utilisateur clique sur un bouton avec un texte spécifique",
"in_your_code_read_more_in_our": "dans votre code. En savoir plus dans notre", "in_your_code_read_more_in_our": "dans votre code. En savoir plus dans notre",
"inner_text": "Texte interne", "inner_text": "Texte interne",
"invalid_action_type_code": "Type d'action invalide pour action code", "invalid_action_type_code": "Type d'action invalide pour action code",
@@ -549,27 +545,27 @@
"invalid_css_selector": "Sélecteur CSS invalide", "invalid_css_selector": "Sélecteur CSS invalide",
"invalid_match_type": "L'option sélectionnée n'est pas disponible.", "invalid_match_type": "L'option sélectionnée n'est pas disponible.",
"invalid_regex": "Veuillez utiliser une expression régulière valide.", "invalid_regex": "Veuillez utiliser une expression régulière valide.",
"limit_the_pages_on_which_this_action_gets_captured": "Vous pouvez limiter le nombre de pages sur lesquelles cette action se déclenche.", "limit_the_pages_on_which_this_action_gets_captured": "Limiter les pages sur lesquelles cette action est capturée",
"limit_to_specific_pages": "Sur certaines pages", "limit_to_specific_pages": "Limiter à des pages spécifiques",
"matches_regex": "Correspond à l'expression régulière", "matches_regex": "Correspond à l'expression régulière",
"on_all_pages": "Sur toutes les pages", "on_all_pages": "Sur toutes les pages",
"page_filter": "Filtrage des pages", "page_filter": "Filtre de page",
"page_view": "Vue de page", "page_view": "Vue de page",
"select_match_type": "Sélectionner le type de match", "select_match_type": "Sélectionner le type de match",
"starts_with": "Commence par", "starts_with": "Commence par",
"test_match": "Match de test", "test_match": "Match de test",
"test_your_url": "Test de l'URL", "test_your_url": "Testez votre URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Cette action a été créée automatiquement. Vous ne pouvez pas y apporter de modifications.", "this_action_was_created_automatically_you_cannot_make_changes_to_it": "Cette action a été créée automatiquement. Vous ne pouvez pas y apporter de modifications.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Cette action se déclenche quand une page est chargée.", "this_action_will_be_triggered_when_the_page_is_loaded": "Cette action sera déclenchée lorsque la page sera chargée.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Cette action se déclenche quand un utilisateur consulte 50 % d'une page.", "this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Cette action sera déclenchée lorsque l'utilisateur fera défiler 50 % de la page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Cette action se déclenche quand un utilisateur tente de quitter une page.", "this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Cette action sera déclenchée lorsque l'utilisateur essaiera de quitter la page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ceci est une action de code. Veuillez apporter des modifications à votre base de code.", "this_is_a_code_action_please_make_changes_in_your_code_base": "Ceci est une action de code. Veuillez apporter des modifications à votre base de code.",
"track_new_user_action": "Suivi des actions d'un utilisateur", "track_new_user_action": "Suivre l'action des nouveaux utilisateurs",
"track_user_action_to_display_surveys_or_create_user_segment": "Vous pouvez suivre les actions d'un utilisateur pour afficher des enquêtes ou créer des segments ad hoc.", "track_user_action_to_display_surveys_or_create_user_segment": "Suivre l'action de l'utilisateur pour afficher des enquêtes ou créer un segment d'utilisateur.",
"url": "URL", "url": "URL",
"user_actions": "Actions utilisateur", "user_actions": "Actions de l'utilisateur",
"user_clicked_download_button": "L'utilisateur a cliqué sur le bouton Télécharger", "user_clicked_download_button": "L'utilisateur a cliqué sur le bouton de téléchargement",
"what_did_your_user_do": "Qu'a fait l'utilisateur ?", "what_did_your_user_do": "Que fait votre utilisateur ?",
"what_is_the_user_doing": "Que fait l'utilisateur ?", "what_is_the_user_doing": "Que fait l'utilisateur ?",
"you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant", "you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant",
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.", "your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
@@ -589,7 +585,7 @@
"contacts": { "contacts": {
"contact_deleted_successfully": "Contact supprimé avec succès", "contact_deleted_successfully": "Contact supprimé avec succès",
"contact_not_found": "Aucun contact trouvé", "contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Actualiser les contacts", "contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès", "contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.", "delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}", "delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
@@ -604,23 +600,23 @@
"upload_contacts_modal_attributes_search_or_add": "Rechercher ou ajouter un attribut", "upload_contacts_modal_attributes_search_or_add": "Rechercher ou ajouter un attribut",
"upload_contacts_modal_attributes_should_be_mapped_to": "devrait être mappé à", "upload_contacts_modal_attributes_should_be_mapped_to": "devrait être mappé à",
"upload_contacts_modal_attributes_title": "Attributs", "upload_contacts_modal_attributes_title": "Attributs",
"upload_contacts_modal_description": "Téléchargez un fichier CSV pour importer rapidement des contacts avec des attributs.", "upload_contacts_modal_description": "Téléchargez un CSV pour importer rapidement des contacts avec des attributs.",
"upload_contacts_modal_download_example_csv": "Télécharger un exemple de CSV", "upload_contacts_modal_download_example_csv": "Télécharger un exemple de CSV",
"upload_contacts_modal_duplicates_description": "Que faire si un contact existe déjà ?", "upload_contacts_modal_duplicates_description": "Comment devrions-nous procéder si un contact existe déjà dans vos contacts ?",
"upload_contacts_modal_duplicates_overwrite_description": "Les contacts existants sont écrasés.", "upload_contacts_modal_duplicates_overwrite_description": "Écrase les contacts existants",
"upload_contacts_modal_duplicates_overwrite_title": "Écraser", "upload_contacts_modal_duplicates_overwrite_title": "Sélectionner",
"upload_contacts_modal_duplicates_skip_description": "Les contacts en double sont ignorés.", "upload_contacts_modal_duplicates_skip_description": "Ignore les contacts en double",
"upload_contacts_modal_duplicates_skip_title": "Ignorer", "upload_contacts_modal_duplicates_skip_title": "Sauter",
"upload_contacts_modal_duplicates_title": "Doublons", "upload_contacts_modal_duplicates_title": "Doublons",
"upload_contacts_modal_duplicates_update_description": "Les contacts existants sont mis à jour.", "upload_contacts_modal_duplicates_update_description": "Mise à jour des contacts existants",
"upload_contacts_modal_duplicates_update_title": "Mettre à jour", "upload_contacts_modal_duplicates_update_title": "Mise à jour",
"upload_contacts_modal_pick_different_file": "Choisissez un fichier différent", "upload_contacts_modal_pick_different_file": "Choisissez un fichier différent",
"upload_contacts_modal_preview": "Voici un aperçu de vos données.", "upload_contacts_modal_preview": "Voici un aperçu de vos données.",
"upload_contacts_modal_upload_btn": "Importer des contacts" "upload_contacts_modal_upload_btn": "Importer des contacts"
}, },
"formbricks_logo": "Logo Formbricks", "formbricks_logo": "Logo Formbricks",
"integrations": { "integrations": {
"activepieces_integration_description": "Connectez instantanément Formbricks à des applications populaires pour automatiser des tâches sans effectuer de codage.", "activepieces_integration_description": "Connectez instantanément Formbricks avec des applications populaires pour automatiser les tâches sans coder.",
"additional_settings": "Paramètres supplémentaires", "additional_settings": "Paramètres supplémentaires",
"airtable": { "airtable": {
"airtable_base": "Base Airtable", "airtable_base": "Base Airtable",
@@ -639,13 +635,13 @@
"sync_responses_with_airtable": "Synchroniser les réponses avec un Airtable", "sync_responses_with_airtable": "Synchroniser les réponses avec un Airtable",
"table_name": "Nom de la table" "table_name": "Nom de la table"
}, },
"airtable_integration_description": "Renseignez instantanément votre table Airtable à l'aide de données issues d'enquêtes.", "airtable_integration_description": "Remplissez instantanément votre table Airtable avec des données d'enquête",
"connected_with_email": "Connecté avec {email}", "connected_with_email": "Connecté avec {email}",
"connecting_integration_failed_please_try_again": "Échec de la connexion d'intégration. Veuillez réessayer !", "connecting_integration_failed_please_try_again": "Échec de la connexion d'intégration. Veuillez réessayer !",
"create_survey_warning": "Vous devez créer une enquête pour pouvoir configurer cette intégration.", "create_survey_warning": "Vous devez créer une enquête pour pouvoir configurer cette intégration.",
"delete_integration": "Supprimer l'intégration", "delete_integration": "Supprimer l'intégration",
"delete_integration_confirmation": "Êtes-vous sûr de vouloir supprimer cette intégration ?", "delete_integration_confirmation": "Êtes-vous sûr de vouloir supprimer cette intégration ?",
"google_sheet_integration_description": "Renseignez instantanément vos feuilles de calcul à l'aide de données issues d'enquêtes.", "google_sheet_integration_description": "Remplissez instantanément vos feuilles de calcul avec des données d'enquête",
"google_sheets": { "google_sheets": {
"connect_with_google_sheets": "Se connecter à Google Sheets", "connect_with_google_sheets": "Se connecter à Google Sheets",
"enter_a_valid_spreadsheet_url_error": "Veuillez entrer une URL de feuille de calcul valide.", "enter_a_valid_spreadsheet_url_error": "Veuillez entrer une URL de feuille de calcul valide.",
@@ -668,9 +664,9 @@
"integration_added_successfully": "Intégration ajoutée avec succès", "integration_added_successfully": "Intégration ajoutée avec succès",
"integration_removed_successfully": "Intégration supprimée avec succès", "integration_removed_successfully": "Intégration supprimée avec succès",
"integration_updated_successfully": "Intégration mise à jour avec succès", "integration_updated_successfully": "Intégration mise à jour avec succès",
"make_integration_description": "Intégrez Formbricks à plus de 1 000 applications via Make", "make_integration_description": "Intégrez Formbricks avec plus de 1000 applications via Make",
"manage_webhooks": "Gérer les Webhooks", "manage_webhooks": "Gérer les Webhooks",
"n8n_integration_description": "Intégrez Formbricks à plus de 350 applications via n8n.", "n8n_integration_description": "Intégrez Formbricks avec plus de 350 applications via n8n.",
"notion": { "notion": {
"col_name_of_type_is_not_supported": "{col_name} de type {type} n'est pas pris en charge par l'API de Notion. Les données ne seront pas reflétées dans votre base de données Notion.", "col_name_of_type_is_not_supported": "{col_name} de type {type} n'est pas pris en charge par l'API de Notion. Les données ne seront pas reflétées dans votre base de données Notion.",
"connect_with_notion": "Se connecter avec Notion", "connect_with_notion": "Se connecter avec Notion",
@@ -698,7 +694,7 @@
"update_connection": "Reconnecter Notion", "update_connection": "Reconnecter Notion",
"update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes." "update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes."
}, },
"notion_integration_description": "Envoyez des données à votre base de données Notion.", "notion_integration_description": "Envoyer des données à votre base de données Notion",
"please_select_a_survey_error": "Veuillez sélectionner une enquête.", "please_select_a_survey_error": "Veuillez sélectionner une enquête.",
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.", "select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
"slack": { "slack": {
@@ -720,9 +716,9 @@
"slack_reconnect_button": "Reconnecter", "slack_reconnect_button": "Reconnecter",
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack." "slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
}, },
"slack_integration_description": "Connectez instantanément votre espace de travail Slack à Formbricks.", "slack_integration_description": "Connectez instantanément votre espace de travail Slack avec Formbricks",
"to_configure_it": "pour le configurer.", "to_configure_it": "pour le configurer.",
"webhook_integration_description": "Déclenchez des webhooks en fonction des actions effectuées dans vos enquêtes.", "webhook_integration_description": "Déclenchez des Webhooks en fonction des actions dans vos enquêtes",
"webhooks": { "webhooks": {
"add_webhook": "Ajouter un Webhook", "add_webhook": "Ajouter un Webhook",
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé", "add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
@@ -748,8 +744,8 @@
"webhook_updated_successfully": "Webhook mis à jour avec succès.", "webhook_updated_successfully": "Webhook mis à jour avec succès.",
"webhook_url_placeholder": "Collez l'URL sur laquelle vous souhaitez que l'événement se déclenche" "webhook_url_placeholder": "Collez l'URL sur laquelle vous souhaitez que l'événement se déclenche"
}, },
"website_or_app_integration_description": "Intégrez Formbricks à votre site Web ou à votre application.", "website_or_app_integration_description": "Intégrez Formbricks dans votre site Web ou votre application.",
"zapier_integration_description": "Intégrez Formbricks à plus de 5 000 applications via Zapier." "zapier_integration_description": "Intégrez Formbricks avec plus de 5000 applications via Zapier."
}, },
"project": { "project": {
"api_keys": { "api_keys": {
@@ -773,39 +769,39 @@
"unable_to_delete_api_key": "Impossible de supprimer la clé API" "unable_to_delete_api_key": "Impossible de supprimer la clé API"
}, },
"app-connection": { "app-connection": {
"app_connection": "Connexion d'une application", "app_connection": "Connexion d'application",
"app_connection_description": "Vous pouvez connecter une application à Formbricks.", "app_connection_description": "Connectez votre application à Formbricks.",
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, cela peut prendre jusqu'à 5 minutes pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks. Ce délai est dû à une limitation de notre système de mise en cache actuel. Nous retravaillons activement le cache et publierons une correction dans Formbricks 4.0.", "cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, cela peut prendre jusqu'à 5 minutes pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks. Ce délai est dû à une limitation de notre système de mise en cache actuel. Nous retravaillons activement le cache et publierons une correction dans Formbricks 4.0.",
"cache_update_delay_title": "Les modifications seront reflétées après 5 minutes en raison de la mise en cache", "cache_update_delay_title": "Les modifications seront reflétées après 5 minutes en raison de la mise en cache",
"environment_id": "Identifiant de votre environnement", "environment_id": "Votre identifiant d'environnement",
"environment_id_description": "Cet identifiant unique est attribué à votre environnement Formbricks.", "environment_id_description": "Cet identifiant identifie de manière unique cet environnement Formbricks.",
"formbricks_sdk_connected": "Le SDK Formbricks est connecté", "formbricks_sdk_connected": "Le SDK Formbricks est connecté",
"formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.", "formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.",
"formbricks_sdk_not_connected_description": "Connectez votre site Web ou votre application à Formbricks.", "formbricks_sdk_not_connected_description": "Connectez votre site web ou votre application à Formbricks.",
"how_to_setup": "Comment configurer", "how_to_setup": "Comment configurer",
"how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.", "how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.",
"receiving_data": "Réception des données 💃🕺", "receiving_data": "Réception des données 💃🕺",
"recheck": "Réessayer", "recheck": "Re-vérifier",
"setup_alert_description": "Suivez les indications de ce tutoriel pour connecter votre application ou votre site Web en moins de cinq minutes.", "setup_alert_description": "Suivez ce tutoriel étape par étape pour connecter votre application ou site web en moins de 5 minutes.",
"setup_alert_title": "Connexion" "setup_alert_title": "Comment connecter"
}, },
"general": { "general": {
"cannot_delete_only_project": "Comme il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.", "cannot_delete_only_project": "Ceci est votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
"delete_project": "Supprimer le projet", "delete_project": "Supprimer le projet",
"delete_project_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.", "delete_project_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.",
"delete_project_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.", "delete_project_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_project_settings_description": "Vous pouvez supprimer un projet et l'intégralité des enquêtes, réponses, personnes, actions et attributs qui lui sont associés. Cette opération est irréversible.", "delete_project_settings_description": "Supprimer le projet avec toutes les enquêtes, réponses, personnes, actions et attributs. Cela ne peut pas être annulé.",
"error_saving_project_information": "Erreur lors de l'enregistrement des informations du projet", "error_saving_project_information": "Erreur lors de l'enregistrement des informations du projet",
"only_owners_or_managers_can_delete_projects": "Seuls les propriétaires ou les gestionnaires peuvent supprimer des projets.", "only_owners_or_managers_can_delete_projects": "Seuls les propriétaires ou les gestionnaires peuvent supprimer des projets.",
"project_deleted_successfully": "Projet supprimé avec succès", "project_deleted_successfully": "Projet supprimé avec succès",
"project_name_settings_description": "Vous pouvez modifier le nom de votre projet.", "project_name_settings_description": "Changez le nom de votre projet.",
"project_name_updated_successfully": "Le nom du projet a été mis à jour avec succès.", "project_name_updated_successfully": "Le nom du projet a été mis à jour avec succès.",
"recontact_waiting_time": "Délai avant nouveau contact", "recontact_waiting_time": "Temps d'attente pour le recontact",
"recontact_waiting_time_settings_description": "Vous pouvez contrôler la fréquence à laquelle les utilisateurs sont sollicités pour répondre aux enquêtes.", "recontact_waiting_time_settings_description": "Contrôlez la fréquence à laquelle les utilisateurs peuvent être sondés dans toutes les enquêtes de l'application.",
"this_action_cannot_be_undone": "Cette action ne peut pas être annulée.", "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 :", "wait_x_days_before_showing_next_survey": "Attendre X jours avant de montrer la prochaine enquête :",
"waiting_period_updated_successfully": "Le délai d'attente a été mis à jour avec succès", "waiting_period_updated_successfully": "Le délai d'attente a été mis à jour avec succès",
"whats_your_project_called": "Quel est le nom de votre projet ?" "whats_your_project_called": "Comment s'appelle votre projet ?"
}, },
"languages": { "languages": {
"add_language": "Ajouter une langue", "add_language": "Ajouter une langue",
@@ -822,8 +818,8 @@
"language": "Langue", "language": "Langue",
"language_deleted_successfully": "Langue supprimée avec succès", "language_deleted_successfully": "Langue supprimée avec succès",
"languages_updated_successfully": "Langues mises à jour avec succès", "languages_updated_successfully": "Langues mises à jour avec succès",
"multi_language_surveys": "Enquêtes multilingues", "multi_language_surveys": "Sondages multilingues",
"multi_language_surveys_description": "Vous pouvez ajouter des langues pour créer des enquêtes multilingues.", "multi_language_surveys_description": "Ajoutez des langues pour créer des enquêtes multilingues.",
"no_language_found": "Aucune langue trouvée. Ajoutez votre première langue ci-dessous.", "no_language_found": "Aucune langue trouvée. Ajoutez votre première langue ci-dessous.",
"please_select_a_language": "Veuillez sélectionner une langue.", "please_select_a_language": "Veuillez sélectionner une langue.",
"remove_language": "Supprimer la langue", "remove_language": "Supprimer la langue",
@@ -834,25 +830,25 @@
"look": { "look": {
"add_background_color": "Ajouter une couleur de fond", "add_background_color": "Ajouter une couleur de fond",
"add_background_color_description": "Ajoutez une couleur de fond au conteneur du logo.", "add_background_color_description": "Ajoutez une couleur de fond au conteneur du logo.",
"app_survey_placement": "Emplacement des enquêtes", "app_survey_placement": "Placement de l'enquête dans l'application",
"app_survey_placement_settings_description": "Vous pouvez choisir l'emplacement des enquêtes dans votre application ou sur votre site Web.", "app_survey_placement_settings_description": "Changez l'emplacement où les enquêtes seront affichées dans votre application web ou votre site web.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée", "centered_modal_overlay_color": "Couleur de superposition modale centrée",
"email_customization": "Personnalisation des e-mails", "email_customization": "Personnalisation des e-mails",
"email_customization_description": "Vous pouvez modifier l'apparence des e-mails envoyés par Formbricks en votre nom.", "email_customization_description": "Modifiez l'apparence des e-mails envoyés par Formbricks en votre nom.",
"enable_custom_styling": "Activer la personnalisation", "enable_custom_styling": "Activer le style personnalisé",
"enable_custom_styling_description": "Permet aux utilisateurs de remplacer ce thème par un autre dans l'éditeur d'enquête.", "enable_custom_styling_description": "Permettre aux utilisateurs de remplacer ce thème dans l'éditeur d'enquête.",
"failed_to_remove_logo": "Échec de la suppression du logo", "failed_to_remove_logo": "Échec de la suppression du logo",
"failed_to_update_logo": "Échec de la mise à jour du logo", "failed_to_update_logo": "Échec de la mise à jour du logo",
"formbricks_branding": "Logo Formbricks", "formbricks_branding": "Marque Formbricks",
"formbricks_branding_hidden": "La marque Formbricks est cachée.", "formbricks_branding_hidden": "La marque Formbricks est cachée.",
"formbricks_branding_settings_description": "Nous comprenons que vous préfériez vous en passer.", "formbricks_branding_settings_description": "Nous apprécions votre soutien mais comprenons si vous le désactivez.",
"formbricks_branding_shown": "La marque Formbricks est affichée.", "formbricks_branding_shown": "La marque Formbricks est affichée.",
"logo_removed_successfully": "Logo supprimé avec succès", "logo_removed_successfully": "Logo supprimé avec succès",
"logo_settings_description": "Téléchargez le logo de votre entreprise pour personnaliser les enquêtes et les aperçus de lien.", "logo_settings_description": "Téléchargez le logo de votre entreprise pour personnaliser les enquêtes et les aperçus de lien.",
"logo_updated_successfully": "Logo mis à jour avec succès", "logo_updated_successfully": "Logo mis à jour avec succès",
"logo_upload_failed": "Échec du téléchargement du logo. Veuillez réessayer.", "logo_upload_failed": "Échec du téléchargement du logo. Veuillez réessayer.",
"placement_updated_successfully": "Placement mis à jour avec succès", "placement_updated_successfully": "Placement mis à jour avec succès",
"remove_branding_with_a_higher_plan": "Retirez le logo en passant à un forfait supérieur", "remove_branding_with_a_higher_plan": "Supprimer la marque avec un plan supérieur",
"remove_logo": "Supprimer le logo", "remove_logo": "Supprimer le logo",
"remove_logo_confirmation": "Êtes-vous sûr de vouloir supprimer le logo ?", "remove_logo_confirmation": "Êtes-vous sûr de vouloir supprimer le logo ?",
"replace_logo": "Remplacer le logo", "replace_logo": "Remplacer le logo",
@@ -862,16 +858,16 @@
"show_powered_by_formbricks": "Afficher la signature \"Propulsé par Formbricks", "show_powered_by_formbricks": "Afficher la signature \"Propulsé par Formbricks",
"styling_updated_successfully": "Style mis à jour avec succès", "styling_updated_successfully": "Style mis à jour avec succès",
"theme": "Thème", "theme": "Thème",
"theme_settings_description": "Vous pouvez créer un thème pour toutes les enquêtes et définir un style personnalisé pour chacune d'elles." "theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer le style personnalisé pour chaque enquête."
}, },
"tags": { "tags": {
"add": "Ajouter", "add": "Ajouter",
"add_tag": "Ajouter une étiquette", "add_tag": "Ajouter une étiquette",
"count": "Compter", "count": "Compter",
"delete_tag_confirmation": "Êtes-vous sûr de vouloir supprimer cette étiquette ?", "delete_tag_confirmation": "Êtes-vous sûr de vouloir supprimer cette étiquette ?",
"empty_message": "Ajoutez une balise à une réponse pour afficher votre liste de balises.", "empty_message": "Taguez une soumission pour trouver votre liste de tags ici.",
"manage_tags": "Gérer les étiquettes", "manage_tags": "Gérer les étiquettes",
"manage_tags_description": "Vous pouvez fusionner et supprimer des balises de réponse.", "manage_tags_description": "Fusionner et supprimer les balises de réponse.",
"merge": "Fusionner", "merge": "Fusionner",
"no_tag_found": "Aucun tag trouvé", "no_tag_found": "Aucun tag trouvé",
"search_tags": "Tags de recherche...", "search_tags": "Tags de recherche...",
@@ -887,18 +883,19 @@
"only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.", "only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.",
"permission": "Permission", "permission": "Permission",
"team_name": "Nom de l'équipe", "team_name": "Nom de l'équipe",
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet." "team_settings_description": "Les équipes et leurs membres peuvent accéder à ce projet et à ses enquêtes. Les propriétaires et les gestionnaires de l'organisation peuvent accorder cet accès."
} }
}, },
"projects_environments_organizations_not_found": "Projets, environnements ou organisations non trouvés",
"segments": { "segments": {
"add_filter_below": "Ajouter un filtre ci-dessous", "add_filter_below": "Ajouter un filtre ci-dessous",
"add_your_first_filter_to_get_started": "Ajoutez votre premier filtre pour commencer.", "add_your_first_filter_to_get_started": "Ajoutez votre premier filtre pour commencer",
"cannot_delete_segment_used_in_surveys": "Vous ne pouvez pas supprimer ce segment car il est encore utilisé dans ces enquêtes :", "cannot_delete_segment_used_in_surveys": "Vous ne pouvez pas supprimer ce segment car il est encore utilisé dans ces enquêtes :",
"clone_and_edit_segment": "Cloner et modifier le segment", "clone_and_edit_segment": "Cloner et modifier le segment",
"create_group": "Créer un groupe", "create_group": "Créer un groupe",
"create_your_first_segment": "Créez votre premier segment pour commencer.", "create_your_first_segment": "Créez votre premier segment pour commencer",
"delete_segment": "Supprimer le segment", "delete_segment": "Supprimer le segment",
"desktop": "Ordinateur", "desktop": "Bureau",
"devices": "Appareils", "devices": "Appareils",
"edit_segment": "Modifier le segment", "edit_segment": "Modifier le segment",
"error_resetting_filters": "Erreur lors de la réinitialisation des filtres", "error_resetting_filters": "Erreur lors de la réinitialisation des filtres",
@@ -915,8 +912,8 @@
"most_active_users_in_the_last_30_days": "Utilisateurs les plus actifs au cours des 30 derniers jours", "most_active_users_in_the_last_30_days": "Utilisateurs les plus actifs au cours des 30 derniers jours",
"no_attributes_yet": "Aucun attribut pour le moment !", "no_attributes_yet": "Aucun attribut pour le moment !",
"no_filters_yet": "Il n'y a pas encore de filtres !", "no_filters_yet": "Il n'y a pas encore de filtres !",
"no_segments_yet": "Aucun segment n'est actuellement enregistré.", "no_segments_yet": "Vous n'avez actuellement aucun segment enregistré.",
"person_and_attributes": "Personne et attributs", "person_and_attributes": "Personne et Attributs",
"phone": "Téléphone", "phone": "Téléphone",
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Veuillez supprimer le segment de ces enquêtes afin de le supprimer.", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Veuillez supprimer le segment de ces enquêtes afin de le supprimer.",
"pre_segment_users": "Précisez vos utilisateurs à l'avance avec des filtres d'attributs.", "pre_segment_users": "Précisez vos utilisateurs à l'avance avec des filtres d'attributs.",
@@ -929,7 +926,7 @@
"segment_id": "ID de segment", "segment_id": "ID de segment",
"segment_saved_successfully": "Segment enregistré avec succès", "segment_saved_successfully": "Segment enregistré avec succès",
"segment_updated_successfully": "Segment mis à jour avec succès !", "segment_updated_successfully": "Segment mis à jour avec succès !",
"segments_help_you_target_users_with_same_characteristics_easily": "Les segments permettent de cibler facilement les utilisateurs ayant les mêmes caractéristiques.", "segments_help_you_target_users_with_same_characteristics_easily": "Les segments vous aident à cibler facilement les utilisateurs ayant les mêmes caractéristiques.",
"target_audience": "Public cible", "target_audience": "Public cible",
"this_action_resets_all_filters_in_this_survey": "Cette action réinitialise tous les filtres de cette enquête.", "this_action_resets_all_filters_in_this_survey": "Cette action réinitialise tous les filtres de cette enquête.",
"this_segment_is_used_in_other_surveys": "Ce segment est utilisé dans d'autres enquêtes. Apportez des modifications.", "this_segment_is_used_in_other_surveys": "Ce segment est utilisé dans d'autres enquêtes. Apportez des modifications.",
@@ -948,59 +945,62 @@
"api_keys": { "api_keys": {
"add_api_key": "Ajouter une clé API", "add_api_key": "Ajouter une clé API",
"add_permission": "Ajouter une permission", "add_permission": "Ajouter une permission",
"api_keys_description": "Les clés d'API permettent d'accéder aux API de gestion de Formbricks." "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
}, },
"billing": { "billing": {
"1000_monthly_responses": "1 000 réponses mensuelles", "1000_monthly_responses": "1000 Réponses Mensuelles",
"1_project": "1 projet", "1_project": "1 Projet",
"2000_contacts": "2 000 contacts", "2000_contacts": "2 000 Contacts",
"3_projects": "3 projets", "3_projects": "3 Projets",
"5000_monthly_responses": "5 000 réponses mensuelles", "5000_monthly_responses": "5,000 Réponses Mensuelles",
"7500_contacts": "7 500 contacts", "7500_contacts": "7 500 Contacts",
"all_integrations": "Tout type d'intégration", "all_integrations": "Toutes les intégrations",
"annually": "Annuel", "annually": "Annuellement",
"api_webhooks": "API et webhooks", "api_webhooks": "API et Webhooks",
"app_surveys": "Enquêtes d'application", "app_surveys": "Sondages d'application",
"attribute_based_targeting": "Ciblage basé sur les attributs", "attribute_based_targeting": "Ciblage basé sur les attributs",
"current": "Actuel", "current": "Actuel",
"current_plan": "Forfait actuel", "current_plan": "Plan actuel",
"current_tier_limit": "Limite de niveau actuel", "current_tier_limit": "Limite de niveau actuel",
"custom": "Personnalisé et Échelle", "custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Personnalisation du nombre de contacts", "custom_contacts_limit": "Limite de contacts personnalisé",
"custom_project_limit": "Personnalisation du nombre de projets", "custom_project_limit": "Limite de projet personnalisé",
"custom_response_limit": "Personnalisation des réponses", "custom_response_limit": "Limite de réponse personnalisé",
"email_embedded_surveys": "Sondages intégrés à des e-mails", "email_embedded_surveys": "Sondages intégrés par e-mail",
"email_follow_ups": "Relances par e-mail", "email_follow_ups": "Relances par e-mail",
"enterprise_description": "Soutien premium et limites personnalisées.", "enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !", "everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Toutes les options du forfait Gratuit", "everything_in_free": "Tout est gratuit",
"everything_in_startup": "Toutes les options du forfait Initial", "everything_in_startup": "Tout dans le Startup",
"free": "Gratuit", "free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.", "free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits", "get_2_months_free": "Obtenez 2 mois gratuits",
"hosted_in_frankfurt": "Hébergement à Francfort", "hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les enquêtes sur mobile", "ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"link_surveys": "Enquêtes par lien (partageables)", "link_surveys": "Sondages par lien (partageables)",
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte", "manage_card_details": "Gérer les détails de la carte",
"manage_subscription": "Gérer l'abonnement", "manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel", "monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs mensuels identifiés", "monthly_identified_users": "Utilisateurs Identifiés Mensuels",
"per_month": "par mois",
"per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès", "plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Assistance premium avec accord de niveau de service", "premium_support_with_slas": "Soutien premium avec SLA",
"remove_branding": "Suppression du logo", "remove_branding": "Supprimer la marque",
"startup": "Initial", "startup": "Startup",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.", "startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan", "switch_plan": "Changer de plan",
"team_access_roles": "Gestion des accès", "switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Rôles d'accès d'équipe",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan", "unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_miu": "MIU Illimité", "unlimited_miu": "MIU Illimité",
"unlimited_projects": "Projets illimités", "unlimited_projects": "Projets illimités",
"unlimited_responses": "Réponses illimitées", "unlimited_responses": "Réponses illimitées",
"unlimited_surveys": "Enquêtes illimitées", "unlimited_surveys": "Sondages illimités",
"unlimited_team_members": "Membres d'équipe illimités", "unlimited_team_members": "Membres d'équipe illimités",
"upgrade": "Mise à niveau", "upgrade": "Mise à niveau",
"uptime_sla_99": "Disponibilité de 99 %", "uptime_sla_99": "SLA de disponibilité (99%)",
"website_surveys": "Sondages de site web" "website_surveys": "Sondages de site web"
}, },
"enterprise": { "enterprise": {
@@ -1031,15 +1031,15 @@
"copy_invite_link_to_clipboard": "Copier le lien d'invitation dans le presse-papiers", "copy_invite_link_to_clipboard": "Copier le lien d'invitation dans le presse-papiers",
"create_new_organization": "Créer une nouvelle organisation", "create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.", "create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
"customize_email_with_a_higher_plan": "Personnalisez vos e-mails en passant à un forfait supérieur", "customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.", "delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation", "delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Vous pouvez supprimer l'organisation, y compris l'intégralité des projets, enquêtes, réponses, personnes, actions et attributs qui y sont associés.", "delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :", "delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
"delete_organization_warning_1": "Suppression permanente de tous les projets liés à cette organisation.", "delete_organization_warning_1": "Suppression permanente de tous les projets liés à cette organisation.",
"delete_organization_warning_2": "Cette action ne peut pas être annulée. Si c'est parti, c'est parti.", "delete_organization_warning_2": "Cette action ne peut pas être annulée. Si c'est parti, c'est parti.",
"delete_organization_warning_3": "Veuillez entrer {organizationName} dans le champ suivant pour confirmer la suppression définitive de cette organisation :", "delete_organization_warning_3": "Veuillez entrer {organizationName} dans le champ suivant pour confirmer la suppression définitive de cette organisation :",
"eliminate_branding_with_whitelabel": "Le logo Formbricks n'apparaîtra plus et d'autres options de personnalisation s'offriront à vous.", "eliminate_branding_with_whitelabel": "Éliminez la marque Formbricks et activez des options de personnalisation supplémentaires.",
"email_customization_preview_email_heading": "Salut {userName}", "email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_text": "Cette est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.", "email_customization_preview_email_text": "Cette est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
"error_deleting_organization_please_try_again": "Erreur lors de la suppression de l'organisation. Veuillez réessayer.", "error_deleting_organization_please_try_again": "Erreur lors de la suppression de l'organisation. Veuillez réessayer.",
@@ -1056,16 +1056,16 @@
"logo_removed_successfully": "Logo supprimé avec succès", "logo_removed_successfully": "Logo supprimé avec succès",
"logo_saved_successfully": "Logo enregistré avec succès", "logo_saved_successfully": "Logo enregistré avec succès",
"manage_members": "Gérer les membres", "manage_members": "Gérer les membres",
"manage_members_description": "Vous pouvez ajouter ou supprimer des membres dans votre organisation.", "manage_members_description": "Ajouter ou supprimer des membres dans votre organisation.",
"member_deleted_successfully": "Membre supprimé avec succès", "member_deleted_successfully": "Membre supprimé avec succès",
"member_invited_successfully": "Membre invité avec succès", "member_invited_successfully": "Membre invité avec succès",
"once_its_gone_its_gone": "Cette opération est irréversible.", "once_its_gone_its_gone": "Une fois que c'est parti, c'est parti.",
"only_org_owner_can_perform_action": "Seules les personnes ayant un rôle d'administrateur dans l'organisation peuvent accéder à ce paramètre.", "only_org_owner_can_perform_action": "Seules les personnes ayant un rôle d'administrateur dans l'organisation peuvent accéder à ce paramètre.",
"organization_created_successfully": "Organisation créée avec succès !", "organization_created_successfully": "Organisation créée avec succès !",
"organization_deleted_successfully": "Organisation supprimée avec succès.", "organization_deleted_successfully": "Organisation supprimée avec succès.",
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !", "organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
"organization_name": "Nom de l'organisation", "organization_name": "Nom de l'organisation",
"organization_name_description": "Attribuez un nom descriptif à votre organisation.", "organization_name_description": "Donnez à votre organisation un nom descriptif.",
"organization_name_placeholder": "e.g. Power Puff Girls", "organization_name_placeholder": "e.g. Power Puff Girls",
"organization_name_updated_successfully": "Nom de l'organisation mis à jour avec succès", "organization_name_updated_successfully": "Nom de l'organisation mis à jour avec succès",
"organization_settings": "Paramètres de l'organisation", "organization_settings": "Paramètres de l'organisation",
@@ -1082,13 +1082,13 @@
"use_multi_language_surveys_with_a_higher_plan_description": "Interrogez vos utilisateurs dans différentes langues." "use_multi_language_surveys_with_a_higher_plan_description": "Interrogez vos utilisateurs dans différentes langues."
}, },
"notifications": { "notifications": {
"auto_subscribe_to_new_surveys": "S'abonner automatiquement aux nouvelles enquêtes", "auto_subscribe_to_new_surveys": "S'abonner automatiquement aux nouveaux sondages",
"email_alerts_surveys": "Alertes par e-mail (Enquêtes)", "email_alerts_surveys": "Alertes par e-mail (Enquêtes)",
"every_response": "Chaque réponse", "every_response": "Chaque réponse",
"every_response_tooltip": "Envoie des réponses complètes, pas de réponses partielles.", "every_response_tooltip": "Envoie des réponses complètes, pas de réponses partielles.",
"need_slack_or_discord_notifications": "Besoin de notifications Slack ou Discord", "need_slack_or_discord_notifications": "Besoin de notifications Slack ou Discord",
"notification_settings_updated": "Paramètres de notification mis à jour", "notification_settings_updated": "Paramètres de notification mis à jour",
"set_up_an_alert_to_get_an_email_on_new_responses": "Vous pouvez configurer une alerte afin d'être informé par e-mail en cas de nouvelles réponses.", "set_up_an_alert_to_get_an_email_on_new_responses": "Configurez une alerte pour recevoir un e-mail lors de nouvelles réponses.",
"use_the_integration": "Utilisez l'intégration", "use_the_integration": "Utilisez l'intégration",
"want_to_loop_in_organization_mates": "Voulez-vous inclure des collègues de l'organisation ?", "want_to_loop_in_organization_mates": "Voulez-vous inclure des collègues de l'organisation ?",
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Vous ne serez plus automatiquement abonné aux enquêtes de cette organisation !", "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Vous ne serez plus automatiquement abonné aux enquêtes de cette organisation !",
@@ -1097,7 +1097,7 @@
"profile": { "profile": {
"account_deletion_consequences_warning": "Conséquences de la suppression de compte", "account_deletion_consequences_warning": "Conséquences de la suppression de compte",
"backup_code": "Code de sauvegarde", "backup_code": "Code de sauvegarde",
"confirm_delete_account": "Vous pouvez supprimer votre compte, y compris toutes vos informations et données personnelles.", "confirm_delete_account": "Supprimez votre compte avec toutes vos informations personnelles et données.",
"confirm_delete_my_account": "Supprimer mon compte", "confirm_delete_my_account": "Supprimer mon compte",
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.", "confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
"delete_account": "Supprimer le compte", "delete_account": "Supprimer le compte",
@@ -1124,7 +1124,7 @@
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure", "unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
"update_personal_info": "Mettez à jour vos informations personnelles", "update_personal_info": "Mettez à jour vos informations personnelles",
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.", "warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
"warning_cannot_undo": "Cette opération est irréversible." "warning_cannot_undo": "Ceci ne peut pas être annulé"
}, },
"teams": { "teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.", "add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
@@ -1269,14 +1269,14 @@
"change_question_type": "Changer le type de question", "change_question_type": "Changer le type de question",
"change_survey_type": "Le changement de type de sondage affecte l'accès existant", "change_survey_type": "Le changement de type de sondage affecte l'accès existant",
"change_the_background_color_of_the_card": "Changez la couleur de fond de la carte.", "change_the_background_color_of_the_card": "Changez la couleur de fond de la carte.",
"change_the_background_color_of_the_input_fields": "Vous pouvez modifier la couleur d'arrière-plan des champs de saisie.", "change_the_background_color_of_the_input_fields": "Changez la couleur de fond des champs de saisie.",
"change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.", "change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.",
"change_the_border_color_of_the_card": "Changez la couleur de la bordure de la carte.", "change_the_border_color_of_the_card": "Changez la couleur de la bordure de la carte.",
"change_the_border_color_of_the_input_fields": "Vous pouvez modifier la couleur de la bordure des champs de saisie.", "change_the_border_color_of_the_input_fields": "Changez la couleur de la bordure des champs de saisie.",
"change_the_border_radius_of_the_card_and_the_inputs": "Vous pouvez arrondir la bordure des encadrés et des champs de saisie.", "change_the_border_radius_of_the_card_and_the_inputs": "Changez le rayon de bordure de la carte et des champs de saisie.",
"change_the_brand_color_of_the_survey": "Vous pouvez modifier la couleur dominante d'une enquête.", "change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.",
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.", "change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Vous pouvez modifier la couleur des questions d'une enquête.", "change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"changes_saved": "Modifications enregistrées.", "changes_saved": "Modifications enregistrées.",
"changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.", "changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.",
"character_limit_toggle_description": "Limitez la longueur des réponses.", "character_limit_toggle_description": "Limitez la longueur des réponses.",
@@ -1416,8 +1416,8 @@
"includes_one_of": "Comprend un de", "includes_one_of": "Comprend un de",
"initial_value": "Valeur initiale", "initial_value": "Valeur initiale",
"inner_text": "Texte interne", "inner_text": "Texte interne",
"input_border_color": "Couleur de la bordure des champs de saisie", "input_border_color": "Couleur de bordure d'entrée",
"input_color": "Couleur d'arrière-plan des champs de saisie", "input_color": "Couleur d'entrée",
"insert_link": "Insérer un lien", "insert_link": "Insérer un lien",
"invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience", "invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience",
"invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.", "invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.",
@@ -1494,7 +1494,7 @@
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.", "protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
"publish": "Publier", "publish": "Publier",
"question": "Question", "question": "Question",
"question_color": "Couleur des questions", "question_color": "Couleur de la question",
"question_deleted": "Question supprimée.", "question_deleted": "Question supprimée.",
"question_duplicated": "Question dupliquée.", "question_duplicated": "Question dupliquée.",
"question_id_updated": "ID de la question mis à jour", "question_id_updated": "ID de la question mis à jour",
@@ -1551,7 +1551,7 @@
"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_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.", "response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse", "response_options": "Options de réponse",
"roundness": "Rondeur", "roundness": "Rondité",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.", "row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"rows": "Lignes", "rows": "Lignes",
"save_and_close": "Enregistrer et fermer", "save_and_close": "Enregistrer et fermer",
@@ -1587,7 +1587,7 @@
"starts_with": "Commence par", "starts_with": "Commence par",
"state": "État", "state": "État",
"straight": "Droit", "straight": "Droit",
"style_the_question_texts_descriptions_and_input_fields": "Vous pouvez personnaliser le texte des questions, les descriptions et les champs de saisie.", "style_the_question_texts_descriptions_and_input_fields": "Stylisez les textes des questions, les descriptions et les champs de saisie.",
"style_the_survey_card": "Styliser la carte d'enquête.", "style_the_survey_card": "Styliser la carte d'enquête.",
"styling_set_to_theme_styles": "Style défini sur les styles du thème", "styling_set_to_theme_styles": "Style défini sur les styles du thème",
"subheading": "Sous-titre", "subheading": "Sous-titre",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "Ligne d'adresse 1", "address_line_1": "Ligne d'adresse 1",
"address_line_2": "Ligne d'adresse 2", "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.", "an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
"browser": "Navigateur", "browser": "Navigateur",
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?", "bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
@@ -1882,6 +1880,7 @@
"setup_integrations": "Configurer les intégrations", "setup_integrations": "Configurer les intégrations",
"share_survey": "Partager l'enquête", "share_survey": "Partager l'enquête",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes", "show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
"show_all_responses_where": "Afficher toutes les réponses où...",
"starts": "Commence", "starts": "Commence",
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.", "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.", "survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
@@ -2247,8 +2246,8 @@
"csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?", "csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?",
"csat_survey_question_3_placeholder": "Entrez votre réponse ici...", "csat_survey_question_3_placeholder": "Entrez votre réponse ici...",
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique", "cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
"custom_survey_description": "Créez une enquête sans utiliser de modèle.", "custom_survey_description": "Créer une enquête sans modèle.",
"custom_survey_name": "Tout créer moi-même", "custom_survey_name": "Commencer à zéro",
"custom_survey_question_1_headline": "Que voudriez-vous savoir ?", "custom_survey_question_1_headline": "Que voudriez-vous savoir ?",
"custom_survey_question_1_placeholder": "Entrez votre réponse ici...", "custom_survey_question_1_placeholder": "Entrez votre réponse ici...",
"customer_effort_score_description": "Déterminez à quel point il est facile d'utiliser une fonctionnalité.", "customer_effort_score_description": "Déterminez à quel point il est facile d'utiliser une fonctionnalité.",
@@ -2393,7 +2392,7 @@
"feedback_box_question_2_subheader": "Plus il y a de détails, mieux c'est :)", "feedback_box_question_2_subheader": "Plus il y a de détails, mieux c'est :)",
"feedback_box_question_3_button_label": "Oui, prévenez-moi", "feedback_box_question_3_button_label": "Oui, prévenez-moi",
"feedback_box_question_3_dismiss_button_label": "Non, merci", "feedback_box_question_3_dismiss_button_label": "Non, merci",
"feedback_box_question_3_headline": "Souhaitez-vous être informé ?", "feedback_box_question_3_headline": "Vous voulez rester informé ?",
"feedback_box_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nous allons régler cela dès que possible. Voulez-vous être informé lorsque ce sera fait ?</span></p>", "feedback_box_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nous allons régler cela dès que possible. Voulez-vous être informé lorsque ce sera fait ?</span></p>",
"feedback_box_question_4_button_label": "Demander une fonctionnalité", "feedback_box_question_4_button_label": "Demander une fonctionnalité",
"feedback_box_question_4_headline": "Charmant, dis-nous en plus !", "feedback_box_question_4_headline": "Charmant, dis-nous en plus !",
@@ -2652,9 +2651,9 @@
"preview_survey_question_1_subheader": "Ceci est un aperçu du sondage.", "preview_survey_question_1_subheader": "Ceci est un aperçu du sondage.",
"preview_survey_question_1_upper_label": "Très bien", "preview_survey_question_1_upper_label": "Très bien",
"preview_survey_question_2_back_button_label": "Retour", "preview_survey_question_2_back_button_label": "Retour",
"preview_survey_question_2_choice_1_label": "Oui, tenez-moi au courant.", "preview_survey_question_2_choice_1_label": "Oui, tiens-moi au courant.",
"preview_survey_question_2_choice_2_label": "Non, merci !", "preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?", "preview_survey_question_2_headline": "Vous voulez rester informé ?",
"preview_survey_welcome_card_headline": "Bienvenue !", "preview_survey_welcome_card_headline": "Bienvenue !",
"preview_survey_welcome_card_html": "Merci pour vos retours - allons-y !", "preview_survey_welcome_card_html": "Merci pour vos retours - allons-y !",
"prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.", "prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.",
+5 -6
View File
@@ -200,7 +200,6 @@
"edit": "編集", "edit": "編集",
"email": "メールアドレス", "email": "メールアドレス",
"ending_card": "終了カード", "ending_card": "終了カード",
"enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス", "enterprise_license": "エンタープライズライセンス",
"environment_not_found": "環境が見つかりません", "environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。", "environment_notice": "現在、{environment} 環境にいます。",
@@ -210,8 +209,6 @@
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。", "error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
"error_rate_limit_title": "レート制限を超えました", "error_rate_limit_title": "レート制限を超えました",
"expand_rows": "行を展開", "expand_rows": "行を展開",
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
"finish": "完了", "finish": "完了",
"follow_these": "こちらの手順に従って", "follow_these": "こちらの手順に従って",
"formbricks_version": "Formbricksバージョン", "formbricks_version": "Formbricksバージョン",
@@ -358,7 +355,6 @@
"segments": "セグメント", "segments": "セグメント",
"select": "選択", "select": "選択",
"select_all": "すべて選択", "select_all": "すべて選択",
"select_filter": "フィルターを選択",
"select_survey": "フォームを選択", "select_survey": "フォームを選択",
"select_teams": "チームを選択", "select_teams": "チームを選択",
"selected": "選択済み", "selected": "選択済み",
@@ -890,6 +886,7 @@
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。" "team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
} }
}, },
"projects_environments_organizations_not_found": "プロジェクト、環境、または組織が見つかりません",
"segments": { "segments": {
"add_filter_below": "下にフィルターを追加", "add_filter_below": "下にフィルターを追加",
"add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください", "add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください",
@@ -986,12 +983,15 @@
"manage_subscription": "サブスクリプションを管理", "manage_subscription": "サブスクリプションを管理",
"monthly": "月間", "monthly": "月間",
"monthly_identified_users": "月間識別ユーザー数", "monthly_identified_users": "月間識別ユーザー数",
"per_month": "月",
"per_year": "年",
"plan_upgraded_successfully": "プランを正常にアップグレードしました", "plan_upgraded_successfully": "プランを正常にアップグレードしました",
"premium_support_with_slas": "SLA付きプレミアムサポート", "premium_support_with_slas": "SLA付きプレミアムサポート",
"remove_branding": "ブランディングを削除", "remove_branding": "ブランディングを削除",
"startup": "スタートアップ", "startup": "スタートアップ",
"startup_description": "無料プランのすべての機能に追加機能。", "startup_description": "無料プランのすべての機能に追加機能。",
"switch_plan": "プランを切り替え", "switch_plan": "プランを切り替え",
"switch_plan_confirmation_text": "本当に {plan} プランに切り替えますか? {price} {period} が請求されます。",
"team_access_roles": "チームアクセスロール", "team_access_roles": "チームアクセスロール",
"unable_to_upgrade_plan": "プランをアップグレードできません", "unable_to_upgrade_plan": "プランをアップグレードできません",
"unlimited_miu": "無制限のMIU", "unlimited_miu": "無制限のMIU",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "住所1", "address_line_1": "住所1",
"address_line_2": "住所2", "address_line_2": "住所2",
"an_error_occurred_adding_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": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?", "bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
@@ -1882,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": "以下のすべての回答を表示...",
"starts": "開始", "starts": "開始",
"starts_tooltip": "フォームが開始された回数。", "starts_tooltip": "フォームが開始された回数。",
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。", "survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",
+5 -6
View File
@@ -200,7 +200,6 @@
"edit": "Editar", "edit": "Editar",
"email": "Email", "email": "Email",
"ending_card": "Cartão de encerramento", "ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial", "enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado", "environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.", "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_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido", "error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas", "expand_rows": "Expandir linhas",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Terminar", "finish": "Terminar",
"follow_these": "Siga esses", "follow_these": "Siga esses",
"formbricks_version": "Versão do Formbricks", "formbricks_version": "Versão do Formbricks",
@@ -358,7 +355,6 @@
"segments": "Segmentos", "segments": "Segmentos",
"select": "Selecionar", "select": "Selecionar",
"select_all": "Selecionar tudo", "select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_survey": "Selecionar Pesquisa", "select_survey": "Selecionar Pesquisa",
"select_teams": "Selecionar times", "select_teams": "Selecionar times",
"selected": "Selecionado", "selected": "Selecionado",
@@ -890,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." "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": { "segments": {
"add_filter_below": "Adicionar filtro abaixo", "add_filter_below": "Adicionar filtro abaixo",
"add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar", "add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar",
@@ -986,12 +983,15 @@
"manage_subscription": "Gerenciar Assinatura", "manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal", "monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente", "monthly_identified_users": "Usuários Identificados Mensalmente",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso", "plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs", "premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Remover Marca", "remove_branding": "Remover Marca",
"startup": "startup", "startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.", "startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano", "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", "team_access_roles": "Funções de Acesso da Equipe",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano", "unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado", "unlimited_miu": "MIU Ilimitado",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "Endereço Linha 1", "address_line_1": "Endereço Linha 1",
"address_line_2": "Complemento", "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", "an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
"browser": "navegador", "browser": "navegador",
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?", "bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
@@ -1882,6 +1880,7 @@
"setup_integrations": "Configurar integrações", "setup_integrations": "Configurar integrações",
"share_survey": "Compartilhar pesquisa", "share_survey": "Compartilhar pesquisa",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem", "show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
"show_all_responses_where": "Mostre todas as respostas onde...",
"starts": "começa", "starts": "começa",
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.", "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.", "survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
+91 -92
View File
@@ -58,7 +58,7 @@
"create_an_account": "Criar uma conta", "create_an_account": "Criar uma conta",
"enter_your_backup_code": "Introduza o seu código de backup", "enter_your_backup_code": "Introduza o seu código de backup",
"enter_your_two_factor_authentication_code": "Introduza o seu código de autenticação de dois fatores", "enter_your_two_factor_authentication_code": "Introduza o seu código de autenticação de dois fatores",
"forgot_your_password": "Esqueceu-se da sua palavra-passe?", "forgot_your_password": "Esqueceu a sua palavra-passe?",
"login_to_your_account": "Iniciar sessão na sua conta", "login_to_your_account": "Iniciar sessão na sua conta",
"login_with_email": "Iniciar sessão com Email", "login_with_email": "Iniciar sessão com Email",
"lost_access": "Perdeu o acesso?", "lost_access": "Perdeu o acesso?",
@@ -113,7 +113,7 @@
"account_settings": "Configurações da conta", "account_settings": "Configurações da conta",
"action": "Ação", "action": "Ação",
"actions": "Ações", "actions": "Ações",
"actions_description": "As ações com código e sem código são usadas para acionar pesquisas de interceptação em apps e em sites.", "actions_description": "Ações com Código e Sem Código são usadas para acionar inquéritos de intercepção dentro de apps e em websites.",
"active_surveys": "Inquéritos ativos", "active_surveys": "Inquéritos ativos",
"activity": "Atividade", "activity": "Atividade",
"add": "Adicionar", "add": "Adicionar",
@@ -127,14 +127,14 @@
"all": "Todos", "all": "Todos",
"all_questions": "Todas as perguntas", "all_questions": "Todas as perguntas",
"allow": "Permitir", "allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'", "allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam ao clicar fora do questionário",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s", "an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"and": "E", "and": "E",
"and_response_limit_of": "e limite de resposta de", "and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo", "anonymous": "Anónimo",
"api_keys": "Chaves API", "api_keys": "Chaves API",
"app": "Aplicação", "app": "Aplicação",
"app_survey": "Inquérito (app)", "app_survey": "Inquérito da Aplicação",
"apply_filters": "Aplicar filtros", "apply_filters": "Aplicar filtros",
"are_you_sure": "Tem a certeza?", "are_you_sure": "Tem a certeza?",
"attributes": "Atributos", "attributes": "Atributos",
@@ -200,7 +200,6 @@
"edit": "Editar", "edit": "Editar",
"email": "Email", "email": "Email",
"ending_card": "Cartão de encerramento", "ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise", "enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado", "environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.", "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_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido", "error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas", "expand_rows": "Expandir linhas",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Concluir", "finish": "Concluir",
"follow_these": "Siga estes", "follow_these": "Siga estes",
"formbricks_version": "Versão do Formbricks", "formbricks_version": "Versão do Formbricks",
@@ -246,8 +243,8 @@
"light_overlay": "Sobreposição leve", "light_overlay": "Sobreposição leve",
"limits_reached": "Limites Atingidos", "limits_reached": "Limites Atingidos",
"link": "Link", "link": "Link",
"link_survey": "Inquérito (link)", "link_survey": "Ligar Inquérito",
"link_surveys": "Inquéritos (links)", "link_surveys": "Ligar Inquéritos",
"load_more": "Carregar mais", "load_more": "Carregar mais",
"loading": "A carregar", "loading": "A carregar",
"logo": "Logótipo", "logo": "Logótipo",
@@ -301,7 +298,7 @@
"others": "Outros", "others": "Outros",
"overview": "Visão geral", "overview": "Visão geral",
"password": "Palavra-passe", "password": "Palavra-passe",
"paused": "Em pausa", "paused": "Pausado",
"pending_downgrade": "Rebaixamento Pendente", "pending_downgrade": "Rebaixamento Pendente",
"people_manager": "Gestor de Pessoas", "people_manager": "Gestor de Pessoas",
"person": "Pessoa", "person": "Pessoa",
@@ -358,7 +355,6 @@
"segments": "Segmentos", "segments": "Segmentos",
"select": "Selecionar", "select": "Selecionar",
"select_all": "Selecionar tudo", "select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_survey": "Selecionar Inquérito", "select_survey": "Selecionar Inquérito",
"select_teams": "Selecionar equipas", "select_teams": "Selecionar equipas",
"selected": "Selecionado", "selected": "Selecionado",
@@ -378,7 +374,7 @@
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar", "some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
"something_went_wrong": "Algo correu mal", "something_went_wrong": "Algo correu mal",
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.", "something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
"sort_by": "Ordem", "sort_by": "Ordenar por",
"start_free_trial": "Iniciar Teste Grátis", "start_free_trial": "Iniciar Teste Grátis",
"status": "Estado", "status": "Estado",
"step_by_step_manual": "Manual passo a passo", "step_by_step_manual": "Manual passo a passo",
@@ -538,7 +534,7 @@
"enter_url": "por exemplo, https://app.com/dashboard", "enter_url": "por exemplo, https://app.com/dashboard",
"exactly_matches": "Corresponde exatamente", "exactly_matches": "Corresponde exatamente",
"exit_intent": "Intenção de Saída", "exit_intent": "Intenção de Saída",
"fifty_percent_scroll": "Scroll 50%", "fifty_percent_scroll": "Rolar 50%",
"how_do_code_actions_work": "Como funcionam as Ações de Código?", "how_do_code_actions_work": "Como funcionam as Ações de Código?",
"if_a_user_clicks_a_button_with_a_specific_css_class_or_id": "Se um utilizador clicar num botão com uma classe ou id CSS específica", "if_a_user_clicks_a_button_with_a_specific_css_class_or_id": "Se um utilizador clicar num botão com uma classe ou id CSS específica",
"if_a_user_clicks_a_button_with_a_specific_text": "Se um utilizador clicar num botão com um texto específico", "if_a_user_clicks_a_button_with_a_specific_text": "Se um utilizador clicar num botão com um texto específico",
@@ -607,12 +603,12 @@
"upload_contacts_modal_description": "Carregue um ficheiro CSV para importar rapidamente contactos com atributos", "upload_contacts_modal_description": "Carregue um ficheiro CSV para importar rapidamente contactos com atributos",
"upload_contacts_modal_download_example_csv": "Descarregar exemplo de CSV", "upload_contacts_modal_download_example_csv": "Descarregar exemplo de CSV",
"upload_contacts_modal_duplicates_description": "Como devemos proceder se um contacto já existir nos seus contactos?", "upload_contacts_modal_duplicates_description": "Como devemos proceder se um contacto já existir nos seus contactos?",
"upload_contacts_modal_duplicates_overwrite_description": "Gravar os contactos existentes por cima", "upload_contacts_modal_duplicates_overwrite_description": "Sobrescreve os contactos existentes",
"upload_contacts_modal_duplicates_overwrite_title": "Gravar por cima", "upload_contacts_modal_duplicates_overwrite_title": "Sobrescrever",
"upload_contacts_modal_duplicates_skip_description": "Ignorar os contactos duplicados", "upload_contacts_modal_duplicates_skip_description": "Ignora os contactos duplicados",
"upload_contacts_modal_duplicates_skip_title": "Saltar", "upload_contacts_modal_duplicates_skip_title": "Saltar",
"upload_contacts_modal_duplicates_title": "Duplicados", "upload_contacts_modal_duplicates_title": "Duplicados",
"upload_contacts_modal_duplicates_update_description": "Atualizar os contactos existentes", "upload_contacts_modal_duplicates_update_description": "Atualiza os contactos existentes",
"upload_contacts_modal_duplicates_update_title": "Atualizar", "upload_contacts_modal_duplicates_update_title": "Atualizar",
"upload_contacts_modal_pick_different_file": "Escolher um ficheiro diferente", "upload_contacts_modal_pick_different_file": "Escolher um ficheiro diferente",
"upload_contacts_modal_preview": "Aqui está uma pré-visualização dos seus dados.", "upload_contacts_modal_preview": "Aqui está uma pré-visualização dos seus dados.",
@@ -773,20 +769,20 @@
"unable_to_delete_api_key": "Não é possível eliminar a chave API" "unable_to_delete_api_key": "Não é possível eliminar a chave API"
}, },
"app-connection": { "app-connection": {
"app_connection": "Conexão de aplicação", "app_connection": "Ligação de Aplicação",
"app_connection_description": "Conecte a sua aplicação ao Formbricks", "app_connection_description": "Ligue a sua aplicação ao Formbricks",
"cache_update_delay_description": "Quando fizer atualizações para inquéritos, contactos, ações ou outros dados, pode demorar até 5 minutos para que essas alterações apareçam na sua aplicação local a correr o SDK do Formbricks. Este atraso deve-se a uma limitação no nosso atual sistema de cache. Estamos a trabalhar ativamente na reformulação da cache e lançaremos uma correção no Formbricks 4.0.", "cache_update_delay_description": "Quando fizer atualizações para inquéritos, contactos, ações ou outros dados, pode demorar até 5 minutos para que essas alterações apareçam na sua aplicação local a correr o SDK do Formbricks. Este atraso deve-se a uma limitação no nosso atual sistema de cache. Estamos a trabalhar ativamente na reformulação da cache e lançaremos uma correção no Formbricks 4.0.",
"cache_update_delay_title": "As alterações serão refletidas após 5 minutos devido ao armazenamento em cache.", "cache_update_delay_title": "As alterações serão refletidas após 5 minutos devido ao armazenamento em cache.",
"environment_id": "O seu identificador", "environment_id": "O Seu ID de Ambiente",
"environment_id_description": "Este id identifica o seu espaço Formbricks.", "environment_id_description": "Este id identifica de forma única este ambiente Formbricks.",
"formbricks_sdk_connected": "O SDK do Formbricks está conectado", "formbricks_sdk_connected": "O SDK do Formbricks está conectado",
"formbricks_sdk_not_connected": "O Formbricks SDK ainda não está conectado", "formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado",
"formbricks_sdk_not_connected_description": "Ligue o seu website ou aplicação ao Formbricks", "formbricks_sdk_not_connected_description": "Ligue o seu website ou aplicação ao Formbricks",
"how_to_setup": "Como configurar", "how_to_setup": "Como configurar",
"how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.", "how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.",
"receiving_data": "A receber dados 💃🕺", "receiving_data": "A receber dados 💃🕺",
"recheck": "Verificar novamente", "recheck": "Verificar novamente",
"setup_alert_description": "Siga este tutorial passo a passo para conectar a sua app ou site em menos de 5 minutos", "setup_alert_description": "Siga este tutorial passo-a-passo para ligar a sua aplicação ou website em menos de 5 minutos",
"setup_alert_title": "Como conectar" "setup_alert_title": "Como conectar"
}, },
"general": { "general": {
@@ -800,23 +796,23 @@
"project_deleted_successfully": "Projeto eliminado com sucesso", "project_deleted_successfully": "Projeto eliminado com sucesso",
"project_name_settings_description": "Altere o nome dos seus projetos.", "project_name_settings_description": "Altere o nome dos seus projetos.",
"project_name_updated_successfully": "Nome do projeto atualizado com sucesso", "project_name_updated_successfully": "Nome do projeto atualizado com sucesso",
"recontact_waiting_time": "Tempo de espera de recontacto", "recontact_waiting_time": "Tempo de Espera para Recontacto",
"recontact_waiting_time_settings_description": "Controle a regularidade com que os utilizadores podem ser inquiridos em todos os inquéritos da aplicação.", "recontact_waiting_time_settings_description": "Controlar a frequência 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.", "this_action_cannot_be_undone": "Esta ação não pode ser desfeita.",
"wait_x_days_before_showing_next_survey": "Dias de espera:", "wait_x_days_before_showing_next_survey": "Aguarde X dias antes de mostrar o próximo inquérito:",
"waiting_period_updated_successfully": "Período de espera atualizado com sucesso", "waiting_period_updated_successfully": "Período de espera atualizado com sucesso",
"whats_your_project_called": "Como se chama o seu projeto?" "whats_your_project_called": "Como se chama o seu projeto?"
}, },
"languages": { "languages": {
"add_language": "Adicionar idioma", "add_language": "Adicionar idioma",
"alias": "Código", "alias": "Pseudónimo",
"alias_tooltip": "O alias é um nome alternativo para identificar a língua em inquéritos de ligação e no SDK (opcional)", "alias_tooltip": "O alias é um nome alternativo para identificar a língua em inquéritos de ligação e no SDK (opcional)",
"cannot_remove_language_warning": "Não pode remover este idioma, pois ainda é utilizado nestes questionários:", "cannot_remove_language_warning": "Não pode remover este idioma, pois ainda é utilizado nestes questionários:",
"conflict_between_identifier_and_alias": "Existe um conflito entre o identificador de uma língua adicionada e um dos seus aliases. Aliases e identificadores não podem ser idênticos.", "conflict_between_identifier_and_alias": "Existe um conflito entre o identificador de uma língua adicionada e um dos seus aliases. Aliases e identificadores não podem ser idênticos.",
"conflict_between_selected_alias_and_another_language": "Existe um conflito entre o alias selecionado e outra língua que tem este identificador. Por favor, adicione a língua com este identificador ao seu projeto para evitar inconsistências.", "conflict_between_selected_alias_and_another_language": "Existe um conflito entre o alias selecionado e outra língua que tem este identificador. Por favor, adicione a língua com este identificador ao seu projeto para evitar inconsistências.",
"delete_language_confirmation": "Tem a certeza de que deseja eliminar este idioma? Esta ação não pode ser desfeita.", "delete_language_confirmation": "Tem a certeza de que deseja eliminar este idioma? Esta ação não pode ser desfeita.",
"duplicate_language_or_language_id": "Idioma ou ID de idioma duplicado", "duplicate_language_or_language_id": "Idioma ou ID de idioma duplicado",
"edit_languages": "Editar línguas", "edit_languages": "Editar idiomas",
"identifier": "Identificador (ISO)", "identifier": "Identificador (ISO)",
"incomplete_translations": "Traduções incompletas", "incomplete_translations": "Traduções incompletas",
"language": "Idioma", "language": "Idioma",
@@ -834,8 +830,8 @@
"look": { "look": {
"add_background_color": "Adicionar cor de fundo", "add_background_color": "Adicionar cor de fundo",
"add_background_color_description": "Adicionar uma cor de fundo ao contentor do logótipo.", "add_background_color_description": "Adicionar uma cor de fundo ao contentor do logótipo.",
"app_survey_placement": "Posição do inquérito (app)", "app_survey_placement": "Colocação do Inquérito da Aplicação",
"app_survey_placement_settings_description": "Altere o local onde os inquéritos serão exibidos na sua aplicação ou site.", "app_survey_placement_settings_description": "Altere onde os inquéritos serão exibidos na sua aplicação web ou site.",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada", "centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"email_customization": "Personalização de E-mail", "email_customization": "Personalização de E-mail",
"email_customization_description": "Altere a aparência e a sensação dos e-mails que o Formbricks envia em seu nome.", "email_customization_description": "Altere a aparência e a sensação dos e-mails que o Formbricks envia em seu nome.",
@@ -845,14 +841,14 @@
"failed_to_update_logo": "Falha ao atualizar o logótipo", "failed_to_update_logo": "Falha ao atualizar o logótipo",
"formbricks_branding": "Marca Formbricks", "formbricks_branding": "Marca Formbricks",
"formbricks_branding_hidden": "Marca Formbricks está oculta.", "formbricks_branding_hidden": "Marca Formbricks está oculta.",
"formbricks_branding_settings_description": "Gostamos muito de ter o seu apoio, mas percebemos que possa querer remover o nosso logo.", "formbricks_branding_settings_description": "Adoramos o seu apoio, mas compreendemos se o desativar.",
"formbricks_branding_shown": "Marca Formbricks está visível.", "formbricks_branding_shown": "Marca Formbricks está visível.",
"logo_removed_successfully": "Logótipo removido com sucesso", "logo_removed_successfully": "Logótipo removido com sucesso",
"logo_settings_description": "Carregue o logótipo da sua empresa para personalizar inquéritos e pré-visualizações de links.", "logo_settings_description": "Carregue o logótipo da sua empresa para personalizar inquéritos e pré-visualizações de links.",
"logo_updated_successfully": "Logótipo atualizado com sucesso", "logo_updated_successfully": "Logótipo atualizado com sucesso",
"logo_upload_failed": "Falha no carregamento do logótipo. Por favor, tente novamente.", "logo_upload_failed": "Falha no carregamento do logótipo. Por favor, tente novamente.",
"placement_updated_successfully": "Posicionamento atualizado com sucesso", "placement_updated_successfully": "Posicionamento atualizado com sucesso",
"remove_branding_with_a_higher_plan": "Remova o logo com a ativação do nosso plano", "remove_branding_with_a_higher_plan": "Remova a marca com um plano superior",
"remove_logo": "Remover Logótipo", "remove_logo": "Remover Logótipo",
"remove_logo_confirmation": "Tem a certeza de que quer remover o logótipo?", "remove_logo_confirmation": "Tem a certeza de que quer remover o logótipo?",
"replace_logo": "Substituir Logotipo", "replace_logo": "Substituir Logotipo",
@@ -869,9 +865,9 @@
"add_tag": "Adicionar Etiqueta", "add_tag": "Adicionar Etiqueta",
"count": "Contagem", "count": "Contagem",
"delete_tag_confirmation": "Tem a certeza de que deseja eliminar esta etiqueta?", "delete_tag_confirmation": "Tem a certeza de que deseja eliminar esta etiqueta?",
"empty_message": "Crie etiquetas para as suas submissões e veja-as aqui", "empty_message": "Etiqueta uma submissão para encontrar a tua lista de etiquetas aqui.",
"manage_tags": "Gerir Etiquetas", "manage_tags": "Gerir Etiquetas",
"manage_tags_description": "Junte e remova etiquetas de resposta", "manage_tags_description": "Fundir e remover etiquetas de resposta.",
"merge": "Fundir", "merge": "Fundir",
"no_tag_found": "Nenhuma etiqueta encontrada", "no_tag_found": "Nenhuma etiqueta encontrada",
"search_tags": "Procurar Etiquetas...", "search_tags": "Procurar Etiquetas...",
@@ -890,6 +886,7 @@
"team_settings_description": "Veja quais equipas podem aceder a este projeto." "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": { "segments": {
"add_filter_below": "Adicionar filtro abaixo", "add_filter_below": "Adicionar filtro abaixo",
"add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar", "add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar",
@@ -948,24 +945,24 @@
"api_keys": { "api_keys": {
"add_api_key": "Adicionar chave API", "add_api_key": "Adicionar chave API",
"add_permission": "Adicionar permissão", "add_permission": "Adicionar permissão",
"api_keys_description": "Faça a gestão das suas chaves API para aceder às APIs de gestão do Formbricks" "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
}, },
"billing": { "billing": {
"1000_monthly_responses": "1000 Respostas Mensais", "1000_monthly_responses": "1000 Respostas Mensais",
"1_project": "1 Projeto", "1_project": "1 Projeto",
"2000_contacts": "2000 Contactos", "2000_contacts": "2,000 Contactos",
"3_projects": "3 Projetos", "3_projects": "3 Projetos",
"5000_monthly_responses": "5000 Respostas Mensais", "5000_monthly_responses": "5,000 Respostas Mensais",
"7500_contacts": "7500 Contactos", "7500_contacts": "7,500 Contactos",
"all_integrations": "Todas as Integrações", "all_integrations": "Todas as Integrações",
"annually": "Anual", "annually": "Anualmente",
"api_webhooks": "API e Webhooks", "api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos (app)", "app_surveys": "Inquéritos da Aplicação",
"attribute_based_targeting": "Segmentação Baseada em Atributos", "attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "Atual", "current": "Atual",
"current_plan": "Plano Atual", "current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível", "current_tier_limit": "Limite Atual do Nível",
"custom": "Personalizado", "custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contactos Personalizado", "custom_contacts_limit": "Limite de Contactos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado", "custom_project_limit": "Limite de Projeto Personalizado",
"custom_response_limit": "Limite de Resposta Personalizado", "custom_response_limit": "Limite de Resposta Personalizado",
@@ -973,25 +970,28 @@
"email_follow_ups": "Acompanhamentos por Email", "email_follow_ups": "Acompanhamentos por Email",
"enterprise_description": "Suporte premium e limites personalizados.", "enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!", "everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo incluído no Plano Grátis", "everything_in_free": "Tudo em Gratuito",
"everything_in_startup": "Tudo incluído no Plano Para começar", "everything_in_startup": "Tudo em Startup",
"free": "Grátis", "free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.", "free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis", "get_2_months_free": "Obtenha 2 meses grátis",
"hosted_in_frankfurt": "Hospedado em Frankfurt", "hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis", "ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Inquéritos por link (partilháveis)", "link_surveys": "Ligar Inquéritos (Partilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltar Perguntas, Campos Ocultos, Inquéritos Regulares, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
"manage_card_details": "Gerir Detalhes do Cartão", "manage_card_details": "Gerir Detalhes do Cartão",
"manage_subscription": "Gerir Subscrição", "manage_subscription": "Gerir Subscrição",
"monthly": "Mensal", "monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente", "monthly_identified_users": "Utilizadores Identificados Mensalmente",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso", "plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs", "premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Possibilidade de remover o logo", "remove_branding": "Remover Marca",
"startup": "Inicialização", "startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.", "startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano", "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", "team_access_roles": "Funções de Acesso da Equipa",
"unable_to_upgrade_plan": "Não é possível atualizar o plano", "unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado", "unlimited_miu": "MIU Ilimitado",
@@ -1001,7 +1001,7 @@
"unlimited_team_members": "Membros da Equipa Ilimitados", "unlimited_team_members": "Membros da Equipa Ilimitados",
"upgrade": "Atualizar", "upgrade": "Atualizar",
"uptime_sla_99": "SLA de Tempo de Atividade (99%)", "uptime_sla_99": "SLA de Tempo de Atividade (99%)",
"website_surveys": "Inquéritos (site)" "website_surveys": "Inquéritos do Website"
}, },
"enterprise": { "enterprise": {
"audit_logs": "Registos de Auditoria", "audit_logs": "Registos de Auditoria",
@@ -1056,7 +1056,7 @@
"logo_removed_successfully": "Logótipo removido com sucesso", "logo_removed_successfully": "Logótipo removido com sucesso",
"logo_saved_successfully": "Logótipo guardado com sucesso", "logo_saved_successfully": "Logótipo guardado com sucesso",
"manage_members": "Gerir membros", "manage_members": "Gerir membros",
"manage_members_description": "Adicione ou remova membros da sua organização.", "manage_members_description": "Adicionar ou remover membros na sua organização.",
"member_deleted_successfully": "Membro eliminado com sucesso", "member_deleted_successfully": "Membro eliminado com sucesso",
"member_invited_successfully": "Membro convidado com sucesso", "member_invited_successfully": "Membro convidado com sucesso",
"once_its_gone_its_gone": "Uma vez que se vai, já era.", "once_its_gone_its_gone": "Uma vez que se vai, já era.",
@@ -1097,7 +1097,7 @@
"profile": { "profile": {
"account_deletion_consequences_warning": "Consequências da eliminação da conta", "account_deletion_consequences_warning": "Consequências da eliminação da conta",
"backup_code": "Código de Backup", "backup_code": "Código de Backup",
"confirm_delete_account": "Elimine a sua conta junto com todas as suas informações e dados pessoais", "confirm_delete_account": "Eliminar a sua conta com todas as suas informações e dados pessoais",
"confirm_delete_my_account": "Eliminar a Minha Conta", "confirm_delete_my_account": "Eliminar a Minha Conta",
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.", "confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
"delete_account": "Eliminar Conta", "delete_account": "Eliminar Conta",
@@ -1149,7 +1149,7 @@
"manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.", "manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.",
"manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.", "manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.", "member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Adicione os membros que deseja a uma Equipa abaixo. Nesta secção, pode gerir quem tem acesso a cada projeto.", "member_role_info_message": "Para dar acesso a novos membros a um projeto, por favor adicione-os a uma Equipa abaixo. Com Equipas pode gerir quem tem acesso a que projeto.",
"owner_role_description": "Os proprietários têm controlo total sobre a organização.", "owner_role_description": "Os proprietários têm controlo total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.", "please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.", "please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
@@ -1165,7 +1165,7 @@
"team_settings_description": "Gerir membros da equipa, direitos de acesso e mais.", "team_settings_description": "Gerir membros da equipa, direitos de acesso e mais.",
"team_updated_successfully": "Equipa atualizada com sucesso", "team_updated_successfully": "Equipa atualizada com sucesso",
"teams": "Equipas", "teams": "Equipas",
"teams_description": "Atribua membros às equipas e dê acesso a projetos.", "teams_description": "Atribua membros às equipas e dê acesso a projetos às equipas.",
"unlock_teams_description": "Gerir quais os membros da organização que têm acesso a projetos e inquéritos específicos.", "unlock_teams_description": "Gerir quais os membros da organização que têm acesso a projetos e inquéritos específicos.",
"unlock_teams_title": "Desbloqueie as Equipas com um plano superior", "unlock_teams_title": "Desbloqueie as Equipas com um plano superior",
"upgrade_plan_notice_message": "Desbloqueie as Funções da Organização com um plano superior", "upgrade_plan_notice_message": "Desbloqueie as Funções da Organização com um plano superior",
@@ -1612,7 +1612,7 @@
"times": "tempos", "times": "tempos",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode", "to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...", "trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
"try_lollipop_or_mountain": "Experimente 'cão' ou 'planta'...", "try_lollipop_or_mountain": "Experimente 'lollipop' ou 'mountain'...",
"type_field_id": "Escreva o id do campo", "type_field_id": "Escreva o id do campo",
"underline": "Sublinhar", "underline": "Sublinhar",
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo", "unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "Endereço Linha 1", "address_line_1": "Endereço Linha 1",
"address_line_2": "Endereço Linha 2", "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", "an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
"browser": "Navegador", "browser": "Navegador",
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?", "bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
@@ -1882,6 +1880,7 @@
"setup_integrations": "Configurar integrações", "setup_integrations": "Configurar integrações",
"share_survey": "Partilhar inquérito", "share_survey": "Partilhar inquérito",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem", "show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
"show_all_responses_where": "Mostrar todas as respostas onde...",
"starts": "Começa", "starts": "Começa",
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.", "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.", "survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
@@ -2170,7 +2169,7 @@
"consent_description": "Pedir para concordar com os termos, condições ou uso de dados", "consent_description": "Pedir para concordar com os termos, condições ou uso de dados",
"contact_info": "Informações de Contacto", "contact_info": "Informações de Contacto",
"contact_info_description": "Peça nome, apelido, email, número de telefone e empresa em conjunto", "contact_info_description": "Peça nome, apelido, email, número de telefone e empresa em conjunto",
"csat_description": "Meça o Customer Satisfaction Score do seu produto ou serviço.", "csat_description": "Medir o Customer Satisfaction Score do seu produto ou serviço.",
"csat_name": "Customer Satisfaction Score (CSAT)", "csat_name": "Customer Satisfaction Score (CSAT)",
"csat_question_10_headline": "Tem mais algum comentário, pergunta ou preocupação?", "csat_question_10_headline": "Tem mais algum comentário, pergunta ou preocupação?",
"csat_question_10_placeholder": "Escreva a sua resposta aqui...", "csat_question_10_placeholder": "Escreva a sua resposta aqui...",
@@ -2247,11 +2246,11 @@
"csat_survey_question_3_headline": "Oh, desculpe! Há algo que possamos fazer para melhorar a sua experiência?", "csat_survey_question_3_headline": "Oh, desculpe! Há algo que possamos fazer para melhorar a sua experiência?",
"csat_survey_question_3_placeholder": "Escreva a sua resposta aqui...", "csat_survey_question_3_placeholder": "Escreva a sua resposta aqui...",
"cta_description": "Exibir informações e solicitar aos utilizadores que tomem uma ação específica", "cta_description": "Exibir informações e solicitar aos utilizadores que tomem uma ação específica",
"custom_survey_description": "Crie um inquérito sem modelo.", "custom_survey_description": "Criar um inquérito sem modelo.",
"custom_survey_name": "Começar do zero", "custom_survey_name": "Começar do zero",
"custom_survey_question_1_headline": "O que gostaria de saber?", "custom_survey_question_1_headline": "O que gostaria de saber?",
"custom_survey_question_1_placeholder": "Escreva a sua resposta aqui...", "custom_survey_question_1_placeholder": "Escreva a sua resposta aqui...",
"customer_effort_score_description": "Determine se uma certa funcionalidade é fácil ou difícil de usar", "customer_effort_score_description": "Determinar quão fácil é usar uma funcionalidade.",
"customer_effort_score_name": "Pontuação de Esforço do Cliente (CES)", "customer_effort_score_name": "Pontuação de Esforço do Cliente (CES)",
"customer_effort_score_question_1_headline": "$[projectName] torna fácil para mim [ADD GOAL]", "customer_effort_score_question_1_headline": "$[projectName] torna fácil para mim [ADD GOAL]",
"customer_effort_score_question_1_lower_label": "Discordo totalmente", "customer_effort_score_question_1_lower_label": "Discordo totalmente",
@@ -2266,7 +2265,7 @@
"default_welcome_card_button_label": "Seguinte", "default_welcome_card_button_label": "Seguinte",
"default_welcome_card_headline": "Bem-vindo!", "default_welcome_card_headline": "Bem-vindo!",
"default_welcome_card_html": "Obrigado por fornecer o seu feedback - vamos a isso!", "default_welcome_card_html": "Obrigado por fornecer o seu feedback - vamos a isso!",
"docs_feedback_description": "Meça a clareza de cada página da sua documentação de desenvolvedor.", "docs_feedback_description": "Medir a clareza de cada página da sua documentação de desenvolvedor.",
"docs_feedback_name": "Feedback de Documentos", "docs_feedback_name": "Feedback de Documentos",
"docs_feedback_question_1_choice_1": "Sim 👍", "docs_feedback_question_1_choice_1": "Sim 👍",
"docs_feedback_question_1_choice_2": "Não 👎", "docs_feedback_question_1_choice_2": "Não 👎",
@@ -2274,7 +2273,7 @@
"docs_feedback_question_2_headline": "Por favor, elabore:", "docs_feedback_question_2_headline": "Por favor, elabore:",
"docs_feedback_question_3_headline": "URL da página", "docs_feedback_question_3_headline": "URL da página",
"earned_advocacy_score_description": "O EAS é uma variação do NPS, mas pergunta sobre comportamentos passados reais em vez de intenções elevadas.", "earned_advocacy_score_description": "O EAS é uma variação do NPS, mas pergunta sobre comportamentos passados reais em vez de intenções elevadas.",
"earned_advocacy_score_name": "Earned Advocacy Score (EAS)", "earned_advocacy_score_name": "Pontuação de Advocacia Ganha (EAS)",
"earned_advocacy_score_question_1_choice_1": "Sim", "earned_advocacy_score_question_1_choice_1": "Sim",
"earned_advocacy_score_question_1_choice_2": "Não", "earned_advocacy_score_question_1_choice_2": "Não",
"earned_advocacy_score_question_1_headline": "Recomendou ativamente $[projectName] a outros?", "earned_advocacy_score_question_1_headline": "Recomendou ativamente $[projectName] a outros?",
@@ -2330,11 +2329,11 @@
"enps_survey_question_1_upper_label": "Extremamente provável", "enps_survey_question_1_upper_label": "Extremamente provável",
"enps_survey_question_2_headline": "Para nos ajudar a melhorar, pode descrever a(s) razão(ões) para a sua classificação?", "enps_survey_question_2_headline": "Para nos ajudar a melhorar, pode descrever a(s) razão(ões) para a sua classificação?",
"enps_survey_question_3_headline": "Algum outro comentário, feedback ou preocupação?", "enps_survey_question_3_headline": "Algum outro comentário, feedback ou preocupação?",
"evaluate_a_product_idea_description": "Sonde os utilizadores sobre ideias de produtos ou funcionalidades. Obtenha feedback rapidamente.", "evaluate_a_product_idea_description": "Pesquise os utilizadores sobre ideias de produtos ou funcionalidades. Obtenha feedback rapidamente.",
"evaluate_a_product_idea_name": "Avaliar uma Ideia de Produto", "evaluate_a_product_idea_name": "Avaliar uma Ideia de Produto",
"evaluate_a_product_idea_question_1_button_label": "Vamos a isso!", "evaluate_a_product_idea_question_1_button_label": "Vamos a isso!",
"evaluate_a_product_idea_question_1_dismiss_button_label": "Saltar", "evaluate_a_product_idea_question_1_dismiss_button_label": "Saltar",
"evaluate_a_product_idea_question_1_headline": "Valorizamos muito a sua opinião. Tem um minuto para partilhar a sua opinião sobre uma funcionalidade?", "evaluate_a_product_idea_question_1_headline": "Adoramos como usa $[projectName]! Gostaríamos de saber a sua opinião sobre uma ideia de funcionalidade. Tem um minuto?",
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Respeitamos o seu tempo e mantivemos isto curto 🤸</span></p>", "evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Respeitamos o seu tempo e mantivemos isto curto 🤸</span></p>",
"evaluate_a_product_idea_question_2_headline": "Obrigado! Quão difícil ou fácil é para si [PROBLEM AREA] hoje?", "evaluate_a_product_idea_question_2_headline": "Obrigado! Quão difícil ou fácil é para si [PROBLEM AREA] hoje?",
"evaluate_a_product_idea_question_2_lower_label": "Muito difícil", "evaluate_a_product_idea_question_2_lower_label": "Muito difícil",
@@ -2354,17 +2353,17 @@
"evaluate_a_product_idea_question_7_placeholder": "Escreva a sua resposta aqui...", "evaluate_a_product_idea_question_7_placeholder": "Escreva a sua resposta aqui...",
"evaluate_a_product_idea_question_8_headline": "Mais alguma coisa que devamos ter em mente?", "evaluate_a_product_idea_question_8_headline": "Mais alguma coisa que devamos ter em mente?",
"evaluate_a_product_idea_question_8_placeholder": "Escreva a sua resposta aqui...", "evaluate_a_product_idea_question_8_placeholder": "Escreva a sua resposta aqui...",
"evaluate_content_quality_description": "Descubra se o seu conteúdo de marketing acerta em cheio.", "evaluate_content_quality_description": "Meça se as suas peças de marketing de conteúdo acertam em cheio.",
"evaluate_content_quality_name": "Avaliar Qualidade do Conteúdo", "evaluate_content_quality_name": "Avaliar Qualidade do Conteúdo",
"evaluate_content_quality_question_1_headline": "Quão bem este artigo abordou o que esperava aprender?", "evaluate_content_quality_question_1_headline": "Quão bem este artigo abordou o que esperava aprender?",
"evaluate_content_quality_question_1_lower_label": "Nada bem", "evaluate_content_quality_question_1_lower_label": "Nada bem",
"evaluate_content_quality_question_1_upper_label": "Extremamente bem", "evaluate_content_quality_question_1_upper_label": "Extremamente bem",
"evaluate_content_quality_question_2_headline": "Quais eram as suas expectativas?", "evaluate_content_quality_question_2_headline": "Hmpft! O que esperavas?",
"evaluate_content_quality_question_2_placeholder": "Escreva a sua resposta aqui...", "evaluate_content_quality_question_2_placeholder": "Escreva a sua resposta aqui...",
"evaluate_content_quality_question_3_headline": "Adorável! Há mais alguma coisa que gostaria que abordássemos?", "evaluate_content_quality_question_3_headline": "Adorável! Há mais alguma coisa que gostaria que abordássemos?",
"evaluate_content_quality_question_3_placeholder": "Tópicos, tendências, tutoriais...", "evaluate_content_quality_question_3_placeholder": "Tópicos, tendências, tutoriais...",
"fake_door_follow_up_description": "Acompanhe os utilizadores que encontraram um dos seus experimentos de Porta Falsa.", "fake_door_follow_up_description": "Acompanhe os utilizadores que encontraram um dos seus experimentos de Porta Falsa.",
"fake_door_follow_up_name": "Fake Door Follow-Up", "fake_door_follow_up_name": "Acompanhamento de Porta Falsa",
"fake_door_follow_up_question_1_headline": "Quão importante é esta funcionalidade para si?", "fake_door_follow_up_question_1_headline": "Quão importante é esta funcionalidade para si?",
"fake_door_follow_up_question_1_lower_label": "Não é importante", "fake_door_follow_up_question_1_lower_label": "Não é importante",
"fake_door_follow_up_question_1_upper_label": "Muito importante", "fake_door_follow_up_question_1_upper_label": "Muito importante",
@@ -2375,7 +2374,7 @@
"fake_door_follow_up_question_2_headline": "O que deve ser definitivamente incluído na construção disto?", "fake_door_follow_up_question_2_headline": "O que deve ser definitivamente incluído na construção disto?",
"feature_chaser_description": "Acompanhe os utilizadores que acabaram de usar uma funcionalidade específica.", "feature_chaser_description": "Acompanhe os utilizadores que acabaram de usar uma funcionalidade específica.",
"feature_chaser_name": "Perseguidor de Funcionalidades", "feature_chaser_name": "Perseguidor de Funcionalidades",
"feature_chaser_question_1_headline": "Quão importante é [FUNCIONALIDADE] para si?", "feature_chaser_question_1_headline": "Quão importante é [ADD FEATURE] para si?",
"feature_chaser_question_1_lower_label": "Não é importante", "feature_chaser_question_1_lower_label": "Não é importante",
"feature_chaser_question_1_upper_label": "Muito importante", "feature_chaser_question_1_upper_label": "Muito importante",
"feature_chaser_question_2_choice_1": "Aspeto 1", "feature_chaser_question_2_choice_1": "Aspeto 1",
@@ -2389,7 +2388,7 @@
"feedback_box_question_1_choice_2": "Pedido de Funcionalidade 💡", "feedback_box_question_1_choice_2": "Pedido de Funcionalidade 💡",
"feedback_box_question_1_headline": "O que tem em mente, chefe?", "feedback_box_question_1_headline": "O que tem em mente, chefe?",
"feedback_box_question_1_subheader": "Obrigado por partilhar. Entraremos em contacto consigo o mais breve possível.", "feedback_box_question_1_subheader": "Obrigado por partilhar. Entraremos em contacto consigo o mais breve possível.",
"feedback_box_question_2_headline": "O que não está a correr bem?", "feedback_box_question_2_headline": "O que está quebrado?",
"feedback_box_question_2_subheader": "Quanto mais detalhes, melhor :)", "feedback_box_question_2_subheader": "Quanto mais detalhes, melhor :)",
"feedback_box_question_3_button_label": "Sim, notifique-me", "feedback_box_question_3_button_label": "Sim, notifique-me",
"feedback_box_question_3_dismiss_button_label": "Não, obrigado", "feedback_box_question_3_dismiss_button_label": "Não, obrigado",
@@ -2442,7 +2441,7 @@
"identify_sign_up_barriers_question_9_button_label": "Inscrever-se", "identify_sign_up_barriers_question_9_button_label": "Inscrever-se",
"identify_sign_up_barriers_question_9_dismiss_button_label": "Saltar por agora", "identify_sign_up_barriers_question_9_dismiss_button_label": "Saltar por agora",
"identify_sign_up_barriers_question_9_headline": "Obrigado! Aqui está o seu código: SIGNUPNOW10", "identify_sign_up_barriers_question_9_headline": "Obrigado! Aqui está o seu código: SIGNUPNOW10",
"identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Muito obrigado por partilhar o seu feedback connosco 🙏</span></p>", "identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Muito obrigado por dedicar tempo a partilhar feedback 🙏</span></p>",
"identify_upsell_opportunities_description": "Descubra quanto tempo o seu produto poupa ao seu utilizador. Use isso para vender mais.", "identify_upsell_opportunities_description": "Descubra quanto tempo o seu produto poupa ao seu utilizador. Use isso para vender mais.",
"identify_upsell_opportunities_name": "Identificar Oportunidades de Venda Adicional", "identify_upsell_opportunities_name": "Identificar Oportunidades de Venda Adicional",
"identify_upsell_opportunities_question_1_choice_1": "Menos de 1 hora", "identify_upsell_opportunities_question_1_choice_1": "Menos de 1 hora",
@@ -2464,12 +2463,12 @@
"improve_activation_rate_question_3_placeholder": "Escreva a sua resposta aqui...", "improve_activation_rate_question_3_placeholder": "Escreva a sua resposta aqui...",
"improve_activation_rate_question_4_headline": "Que funcionalidades ou características estavam em falta?", "improve_activation_rate_question_4_headline": "Que funcionalidades ou características estavam em falta?",
"improve_activation_rate_question_4_placeholder": "Escreva a sua resposta aqui...", "improve_activation_rate_question_4_placeholder": "Escreva a sua resposta aqui...",
"improve_activation_rate_question_5_headline": "Como podemos tornar tudo mais fácil?", "improve_activation_rate_question_5_headline": "Como poderíamos tornar mais fácil para si começar?",
"improve_activation_rate_question_5_placeholder": "Escreva a sua resposta aqui...", "improve_activation_rate_question_5_placeholder": "Escreva a sua resposta aqui...",
"improve_activation_rate_question_6_headline": "O que foi? Por favor, explique:", "improve_activation_rate_question_6_headline": "O que foi? Por favor, explique:",
"improve_activation_rate_question_6_placeholder": "Escreva a sua resposta aqui...", "improve_activation_rate_question_6_placeholder": "Escreva a sua resposta aqui...",
"improve_activation_rate_question_6_subheader": "Estamos ansiosos por corrigi-lo o mais rápido possível.", "improve_activation_rate_question_6_subheader": "Estamos ansiosos por corrigi-lo o mais rápido possível.",
"improve_newsletter_content_description": "Descubra se o os seus subscritores gostam do conteúdo da sua newsletter.", "improve_newsletter_content_description": "Descubra como os seus subscritores gostam do conteúdo da sua newsletter.",
"improve_newsletter_content_name": "Melhorar o Conteúdo da Newsletter", "improve_newsletter_content_name": "Melhorar o Conteúdo da Newsletter",
"improve_newsletter_content_question_1_headline": "Como classificaria a newsletter desta semana?", "improve_newsletter_content_question_1_headline": "Como classificaria a newsletter desta semana?",
"improve_newsletter_content_question_1_lower_label": "Mais ou menos", "improve_newsletter_content_question_1_lower_label": "Mais ou menos",
@@ -2480,14 +2479,14 @@
"improve_newsletter_content_question_3_dismiss_button_label": "Encontre os seus próprios amigos", "improve_newsletter_content_question_3_dismiss_button_label": "Encontre os seus próprios amigos",
"improve_newsletter_content_question_3_headline": "Obrigado! ❤️ Espalhe o amor com UM amigo.", "improve_newsletter_content_question_3_headline": "Obrigado! ❤️ Espalhe o amor com UM amigo.",
"improve_newsletter_content_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Quem pensa como tu? Farias-nos um grande favor se partilhasses o episódio desta semana com o teu amigo cérebro!</span></p>", "improve_newsletter_content_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Quem pensa como tu? Farias-nos um grande favor se partilhasses o episódio desta semana com o teu amigo cérebro!</span></p>",
"improve_trial_conversion_description": "Descubra por que as pessoas não acabaram o inquérito. Estes insights ajudam a melhorar o seu funil.", "improve_trial_conversion_description": "Descubra por que as pessoas interromperam o seu teste. Estes insights ajudam-no a melhorar o seu funil.",
"improve_trial_conversion_name": "Melhorar a Conversão de Inquéritos", "improve_trial_conversion_name": "Melhorar a Conversão de Testes",
"improve_trial_conversion_question_1_choice_1": "Não obtive muito valor com isso", "improve_trial_conversion_question_1_choice_1": "Não obtive muito valor com isso",
"improve_trial_conversion_question_1_choice_2": "Eu esperava outra coisa", "improve_trial_conversion_question_1_choice_2": "Eu esperava outra coisa",
"improve_trial_conversion_question_1_choice_3": "É muito caro para o que faz", "improve_trial_conversion_question_1_choice_3": "É muito caro para o que faz",
"improve_trial_conversion_question_1_choice_4": "Falta-me uma funcionalidade", "improve_trial_conversion_question_1_choice_4": "Falta-me uma funcionalidade",
"improve_trial_conversion_question_1_choice_5": "Eu estava apenas a ver", "improve_trial_conversion_question_1_choice_5": "Eu estava apenas a ver",
"improve_trial_conversion_question_1_headline": "Por que é que decidiu não continuar o seu inquérito?", "improve_trial_conversion_question_1_headline": "Porque parou o seu teste?",
"improve_trial_conversion_question_1_subheader": "Ajude-nos a compreendê-lo melhor:", "improve_trial_conversion_question_1_subheader": "Ajude-nos a compreendê-lo melhor:",
"improve_trial_conversion_question_2_button_label": "Seguinte", "improve_trial_conversion_question_2_button_label": "Seguinte",
"improve_trial_conversion_question_2_headline": "Lamentamos saber. Qual foi o maior problema ao usar $[projectName]?", "improve_trial_conversion_question_2_headline": "Lamentamos saber. Qual foi o maior problema ao usar $[projectName]?",
@@ -2512,8 +2511,8 @@
"interview_prompt_description": "Convide um subconjunto específico dos seus utilizadores para agendar uma entrevista com a sua equipa de produto.", "interview_prompt_description": "Convide um subconjunto específico dos seus utilizadores para agendar uma entrevista com a sua equipa de produto.",
"interview_prompt_name": "Sugestão de Entrevista", "interview_prompt_name": "Sugestão de Entrevista",
"interview_prompt_question_1_button_label": "Reservar horário", "interview_prompt_question_1_button_label": "Reservar horário",
"interview_prompt_question_1_headline": "Tem 15 minutos para falar connosco? 🙏", "interview_prompt_question_1_headline": "Tens 15 minutos para falar connosco? 🙏",
"interview_prompt_question_1_html": "É um dos nossos melhores utlizadores. Adoraríamos que partilhasse a sua experiência connosco!", "interview_prompt_question_1_html": s um dos nossos utilizadores avançados. Adoraríamos entrevistar-te brevemente!",
"long_term_retention_check_in_description": "Avalie a satisfação a longo prazo dos utilizadores, lealdade e áreas de melhoria para reter utilizadores leais.", "long_term_retention_check_in_description": "Avalie a satisfação a longo prazo dos utilizadores, lealdade e áreas de melhoria para reter utilizadores leais.",
"long_term_retention_check_in_name": "Verificação de Retenção a Longo Prazo", "long_term_retention_check_in_name": "Verificação de Retenção a Longo Prazo",
"long_term_retention_check_in_question_10_headline": "Algum comentário ou feedback adicional?", "long_term_retention_check_in_question_10_headline": "Algum comentário ou feedback adicional?",
@@ -2564,11 +2563,11 @@
"market_site_clarity_question_1_choice_3": "Não, de todo", "market_site_clarity_question_1_choice_3": "Não, de todo",
"market_site_clarity_question_1_headline": "Tem todas as informações de que precisa para experimentar $[projectName]?", "market_site_clarity_question_1_headline": "Tem todas as informações de que precisa para experimentar $[projectName]?",
"market_site_clarity_question_2_headline": "O que está em falta ou não está claro para si sobre $[projectName]?", "market_site_clarity_question_2_headline": "O que está em falta ou não está claro para si sobre $[projectName]?",
"market_site_clarity_question_3_button_label": "Obter desconto", "market_site_clarity_question_3_button_label": "Obtenha desconto",
"market_site_clarity_question_3_headline": "Obrigado pela sua resposta! Obtenha 25% de desconto nos primeiros 6 meses:", "market_site_clarity_question_3_headline": "Obrigado pela sua resposta! Obtenha 25% de desconto nos primeiros 6 meses:",
"matrix": "Matriz", "matrix": "Matriz",
"matrix_description": "Crie uma grelha para avaliar vários itens com o mesmo conjunto de critérios", "matrix_description": "Crie uma grelha para avaliar vários itens com o mesmo conjunto de critérios",
"measure_search_experience_description": "Meça a relevância dos seus resultados de pesquisa.", "measure_search_experience_description": "Meça quão relevantes são os seus resultados de pesquisa.",
"measure_search_experience_name": "Medir Experiência de Pesquisa", "measure_search_experience_name": "Medir Experiência de Pesquisa",
"measure_search_experience_question_1_headline": "Quão relevantes são estes resultados de pesquisa?", "measure_search_experience_question_1_headline": "Quão relevantes são estes resultados de pesquisa?",
"measure_search_experience_question_1_lower_label": "Nada relevante", "measure_search_experience_question_1_lower_label": "Nada relevante",
@@ -2577,11 +2576,11 @@
"measure_search_experience_question_2_placeholder": "Escreva a sua resposta aqui...", "measure_search_experience_question_2_placeholder": "Escreva a sua resposta aqui...",
"measure_search_experience_question_3_headline": "Ótimo! Há algo que possamos fazer para melhorar a sua experiência?", "measure_search_experience_question_3_headline": "Ótimo! Há algo que possamos fazer para melhorar a sua experiência?",
"measure_search_experience_question_3_placeholder": "Escreva a sua resposta aqui...", "measure_search_experience_question_3_placeholder": "Escreva a sua resposta aqui...",
"measure_task_accomplishment_description": "Veja se as pessoas realizam as suas tarefas a tempo. As pessoas bem-sucedidas são melhores clientes.", "measure_task_accomplishment_description": "Veja se as pessoas realizam a sua 'Tarefa a Ser Feita'. Pessoas bem-sucedidas são melhores clientes.",
"measure_task_accomplishment_name": "Meça o cumprimento das tarefas", "measure_task_accomplishment_name": "Medir Realização de Tarefas",
"measure_task_accomplishment_question_1_headline": "Conseguiu realizar o que veio fazer hoje?", "measure_task_accomplishment_question_1_headline": "Conseguiu realizar o que veio fazer hoje?",
"measure_task_accomplishment_question_1_option_1_label": "Sim", "measure_task_accomplishment_question_1_option_1_label": "Sim",
"measure_task_accomplishment_question_1_option_2_label": "A trabalhar nisso", "measure_task_accomplishment_question_1_option_2_label": "A trabalhar nisso, chefe",
"measure_task_accomplishment_question_1_option_3_label": "Não", "measure_task_accomplishment_question_1_option_3_label": "Não",
"measure_task_accomplishment_question_2_headline": "Quão fácil foi alcançar o seu objetivo?", "measure_task_accomplishment_question_2_headline": "Quão fácil foi alcançar o seu objetivo?",
"measure_task_accomplishment_question_2_lower_label": "Muito difícil", "measure_task_accomplishment_question_2_lower_label": "Muito difícil",
@@ -2595,7 +2594,7 @@
"measure_task_accomplishment_question_5_placeholder": "Escreva a sua resposta aqui...", "measure_task_accomplishment_question_5_placeholder": "Escreva a sua resposta aqui...",
"multi_select": "Seleção Múltipla", "multi_select": "Seleção Múltipla",
"multi_select_description": "Peça aos respondentes para escolherem uma ou mais opções", "multi_select_description": "Peça aos respondentes para escolherem uma ou mais opções",
"new_integration_survey_description": "Descubra quais são as integrações que os seus utilizadores gostariam de ver a seguir.", "new_integration_survey_description": "Descubra quais integrações os seus utilizadores gostariam de ver a seguir.",
"new_integration_survey_name": "Novo Inquérito de Integração", "new_integration_survey_name": "Novo Inquérito de Integração",
"new_integration_survey_question_1_choice_1": "PostHog", "new_integration_survey_question_1_choice_1": "PostHog",
"new_integration_survey_question_1_choice_2": "Segmento", "new_integration_survey_question_1_choice_2": "Segmento",
@@ -2605,7 +2604,7 @@
"new_integration_survey_question_1_headline": "Quais outras ferramentas está a utilizar?", "new_integration_survey_question_1_headline": "Quais outras ferramentas está a utilizar?",
"next": "Seguinte", "next": "Seguinte",
"nps": "Net Promoter Score (NPS)", "nps": "Net Promoter Score (NPS)",
"nps_description": "Meça o Net-Promoter-Score (0-10)", "nps_description": "Medir o Net-Promoter-Score (0-10)",
"nps_lower_label": "Nada provável", "nps_lower_label": "Nada provável",
"nps_name": "Net Promoter Score (NPS)", "nps_name": "Net Promoter Score (NPS)",
"nps_question_1_headline": "Qual a probabilidade de recomendar $[projectName] a um amigo ou colega?", "nps_question_1_headline": "Qual a probabilidade de recomendar $[projectName] a um amigo ou colega?",
@@ -2626,7 +2625,7 @@
"onboarding_segmentation_question_1_choice_3": "Gestor de Produto", "onboarding_segmentation_question_1_choice_3": "Gestor de Produto",
"onboarding_segmentation_question_1_choice_4": "Proprietário do Produto", "onboarding_segmentation_question_1_choice_4": "Proprietário do Produto",
"onboarding_segmentation_question_1_choice_5": "Engenheiro de Software", "onboarding_segmentation_question_1_choice_5": "Engenheiro de Software",
"onboarding_segmentation_question_1_headline": "Qual é o seu cargo?", "onboarding_segmentation_question_1_headline": "Qual é o seu papel?",
"onboarding_segmentation_question_1_subheader": "Por favor, selecione uma das seguintes opções:", "onboarding_segmentation_question_1_subheader": "Por favor, selecione uma das seguintes opções:",
"onboarding_segmentation_question_2_choice_1": "só eu", "onboarding_segmentation_question_2_choice_1": "só eu",
"onboarding_segmentation_question_2_choice_2": "1-5 funcionários", "onboarding_segmentation_question_2_choice_2": "1-5 funcionários",
@@ -2657,7 +2656,7 @@
"preview_survey_question_2_headline": "Quer manter-se atualizado?", "preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_welcome_card_headline": "Bem-vindo!", "preview_survey_welcome_card_headline": "Bem-vindo!",
"preview_survey_welcome_card_html": "Obrigado por fornecer o seu feedback - vamos a isso!", "preview_survey_welcome_card_html": "Obrigado por fornecer o seu feedback - vamos a isso!",
"prioritize_features_description": "Identifique as funcionalidades que os seus utilizadores precisam mais e menos.", "prioritize_features_description": "Identificar as funcionalidades que os seus utilizadores mais e menos precisam.",
"prioritize_features_name": "Priorizar Funcionalidades", "prioritize_features_name": "Priorizar Funcionalidades",
"prioritize_features_question_1_choice_1": "Funcionalidade 1", "prioritize_features_question_1_choice_1": "Funcionalidade 1",
"prioritize_features_question_1_choice_2": "Funcionalidade 2", "prioritize_features_question_1_choice_2": "Funcionalidade 2",
@@ -2695,7 +2694,7 @@
"product_market_fit_superhuman_question_3_choice_3": "Gestor de Produto", "product_market_fit_superhuman_question_3_choice_3": "Gestor de Produto",
"product_market_fit_superhuman_question_3_choice_4": "Proprietário do Produto", "product_market_fit_superhuman_question_3_choice_4": "Proprietário do Produto",
"product_market_fit_superhuman_question_3_choice_5": "Engenheiro de Software", "product_market_fit_superhuman_question_3_choice_5": "Engenheiro de Software",
"product_market_fit_superhuman_question_3_headline": "Qual é o seu cargo?", "product_market_fit_superhuman_question_3_headline": "Qual é o seu papel?",
"product_market_fit_superhuman_question_3_subheader": "Por favor, selecione uma das seguintes opções:", "product_market_fit_superhuman_question_3_subheader": "Por favor, selecione uma das seguintes opções:",
"product_market_fit_superhuman_question_4_headline": "Que tipo de pessoas acha que mais beneficiariam de $[projectName]?", "product_market_fit_superhuman_question_4_headline": "Que tipo de pessoas acha que mais beneficiariam de $[projectName]?",
"product_market_fit_superhuman_question_5_headline": "Qual é o principal benefício que recebe de $[projectName]?", "product_market_fit_superhuman_question_5_headline": "Qual é o principal benefício que recebe de $[projectName]?",
@@ -2742,7 +2741,7 @@
"professional_development_survey_question_5_headline": "Quais são as suas principais razões para querer dedicar tempo ao desenvolvimento profissional?", "professional_development_survey_question_5_headline": "Quais são as suas principais razões para querer dedicar tempo ao desenvolvimento profissional?",
"ranking": "Classificação", "ranking": "Classificação",
"ranking_description": "Peça aos respondentes para ordenar os itens por preferência ou importância", "ranking_description": "Peça aos respondentes para ordenar os itens por preferência ou importância",
"rate_checkout_experience_description": "Permita que os clientes avaliem a sua experiência de finalização de compra para melhorar a conversão.", "rate_checkout_experience_description": "Permitir que os clientes avaliem a experiência de finalização de compra para ajustar a conversão.",
"rate_checkout_experience_name": "Avaliar Experiência de Finalização de Compra", "rate_checkout_experience_name": "Avaliar Experiência de Finalização de Compra",
"rate_checkout_experience_question_1_headline": "Quão fácil ou difícil foi concluir o checkout?", "rate_checkout_experience_question_1_headline": "Quão fácil ou difícil foi concluir o checkout?",
"rate_checkout_experience_question_1_lower_label": "Muito difícil", "rate_checkout_experience_question_1_lower_label": "Muito difícil",
@@ -2777,7 +2776,7 @@
"review_prompt_question_2_headline": "Ficamos felizes em saber 🙏 Por favor, escreva uma avaliação para nós!", "review_prompt_question_2_headline": "Ficamos felizes em saber 🙏 Por favor, escreva uma avaliação para nós!",
"review_prompt_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Isto ajuda-nos imenso.</span></p>", "review_prompt_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Isto ajuda-nos imenso.</span></p>",
"review_prompt_question_3_button_label": "Enviar", "review_prompt_question_3_button_label": "Enviar",
"review_prompt_question_3_headline": "Lamentamos! O que podemos fazer melhor?", "review_prompt_question_3_headline": "Lamentamos saber! O que é UMA coisa que podemos fazer melhor?",
"review_prompt_question_3_placeholder": "Escreva a sua resposta aqui...", "review_prompt_question_3_placeholder": "Escreva a sua resposta aqui...",
"review_prompt_question_3_subheader": "Ajude-nos a melhorar a sua experiência.", "review_prompt_question_3_subheader": "Ajude-nos a melhorar a sua experiência.",
"schedule_a_meeting": "Agendar uma reunião", "schedule_a_meeting": "Agendar uma reunião",
@@ -2789,7 +2788,7 @@
"site_abandonment_survey_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Notámos que está a sair do nosso site sem fazer uma compra. Gostaríamos de entender porquê.</span></p>", "site_abandonment_survey_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Notámos que está a sair do nosso site sem fazer uma compra. Gostaríamos de entender porquê.</span></p>",
"site_abandonment_survey_question_2_button_label": "Claro!", "site_abandonment_survey_question_2_button_label": "Claro!",
"site_abandonment_survey_question_2_dismiss_button_label": "Não, obrigado.", "site_abandonment_survey_question_2_dismiss_button_label": "Não, obrigado.",
"site_abandonment_survey_question_2_headline": "Tem um minuto?", "site_abandonment_survey_question_2_headline": "Tens um minuto?",
"site_abandonment_survey_question_3_choice_1": "Não consigo encontrar o que procuro", "site_abandonment_survey_question_3_choice_1": "Não consigo encontrar o que procuro",
"site_abandonment_survey_question_3_choice_2": "Encontrei um site melhor", "site_abandonment_survey_question_3_choice_2": "Encontrei um site melhor",
"site_abandonment_survey_question_3_choice_3": "O site é muito lento", "site_abandonment_survey_question_3_choice_3": "O site é muito lento",
@@ -2839,8 +2838,8 @@
"statement_call_to_action": "Declaração (Chamada para Ação)", "statement_call_to_action": "Declaração (Chamada para Ação)",
"strongly_agree": "Concordo totalmente", "strongly_agree": "Concordo totalmente",
"strongly_disagree": "Discordo totalmente", "strongly_disagree": "Discordo totalmente",
"supportive_work_culture_survey_description": "Avalie as perceções dos funcionários sobre o apoio da liderança, comunicação e o ambiente de trabalho geral.", "supportive_work_culture_survey_description": "Avaliar as perceções dos funcionários sobre o apoio da liderança, comunicação e o ambiente de trabalho geral.",
"supportive_work_culture_survey_name": "Apoio no trabalho", "supportive_work_culture_survey_name": "Cultura de Trabalho de Apoio",
"supportive_work_culture_survey_question_1_headline": "O meu gestor fornece-me o apoio de que preciso para concluir o meu trabalho.", "supportive_work_culture_survey_question_1_headline": "O meu gestor fornece-me o apoio de que preciso para concluir o meu trabalho.",
"supportive_work_culture_survey_question_1_lower_label": "Não apoiado", "supportive_work_culture_survey_question_1_lower_label": "Não apoiado",
"supportive_work_culture_survey_question_1_upper_label": "Altamente apoiado", "supportive_work_culture_survey_question_1_upper_label": "Altamente apoiado",
@@ -2888,12 +2887,12 @@
"understand_low_engagement_question_6_placeholder": "Escreva a sua resposta aqui...", "understand_low_engagement_question_6_placeholder": "Escreva a sua resposta aqui...",
"understand_purchase_intention_description": "Descubra quão perto estão os seus visitantes de comprar ou subscrever.", "understand_purchase_intention_description": "Descubra quão perto estão os seus visitantes de comprar ou subscrever.",
"understand_purchase_intention_name": "Compreender a Intenção de Compra", "understand_purchase_intention_name": "Compreender a Intenção de Compra",
"understand_purchase_intention_question_1_headline": "Qual é a probabilidade de fazer as suas compras connosco hoje?", "understand_purchase_intention_question_1_headline": "Qual a probabilidade de fazer compras connosco hoje?",
"understand_purchase_intention_question_1_lower_label": "Nada provável", "understand_purchase_intention_question_1_lower_label": "Nada provável",
"understand_purchase_intention_question_1_upper_label": "Extremamente provável", "understand_purchase_intention_question_1_upper_label": "Extremamente provável",
"understand_purchase_intention_question_2_headline": "Entendido. Qual é a sua principal razão para visitar hoje?", "understand_purchase_intention_question_2_headline": "Entendido. Qual é a sua principal razão para visitar hoje?",
"understand_purchase_intention_question_2_placeholder": "Escreva a sua resposta aqui...", "understand_purchase_intention_question_2_placeholder": "Escreva a sua resposta aqui...",
"understand_purchase_intention_question_3_headline": "Há algo que está a impedir de efetuar a sua compra hoje?", "understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui...", "understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui...",
"usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.", "usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.",
"usability_question_1_headline": "Provavelmente usaria este sistema com frequência.", "usability_question_1_headline": "Provavelmente usaria este sistema com frequência.",
@@ -2906,6 +2905,6 @@
"usability_question_8_headline": "Usar o sistema pareceu complicado.", "usability_question_8_headline": "Usar o sistema pareceu complicado.",
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.", "usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.", "usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "System Usability Score (SUS)" "usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
} }
} }
+5 -6
View File
@@ -200,7 +200,6 @@
"edit": "Editare", "edit": "Editare",
"email": "Email", "email": "Email",
"ending_card": "Cardul de finalizare", "ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere", "enterprise_license": "Licență Întreprindere",
"environment_not_found": "Mediul nu a fost găsit", "environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}", "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_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ă", "error_rate_limit_title": "Limită de cereri depășită",
"expand_rows": "Extinde rândurile", "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ă", "finish": "Finalizează",
"follow_these": "Urmați acestea", "follow_these": "Urmați acestea",
"formbricks_version": "Versiunea Formbricks", "formbricks_version": "Versiunea Formbricks",
@@ -358,7 +355,6 @@
"segments": "Segment", "segments": "Segment",
"select": "Selectați", "select": "Selectați",
"select_all": "Selectați toate", "select_all": "Selectați toate",
"select_filter": "Selectați filtrul",
"select_survey": "Selectați chestionar", "select_survey": "Selectați chestionar",
"select_teams": "Selectați echipele", "select_teams": "Selectați echipele",
"selected": "Selectat", "selected": "Selectat",
@@ -890,6 +886,7 @@
"team_settings_description": "Vezi care echipe pot accesa acest proiect." "team_settings_description": "Vezi care echipe pot accesa acest proiect."
} }
}, },
"projects_environments_organizations_not_found": "Proiecte, medii sau organizații nu găsite",
"segments": { "segments": {
"add_filter_below": "Adăugați un filtru mai jos", "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", "add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe",
@@ -986,12 +983,15 @@
"manage_subscription": "Gestionați abonamentul", "manage_subscription": "Gestionați abonamentul",
"monthly": "Lunar", "monthly": "Lunar",
"monthly_identified_users": "Utilizatori identificați 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", "plan_upgraded_successfully": "Planul a fost upgradat cu succes",
"premium_support_with_slas": "Suport premium cu SLA-uri", "premium_support_with_slas": "Suport premium cu SLA-uri",
"remove_branding": "Eliminare branding", "remove_branding": "Eliminare branding",
"startup": "Pornire", "startup": "Pornire",
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.", "startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
"switch_plan": "Schimbă planul", "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ă", "team_access_roles": "Roluri acces echipă",
"unable_to_upgrade_plan": "Nu se poate upgrada planul", "unable_to_upgrade_plan": "Nu se poate upgrada planul",
"unlimited_miu": "MIU Nelimitat", "unlimited_miu": "MIU Nelimitat",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "Adresă Linie 1", "address_line_1": "Adresă Linie 1",
"address_line_2": "Adresă Linie 2", "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", "an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
"browser": "Browser", "browser": "Browser",
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?", "bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
@@ -1882,6 +1880,7 @@
"setup_integrations": "Configurare integrare", "setup_integrations": "Configurare integrare",
"share_survey": "Distribuie chestionarul", "share_survey": "Distribuie chestionarul",
"show_all_responses_that_match": "Afișează toate răspunsurile care corespund", "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": "Începuturi",
"starts_tooltip": "Număr de ori când sondajul a fost început.", "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.", "survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",
+5 -6
View File
@@ -200,7 +200,6 @@
"edit": "编辑", "edit": "编辑",
"email": "邮箱", "email": "邮箱",
"ending_card": "结尾卡片", "ending_card": "结尾卡片",
"enter_url": "输入 URL",
"enterprise_license": "企业 许可证", "enterprise_license": "企业 许可证",
"environment_not_found": "环境 未找到", "environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。", "environment_notice": "你 目前 位于 {environment} 环境。",
@@ -210,8 +209,6 @@
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。", "error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
"error_rate_limit_title": "速率 限制 超过", "error_rate_limit_title": "速率 限制 超过",
"expand_rows": "展开 行", "expand_rows": "展开 行",
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_projects": "加载项目失败",
"finish": "完成", "finish": "完成",
"follow_these": "遵循 这些", "follow_these": "遵循 这些",
"formbricks_version": "Formbricks 版本", "formbricks_version": "Formbricks 版本",
@@ -358,7 +355,6 @@
"segments": "细分", "segments": "细分",
"select": "选择", "select": "选择",
"select_all": "选择 全部", "select_all": "选择 全部",
"select_filter": "选择过滤器",
"select_survey": "选择 调查", "select_survey": "选择 调查",
"select_teams": "选择 团队", "select_teams": "选择 团队",
"selected": "已选择", "selected": "已选择",
@@ -890,6 +886,7 @@
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。" "team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
} }
}, },
"projects_environments_organizations_not_found": "项目 、 环境 或 组织 未 找到",
"segments": { "segments": {
"add_filter_below": "在下方添加 过滤器", "add_filter_below": "在下方添加 过滤器",
"add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始", "add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始",
@@ -986,12 +983,15 @@
"manage_subscription": "管理 订阅", "manage_subscription": "管理 订阅",
"monthly": "每月", "monthly": "每月",
"monthly_identified_users": "每月 已识别的 用户", "monthly_identified_users": "每月 已识别的 用户",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "计划 升级 成功", "plan_upgraded_successfully": "计划 升级 成功",
"premium_support_with_slas": "优质支持与 SLAs", "premium_support_with_slas": "优质支持与 SLAs",
"remove_branding": "移除 品牌", "remove_branding": "移除 品牌",
"startup": "初创企业", "startup": "初创企业",
"startup_description": "包含免费版的所有功能以及附加功能.", "startup_description": "包含免费版的所有功能以及附加功能.",
"switch_plan": "切换 计划", "switch_plan": "切换 计划",
"switch_plan_confirmation_text": "你确定要切换到 {plan} 计划吗?你将被收取 {price} {period} 。",
"team_access_roles": "团队访问角色", "team_access_roles": "团队访问角色",
"unable_to_upgrade_plan": "无法升级计划", "unable_to_upgrade_plan": "无法升级计划",
"unlimited_miu": "无限 MIU", "unlimited_miu": "无限 MIU",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "地址 第1行", "address_line_1": "地址 第1行",
"address_line_2": "地址 第2行", "address_line_2": "地址 第2行",
"an_error_occurred_adding_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": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?", "bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
@@ -1882,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": "显示所有的响应,条件为...",
"starts": "开始", "starts": "开始",
"starts_tooltip": "调查 被 开始 的 次数", "starts_tooltip": "调查 被 开始 的 次数",
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。", "survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",
+5 -6
View File
@@ -200,7 +200,6 @@
"edit": "編輯", "edit": "編輯",
"email": "電子郵件", "email": "電子郵件",
"ending_card": "結尾卡片", "ending_card": "結尾卡片",
"enter_url": "輸入 URL",
"enterprise_license": "企業授權", "enterprise_license": "企業授權",
"environment_not_found": "找不到環境", "environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。", "environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -210,8 +209,6 @@
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。", "error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過", "error_rate_limit_title": "限流超過",
"expand_rows": "展開列", "expand_rows": "展開列",
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_projects": "無法載入專案",
"finish": "完成", "finish": "完成",
"follow_these": "按照這些步驟", "follow_these": "按照這些步驟",
"formbricks_version": "Formbricks 版本", "formbricks_version": "Formbricks 版本",
@@ -358,7 +355,6 @@
"segments": "區隔", "segments": "區隔",
"select": "選擇", "select": "選擇",
"select_all": "全選", "select_all": "全選",
"select_filter": "選擇篩選器",
"select_survey": "選擇問卷", "select_survey": "選擇問卷",
"select_teams": "選擇 團隊", "select_teams": "選擇 團隊",
"selected": "已選取", "selected": "已選取",
@@ -890,6 +886,7 @@
"team_settings_description": "查看哪些團隊可以存取此專案。" "team_settings_description": "查看哪些團隊可以存取此專案。"
} }
}, },
"projects_environments_organizations_not_found": "找不到專案、環境或組織",
"segments": { "segments": {
"add_filter_below": "在下方新增篩選器", "add_filter_below": "在下方新增篩選器",
"add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用", "add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用",
@@ -986,12 +983,15 @@
"manage_subscription": "管理訂閱", "manage_subscription": "管理訂閱",
"monthly": "每月", "monthly": "每月",
"monthly_identified_users": "每月識別使用者", "monthly_identified_users": "每月識別使用者",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級", "plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援", "premium_support_with_slas": "具有 SLA 的頂級支援",
"remove_branding": "移除品牌", "remove_branding": "移除品牌",
"startup": "啟動版", "startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。", "startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案", "switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色", "team_access_roles": "團隊存取角色",
"unable_to_upgrade_plan": "無法升級方案", "unable_to_upgrade_plan": "無法升級方案",
"unlimited_miu": "無限 MIU", "unlimited_miu": "無限 MIU",
@@ -1664,8 +1664,6 @@
"responses": { "responses": {
"address_line_1": "地址 1", "address_line_1": "地址 1",
"address_line_2": "地址 2", "address_line_2": "地址 2",
"an_error_occurred_adding_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": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?", "bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
@@ -1882,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": "顯示所有回應,其中...",
"starts": "開始次數", "starts": "開始次數",
"starts_tooltip": "問卷已開始的次數。", "starts_tooltip": "問卷已開始的次數。",
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。", "survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
@@ -1,11 +1,10 @@
"use client"; "use client";
import { SettingsIcon } from "lucide-react"; import { AlertCircleIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag"; 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 [open, setOpen] = React.useState(false);
const [tagsState, setTagsState] = useState(tags); const [tagsState, setTagsState] = useState(tags);
const [tagIdToHighlight, setTagIdToHighlight] = useState(""); const [tagIdToHighlight, setTagIdToHighlight] = useState("");
const [isLoadingTagOperation, setIsLoadingTagOperation] = useState(false);
const onDelete = async (tagId: string) => { const onDelete = async (tagId: string) => {
setIsLoadingTagOperation(true); try {
const deleteTagResponse = await deleteTagOnResponseAction({ responseId, tagId }); await deleteTagOnResponseAction({ responseId, tagId });
if (deleteTagResponse?.data) {
updateFetchedResponses(); updateFetchedResponses();
} else { } catch (e) {
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
logger.error({ errorMessage }, "Error deleting tag");
toast.error(t("environments.surveys.responses.an_error_occurred_deleting_the_tag")); toast.error(t("environments.surveys.responses.an_error_occurred_deleting_the_tag"));
} }
setIsLoadingTagOperation(false);
}; };
useEffect(() => { useEffect(() => {
@@ -66,70 +60,72 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
}, [tagIdToHighlight]); }, [tagIdToHighlight]);
const handleCreateTag = async (tagName: string) => { const handleCreateTag = async (tagName: string) => {
setIsLoadingTagOperation(true); setOpen(false);
const newTagResponse = await createTagAction({ environmentId, tagName });
if (!newTagResponse?.data) { const createTagResponse = await createTagAction({
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag")); environmentId,
return; tagName: tagName?.trim() ?? "",
} });
if (!newTagResponse.data.ok) { if (createTagResponse?.data?.ok) {
const errorMessage = newTagResponse.data.error; const tag = createTagResponse.data.data;
if (errorMessage?.code === TagError.TAG_NAME_ALREADY_EXISTS) { setTagsState((prevTags) => [
toast.error(t("environments.surveys.responses.tag_already_exists"), { ...prevTags,
duration: 2000, {
icon: <SettingsIcon className="h-5 w-5 text-orange-500" />, tagId: tag.id,
}); tagName: tag.name,
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: tag.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
} else { } else {
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag")); const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
toast.error(errorMessage);
} }
return; return;
} }
const newTag = newTagResponse.data.data; if (
const createTagToResponseResponse = await createTagToResponseAction({ responseId, tagId: newTag.id }); createTagResponse?.data?.ok === false &&
if (createTagToResponseResponse?.data) { createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS
setTagsState((prevTags) => [...prevTags, { tagId: newTag.id, tagName: newTag.name }]); ) {
setTagIdToHighlight(newTag.id); toast.error(t("environments.surveys.responses.tag_already_exists"), {
updateFetchedResponses(); duration: 2000,
setSearchValue(""); icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
setOpen(false); });
} else {
const errorMessage = getFormattedErrorMessage(createTagToResponseResponse);
logger.error({ errorMessage });
toast.error(errorMessage);
}
setIsLoadingTagOperation(false);
};
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(""); setSearchValue("");
setOpen(false); return;
} 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);
} }
const errorMessage = getFormattedErrorMessage(createTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
setSearchValue("");
}; };
return ( 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"> <div className="flex flex-wrap items-center gap-2">
{tagsState?.map((tag) => ( {tagsState?.map((tag) => (
<Tag <Tag
@@ -140,35 +136,37 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
tags={tagsState} tags={tagsState}
setTagsState={setTagsState} setTagsState={setTagsState}
highlight={tagIdToHighlight === tag.tagId} highlight={tagIdToHighlight === tag.tagId}
allowDelete={!isReadOnly && !isLoadingTagOperation} allowDelete={!isReadOnly}
/> />
))} ))}
{!isReadOnly && ( {!isReadOnly && (
<TagsCombobox <TagsCombobox
open={open && !isLoadingTagOperation} open={open}
setOpen={setOpen} setOpen={setOpen}
searchValue={searchValue} searchValue={searchValue}
setSearchValue={setSearchValue} setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []} tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))} currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={handleCreateTag} 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> </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> </div>
); );
}; };
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
@@ -41,58 +42,46 @@ export const SingleResponseCard = ({
setSelectedResponseId, setSelectedResponseId,
locale, locale,
}: SingleResponseCardProps) => { }: 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 [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
const { t } = useTranslation(); const { t } = useTranslation();
const environmentId = survey.environmentId; const environmentId = survey.environmentId;
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const skippedQuestions: string[][] = useMemo(() => { let skippedQuestions: string[][] = [];
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => { let temp: string[] = [];
if (temp.length > 0) {
if (shouldReverse) temp.reverse(); if (response.finished) {
result.push([...temp]); survey.questions.forEach((question) => {
temp.length = 0; if (!isValidValue(response.data[question.id])) {
temp.push(question.id);
} else if (temp.length > 0) {
skippedQuestions.push([...temp]);
temp = [];
} }
}; });
} else {
const processFinishedResponse = () => { for (let index = survey.questions.length - 1; index >= 0; index--) {
const result: string[][] = []; const question = survey.questions[index];
let temp: string[] = []; if (
!response.data[question.id] &&
for (const question of survey.questions) { (skippedQuestions.length === 0 ||
if (isValidValue(response.data[question.id])) { (skippedQuestions.length > 0 && !isValidValue(response.data[question.id])))
flushTemp(temp, result); ) {
} else { temp.push(question.id);
temp.push(question.id); } else if (temp.length > 0) {
} temp.reverse();
skippedQuestions.push([...temp]);
temp = [];
} }
flushTemp(temp, result); }
return result; }
}; // Handle the case where the last entries are empty
if (temp.length > 0) {
const processUnfinishedResponse = () => { skippedQuestions.push(temp);
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]);
const handleDeleteResponse = async () => { const handleDeleteResponse = async () => {
setIsDeleting(true); setIsDeleting(true);
@@ -102,6 +91,7 @@ export const SingleResponseCard = ({
} }
await deleteResponseAction({ responseId: response.id, decrementQuotas }); await deleteResponseAction({ responseId: response.id, decrementQuotas });
updateResponseList?.([response.id]); updateResponseList?.([response.id]);
router.refresh();
if (setSelectedResponseId) setSelectedResponseId(null); if (setSelectedResponseId) setSelectedResponseId(null);
toast.success(t("environments.surveys.responses.response_deleted_successfully")); toast.success(t("environments.surveys.responses.response_deleted_successfully"));
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi"; import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses"; import { ZResponse } from "@formbricks/database/zod/responses";
import { ZResponseUpdateInput } from "@formbricks/types/responses"; import { ZResponseInput } from "@formbricks/types/responses";
import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses"; import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response"; import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
@@ -52,8 +52,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
export const updateResponseEndpoint: ZodOpenApiOperationObject = { export const updateResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "updateResponse", operationId: "updateResponse",
summary: "Update a response", summary: "Update a response",
description: description: "Updates a response in the database.",
"Updates a response in the database. This will trigger the response pipeline, including webhooks, integrations, follow-up emails (if the response is marked as finished), and other configured actions.",
tags: ["Management API - Responses"], tags: ["Management API - Responses"],
requestParams: { requestParams: {
path: z.object({ path: z.object({
@@ -62,10 +61,10 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
}, },
requestBody: { requestBody: {
required: true, required: true,
description: "The response fields to update", description: "The response to update",
content: { content: {
"application/json": { "application/json": {
schema: ZResponseUpdateInput, schema: ZResponseInput,
}, },
}, },
}, },
@@ -4,7 +4,6 @@ import { z } from "zod";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TResponse } from "@formbricks/types/responses";
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display"; import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils"; import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
@@ -33,58 +32,6 @@ export const getResponse = reactCache(async (responseId: string) => {
} }
}); });
export const getResponseForPipeline = async (
responseId: string
): Promise<Result<TResponse, ApiErrorResponseV2>> => {
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
include: {
contact: {
select: {
id: true,
},
},
tags: {
select: {
tag: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
environmentId: true,
},
},
},
},
},
});
if (!responsePrisma) {
return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] });
}
return ok({
...responsePrisma,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id,
userId: responsePrisma.contactAttributes?.userId,
}
: null,
tags: responsePrisma.tags.map((t) => t.tag),
});
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "response", issue: error.message }],
});
}
};
export const deleteResponse = async (responseId: string): Promise<Result<Response, ApiErrorResponseV2>> => { export const deleteResponse = async (responseId: string): Promise<Result<Response, ApiErrorResponseV2>> => {
try { try {
const deletedResponse = await prisma.response.delete({ const deletedResponse = await prisma.response.delete({
@@ -7,13 +7,7 @@ import { ok, okVoid } from "@formbricks/types/error-handlers";
import { TSurveyQuota } from "@formbricks/types/quota"; import { TSurveyQuota } from "@formbricks/types/quota";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { deleteDisplay } from "../display"; import { deleteDisplay } from "../display";
import { import { deleteResponse, getResponse, updateResponse, updateResponseWithQuotaEvaluation } from "../response";
deleteResponse,
getResponse,
getResponseForPipeline,
updateResponse,
updateResponseWithQuotaEvaluation,
} from "../response";
import { getSurveyQuestions } from "../survey"; import { getSurveyQuestions } from "../survey";
import { findAndDeleteUploadedFilesInResponse } from "../utils"; import { findAndDeleteUploadedFilesInResponse } from "../utils";
@@ -112,177 +106,6 @@ describe("Response Lib", () => {
}); });
}); });
describe("getResponseForPipeline", () => {
test("return the response with contact and tags when found", async () => {
const mockPrismaResponse = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
displayId: "jowdit1qrf04t97jcc0io9di",
finished: true,
data: { question1: "answer1" },
meta: {},
ttc: {},
variables: {},
contactAttributes: { userId: "user123" },
singleUseId: null,
language: "en",
endingId: null,
contact: {
id: "olwablfltg9eszoh0nz83w02",
},
tags: [
{
tag: {
id: "tag123",
createdAt: new Date(),
updatedAt: new Date(),
name: "important",
environmentId: "env123",
},
},
],
};
vi.mocked(prisma.response.findUnique).mockResolvedValue(mockPrismaResponse as any);
const result = await getResponseForPipeline(responseId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
...mockPrismaResponse,
contact: {
id: "olwablfltg9eszoh0nz83w02",
userId: "user123",
},
tags: [
{
id: "tag123",
createdAt: mockPrismaResponse.tags[0].tag.createdAt,
updatedAt: mockPrismaResponse.tags[0].tag.updatedAt,
name: "important",
environmentId: "env123",
},
],
});
}
expect(prisma.response.findUnique).toHaveBeenCalledWith({
where: { id: responseId },
include: {
contact: {
select: {
id: true,
},
},
tags: {
select: {
tag: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
environmentId: true,
},
},
},
},
},
});
});
test("return the response with null contact when contact does not exist", async () => {
const mockPrismaResponseWithoutContact = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
displayId: "jowdit1qrf04t97jcc0io9di",
finished: true,
data: { question1: "answer1" },
meta: {},
ttc: {},
variables: {},
contactAttributes: null,
singleUseId: null,
language: "en",
endingId: null,
contact: null,
tags: [],
};
vi.mocked(prisma.response.findUnique).mockResolvedValue(mockPrismaResponseWithoutContact as any);
const result = await getResponseForPipeline(responseId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.contact).toBeNull();
expect(result.data.tags).toEqual([]);
}
});
test("return a not_found error when the response is missing", async () => {
vi.mocked(prisma.response.findUnique).mockResolvedValue(null);
const result = await getResponseForPipeline(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "response", issue: "not found" }],
});
}
});
test("return an internal_server_error when prisma throws an error", async () => {
vi.mocked(prisma.response.findUnique).mockRejectedValue(new Error("DB error"));
const result = await getResponseForPipeline(responseId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "response", issue: "DB error" }],
});
}
});
test("handle response with contact but no userId in contactAttributes", async () => {
const mockPrismaResponse = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
displayId: null,
finished: false,
data: {},
meta: {},
ttc: {},
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
endingId: null,
contact: {
id: "contact-id",
},
tags: [],
};
vi.mocked(prisma.response.findUnique).mockResolvedValue(mockPrismaResponse as any);
const result = await getResponseForPipeline(responseId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.contact).toEqual({
id: "contact-id",
userId: undefined,
});
}
});
});
describe("deleteResponse", () => { describe("deleteResponse", () => {
test("delete the response, delete the display and remove uploaded files", async () => { test("delete the response, delete the display and remove uploaded files", async () => {
vi.mocked(prisma.response.delete).mockResolvedValue(response); vi.mocked(prisma.response.delete).mockResolvedValue(response);
@@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import { sendToPipeline } from "@/app/lib/pipelines";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response"; import { responses } from "@/modules/api/v2/lib/response";
@@ -8,7 +7,6 @@ import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { import {
deleteResponse, deleteResponse,
getResponse, getResponse,
getResponseForPipeline,
updateResponseWithQuotaEvaluation, updateResponseWithQuotaEvaluation,
} from "@/modules/api/v2/management/responses/[responseId]/lib/response"; } from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
@@ -126,7 +124,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request, request,
{ {
type: "bad_request", type: "bad_request",
details: [{ field: body ? "params" : "body", issue: "missing" }], details: [{ field: !body ? "body" : "params", issue: "missing" }],
}, },
auditLog auditLog
); );
@@ -198,26 +196,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
return handleApiError(request, response.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error return handleApiError(request, response.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
} }
// Fetch updated response with relations for pipeline
const updatedResponseForPipeline = await getResponseForPipeline(params.responseId);
if (updatedResponseForPipeline.ok) {
sendToPipeline({
event: "responseUpdated",
environmentId: environmentIdResult.data,
surveyId: existingResponse.data.surveyId,
response: updatedResponseForPipeline.data,
});
if (response.data.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: environmentIdResult.data,
surveyId: existingResponse.data.surveyId,
response: updatedResponseForPipeline.data,
});
}
}
if (auditLog) { if (auditLog) {
auditLog.oldObject = existingResponse.data; auditLog.oldObject = existingResponse.data;
auditLog.newObject = response.data; auditLog.newObject = response.data;
@@ -1,62 +0,0 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const getContactByUserId = async (
environmentId: string,
userId: string
): Promise<
Result<
{
id: string;
attributes: TContactAttributes;
} | null,
ApiErrorResponseV2
>
> => {
try {
const contact = await prisma.contact.findFirst({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
if (!contact) {
return ok(null);
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
return ok({
id: contact.id,
attributes: contactAttributes,
});
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contact", issue: error.message }],
});
}
};
@@ -32,8 +32,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
export const createResponseEndpoint: ZodOpenApiOperationObject = { export const createResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "createResponse", operationId: "createResponse",
summary: "Create a response", summary: "Create a response",
description: description: "Creates a response in the database.",
"Creates a response in the database. This will trigger the response pipeline, including webhooks, integrations, follow-up emails, and other configured actions.",
tags: ["Management API - Responses"], tags: ["Management API - Responses"],
requestBody: { requestBody: {
required: true, required: true,
@@ -2,13 +2,11 @@ import "server-only";
import { Prisma, Response } from "@prisma/client"; import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry"; import { captureTelemetry } from "@/lib/telemetry";
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationBilling, getOrganizationBilling,
@@ -56,7 +54,6 @@ export const createResponse = async (
const { const {
surveyId, surveyId,
displayId, displayId,
userId,
finished, finished,
data, data,
language, language,
@@ -70,17 +67,6 @@ export const createResponse = async (
} = responseInput; } = responseInput;
try { try {
let contact: { id: string; attributes: TContactAttributes } | null = null;
// If userId is provided, look up the contact by userId
if (userId) {
const contactResult = await getContactByUserId(environmentId, userId);
if (!contactResult.ok) {
return err(contactResult.error);
}
contact = contactResult.data;
}
let ttc = {}; let ttc = {};
if (initialTtc) { if (initialTtc) {
if (finished) { if (finished) {
@@ -97,14 +83,6 @@ export const createResponse = async (
}, },
}, },
display: displayId ? { connect: { id: displayId } } : undefined, display: displayId ? { connect: { id: displayId } } : undefined,
...(contact?.id && {
contact: {
connect: {
id: contact.id,
},
},
contactAttributes: contact.attributes,
}),
finished, finished,
data, data,
language, language,
@@ -1,176 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { getContactByUserId } from "../contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
const environmentId = "test-env-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const mockContactDbData = {
id: contactId,
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "plan" }, value: "premium" },
],
};
const expectedContactAttributes: TContactAttributes = {
userId: userId,
email: "test@example.com",
plan: "premium",
};
describe("getContactByUserId", () => {
test("should return ok result with contact and attributes when found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
const result = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
id: contactId,
attributes: expectedContactAttributes,
});
}
});
test("should return ok result with null when contact is not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeNull();
}
});
test("should return error result when database throws an error", async () => {
const errorMessage = "Database connection failed";
vi.mocked(prisma.contact.findFirst).mockRejectedValue(new Error(errorMessage));
const result = await getContactByUserId(environmentId, userId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
details: [{ field: "contact", issue: errorMessage }],
});
}
});
test("should correctly transform multiple attributes", async () => {
const mockContactWithManyAttributes = {
id: contactId,
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [
{ attributeKey: { key: "userId" }, value: "user123" },
{ attributeKey: { key: "email" }, value: "multi@example.com" },
{ attributeKey: { key: "firstName" }, value: "John" },
{ attributeKey: { key: "lastName" }, value: "Doe" },
{ attributeKey: { key: "company" }, value: "Acme Corp" },
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactWithManyAttributes);
const result = await getContactByUserId(environmentId, userId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data?.attributes).toEqual({
userId: "user123",
email: "multi@example.com",
firstName: "John",
lastName: "Doe",
company: "Acme Corp",
});
}
});
test("should handle contact with empty attributes array", async () => {
const mockContactWithNoAttributes = {
id: contactId,
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactWithNoAttributes);
const result = await getContactByUserId(environmentId, userId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
id: contactId,
attributes: {},
});
}
});
});
@@ -1,12 +1,10 @@
import { Response } from "@prisma/client"; import { Response } from "@prisma/client";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { sendToPipeline } from "@/app/lib/pipelines";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response"; import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils"; import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper"; import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getResponseForPipeline } from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses"; import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -133,26 +131,6 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error, auditLog); return handleApiError(request, createResponseResult.error, auditLog);
} }
// Fetch created response with relations for pipeline
const createdResponseForPipeline = await getResponseForPipeline(createResponseResult.data.id);
if (createdResponseForPipeline.ok) {
sendToPipeline({
event: "responseCreated",
environmentId: environmentId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
if (createResponseResult.data.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: environmentId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
}
}
if (auditLog) { if (auditLog) {
auditLog.targetId = createResponseResult.data.id; auditLog.targetId = createResponseResult.data.id;
auditLog.newObject = createResponseResult.data; auditLog.newObject = createResponseResult.data;
@@ -32,20 +32,16 @@ export const ZResponseInput = ZResponse.pick({
variables: true, variables: true,
ttc: true, ttc: true,
meta: true, meta: true,
}) }).partial({
.partial({ displayId: true,
displayId: true, singleUseId: true,
singleUseId: true, endingId: true,
endingId: true, language: true,
language: true, variables: true,
variables: true, ttc: true,
ttc: true, meta: true,
meta: true, createdAt: true,
createdAt: true, updatedAt: true,
updatedAt: true, });
})
.extend({
userId: z.string().optional(),
});
export type TResponseInput = z.infer<typeof ZResponseInput>; export type TResponseInput = z.infer<typeof ZResponseInput>;
+1
View File
@@ -31,6 +31,7 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo"; import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
debug: true,
providers: [ providers: [
CredentialsProvider({ CredentialsProvider({
id: "credentials", id: "credentials",
@@ -1,67 +1,43 @@
import Stripe from "stripe"; import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors"; 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 { 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!, { const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: STRIPE_API_VERSION, apiVersion: STRIPE_API_VERSION,
}); });
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => { export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
const checkoutSession = event.data.object as Stripe.Checkout.Session; 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); 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) if (!organization)
throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId); throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId);
const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string, { await stripe.subscriptions.update(stripeSubscriptionObject.id, {
expand: ["items.data.price"], metadata: {
}); organizationId: organization.id,
responses: checkoutSession.metadata.responses,
let period: "monthly" | "yearly" = "monthly"; miu: checkoutSession.metadata.miu,
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(),
}, },
}); });
logger.info( await stripe.customers.update(stripeCustomer.id, {
{ name: organization.name,
organizationId: checkoutSession.metadata.organizationId, metadata: { organizationId: organization.id },
plan: PROJECT_FEATURE_KEYS.STARTUP, invoice_settings: {
period, default_payment_method: stripeSubscriptionObject.default_payment_method as string,
checkoutSessionId: checkoutSession.id,
}, },
"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.7500_contacts"),
t("environments.settings.billing.3_projects"), t("environments.settings.billing.3_projects"),
t("environments.settings.billing.remove_branding"), t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.attribute_based_targeting"), t("environments.settings.billing.attribute_based_targeting"),
], ],
}; };
const customPlan: TPricingPlan = { const customPlan: TPricingPlan = {
id: "custom", id: "enterprise",
name: t("environments.settings.billing.custom"), name: t("environments.settings.billing.custom"),
featured: false, featured: false,
CTA: t("common.request_pricing"), CTA: t("common.request_pricing"),
@@ -68,7 +69,6 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
}, },
mainFeatures: [ mainFeatures: [
t("environments.settings.billing.everything_in_startup"), 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_response_limit"),
t("environments.settings.billing.custom_contacts_limit"), t("environments.settings.billing.custom_contacts_limit"),
t("environments.settings.billing.custom_project_limit"), t("environments.settings.billing.custom_project_limit"),
@@ -16,17 +16,22 @@ export const createSubscription = async (
try { try {
const organization = await getOrganization(organizationId); const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Organization not found."); if (!organization) throw new Error("Organization not found.");
let isNewOrganization =
!organization.billing.stripeCustomerId ||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
const priceObject = ( const priceObject = (
await stripe.prices.list({ await stripe.prices.list({
lookup_keys: [priceLookupKey], lookup_keys: [priceLookupKey],
expand: ["data.product"],
}) })
).data[0]; ).data[0];
if (!priceObject) throw new Error("Price not found"); 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 checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
const session = await stripe.checkout.sessions.create({
mode: "subscription", mode: "subscription",
line_items: [ line_items: [
{ {
@@ -36,20 +41,63 @@ export const createSubscription = async (
], ],
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`, success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`, cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
customer: organization.billing.stripeCustomerId ?? undefined,
allow_promotion_codes: true, allow_promotion_codes: true,
subscription_data: { subscription_data: {
metadata: { organizationId }, metadata: { organizationId },
trial_period_days: 15, trial_period_days: 30,
}, },
metadata: { organizationId }, metadata: { organizationId, responses, miu },
billing_address_collection: "required", billing_address_collection: "required",
automatic_tax: { enabled: true }, automatic_tax: { enabled: true },
tax_id_collection: { enabled: true }, tax_id_collection: { enabled: true },
payment_method_data: { allow_redisplay: "always" }, 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) { } catch (err) {
logger.error(err, "Error creating subscription"); logger.error(err, "Error creating subscription");
return { return {
@@ -1,68 +1,32 @@
import Stripe from "stripe"; 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"; import { getOrganization, updateOrganization } from "@/lib/organization/service";
export const handleInvoiceFinalized = async (event: Stripe.Event) => { export const handleInvoiceFinalized = async (event: Stripe.Event) => {
const invoice = event.data.object as Stripe.Invoice; const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription as string; const stripeSubscriptionDetails = invoice.subscription_details;
if (!subscriptionId) { const organizationId = stripeSubscriptionDetails?.metadata?.organizationId;
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
return { status: 400, message: "No subscription ID found in invoice" }; if (!organizationId) {
throw new Error("No organizationId found in subscription");
} }
try { const organization = await getOrganization(organizationId);
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { if (!organization) {
apiVersion: STRIPE_API_VERSION, throw new Error("Organization not found");
});
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 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 { env } from "@/lib/env";
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed"; import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized"; 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"; import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { 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); event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error"; 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}` }; return { status: 400, message: `Webhook Error: ${errorMessage}` };
} }
@@ -27,6 +28,11 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleCheckoutSessionCompleted(event); await handleCheckoutSessionCompleted(event);
} else if (event.type === "invoice.finalized") { } else if (event.type === "invoice.finalized") {
await handleInvoiceFinalized(event); 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") { } else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event); 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", period: "monthly",
}, },
}); });
logger.info(
{
organizationId,
subscriptionId: stripeSubscriptionObject.id,
},
"Subscription cancelled - downgraded to FREE plan"
);
}; };
@@ -19,7 +19,7 @@ interface PricingCardProps {
projectFeatureKeys: { projectFeatureKeys: {
FREE: string; FREE: string;
STARTUP: string; STARTUP: string;
CUSTOM: string; ENTERPRISE: string;
}; };
} }
@@ -33,21 +33,17 @@ export const PricingCard = ({
}: PricingCardProps) => { }: PricingCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const displayPrice = (() => {
if (plan.id === projectFeatureKeys.CUSTOM) {
return plan.price.monthly;
}
return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
})();
const isCurrentPlan = useMemo(() => { const isCurrentPlan = useMemo(() => {
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) { if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
return true; return true;
} }
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) { if (
organization.billing.plan === projectFeatureKeys.ENTERPRISE &&
plan.id === projectFeatureKeys.ENTERPRISE
) {
return true; return true;
} }
@@ -57,7 +53,7 @@ export const PricingCard = ({
organization.billing.plan, organization.billing.plan,
plan.id, plan.id,
planPeriod, planPeriod,
projectFeatureKeys.CUSTOM, projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.FREE, projectFeatureKeys.FREE,
]); ]);
@@ -66,7 +62,7 @@ export const PricingCard = ({
return null; return null;
} }
if (plan.id === projectFeatureKeys.CUSTOM) { if (plan.id === projectFeatureKeys.ENTERPRISE) {
return ( return (
<Button <Button
variant="outline" variant="outline"
@@ -101,7 +97,7 @@ export const PricingCard = ({
<Button <Button
loading={loading} loading={loading}
onClick={() => { onClick={() => {
setContactModalOpen(true); setUpgradeModalOpen(true);
}} }}
className="flex justify-center"> className="flex justify-center">
{t("environments.settings.billing.switch_plan")} {t("environments.settings.billing.switch_plan")}
@@ -119,7 +115,7 @@ export const PricingCard = ({
plan.featured, plan.featured,
plan.href, plan.href,
plan.id, plan.id,
projectFeatureKeys.CUSTOM, projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.FREE, projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP, projectFeatureKeys.STARTUP,
t, t,
@@ -155,9 +151,13 @@ export const PricingCard = ({
plan.featured ? "text-slate-900" : "text-slate-800", plan.featured ? "text-slate-900" : "text-slate-800",
"text-4xl font-bold tracking-tight" "text-4xl font-bold tracking-tight"
)}> )}>
{displayPrice} {plan.id !== projectFeatureKeys.ENTERPRISE
? planPeriod === "monthly"
? plan.price.monthly
: plan.price.yearly
: plan.price.monthly}
</p> </p>
{plan.id !== projectFeatureKeys.CUSTOM && ( {plan.id !== projectFeatureKeys.ENTERPRISE && (
<div className="text-sm leading-5"> <div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}> <p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"} / {planPeriod === "monthly" ? "Month" : "Year"}
@@ -203,13 +203,28 @@ export const PricingCard = ({
</div> </div>
<ConfirmationModal <ConfirmationModal
title="Please reach out to us" title={t("environments.settings.billing.switch_plan")}
open={contactModalOpen} buttonText={t("common.confirm")}
setOpen={setContactModalOpen} onConfirm={async () => {
onConfirm={() => setContactModalOpen(false)} setLoading(true);
buttonText="Close" 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" buttonVariant="default"
body="To switch your billing rhythm, please reach out to hola@formbricks.com" buttonLoading={loading}
closeOnOutsideClick={false}
hideCloseButton
/> />
</div> </div>
); );
@@ -26,7 +26,7 @@ interface PricingTableProps {
projectFeatureKeys: { projectFeatureKeys: {
FREE: string; FREE: string;
STARTUP: string; STARTUP: string;
CUSTOM: string; ENTERPRISE: string;
}; };
hasBillingRights: boolean; hasBillingRights: boolean;
} }
@@ -127,11 +127,11 @@ export const PricingTable = ({
}; };
const responsesUnlimitedCheck = const responsesUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null; organization.billing.plan === "enterprise" && organization.billing.limits.monthly.responses === null;
const peopleUnlimitedCheck = const peopleUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null; organization.billing.plan === "enterprise" && organization.billing.limits.monthly.miu === null;
const projectsUnlimitedCheck = const projectsUnlimitedCheck =
organization.billing.plan === "custom" && organization.billing.limits.projects === null; organization.billing.plan === "enterprise" && organization.billing.limits.projects === null;
return ( return (
<main> <main>
@@ -92,7 +92,7 @@ export const UploadContactsAttributeCombobox = ({
}} }}
/> />
</div> </div>
<CommandList className="border-0"> <CommandList>
<CommandGroup> <CommandGroup>
{keys.map((tag) => { {keys.map((tag) => {
return ( return (
@@ -94,7 +94,7 @@ describe("License Utils", () => {
test("should return true if license active and plan is not FREE (cloud)", async () => { test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true; vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); 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); 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 () => { test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true; vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); 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); expect(result).toBe(true);
}); });
@@ -154,17 +154,27 @@ describe("License Utils", () => {
expect(result).toBe(true); 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(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense, ...defaultLicense,
features: { ...defaultFeatures, accessControl: true }, 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); 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(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense, ...defaultLicense,
@@ -174,16 +184,6 @@ describe("License Utils", () => {
expect(result).toBe(false); 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 () => { test("should return true if license active but accessControl feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getAccessControlPermission(mockOrganization.billing.plan); 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 () => { test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true; vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); 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); expect(result).toBe(true);
}); });
@@ -243,17 +243,27 @@ describe("License Utils", () => {
expect(result).toBe(true); 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(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense, ...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true }, 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); 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(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense, ...defaultLicense,
@@ -263,16 +273,6 @@ describe("License Utils", () => {
expect(result).toBe(false); 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 () => { test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense); vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(mockOrganization.billing.plan); 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 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(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense, ...defaultLicense,
features: { ...defaultFeatures, spamProtection: true }, 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); 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(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({ vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense, ...defaultLicense,
@@ -111,7 +111,9 @@ export const getIsSpamProtectionEnabled = async (
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
return ( 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 featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
const license = await getEnterpriseLicense(); 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; else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false; return false;
}; };
+6 -1
View File
@@ -14,6 +14,7 @@ import {
OIDC_CLIENT_SECRET, OIDC_CLIENT_SECRET,
OIDC_DISPLAY_NAME, OIDC_DISPLAY_NAME,
OIDC_ISSUER, OIDC_ISSUER,
OIDC_ISSUER_INTERNAL,
OIDC_SIGNING_ALGORITHM, OIDC_SIGNING_ALGORITHM,
WEBAPP_URL, WEBAPP_URL,
} from "@/lib/constants"; } from "@/lib/constants";
@@ -39,7 +40,11 @@ export const getSSOProviders = () => [
type: "oauth" as const, type: "oauth" as const,
clientId: OIDC_CLIENT_ID || "", clientId: OIDC_CLIENT_ID || "",
clientSecret: OIDC_CLIENT_SECRET || "", clientSecret: OIDC_CLIENT_SECRET || "",
wellKnown: `${OIDC_ISSUER}/.well-known/openid-configuration`, // Use OIDC_ISSUER_INTERNAL for server-side token validation if set,
// otherwise fall back to OIDC_ISSUER (maintains backward compatibility)
wellKnown: `${OIDC_ISSUER_INTERNAL || OIDC_ISSUER}/.well-known/openid-configuration`,
// Use regular OIDC_ISSUER for authorization (browser redirects)
issuer: OIDC_ISSUER,
authorization: { params: { scope: "openid email profile" } }, authorization: { params: { scope: "openid email profile" } },
idToken: true, idToken: true,
client: { client: {
+2 -481
View File
@@ -2,7 +2,6 @@
// Pull in the mocked implementations to configure them in tests // Pull in the mocked implementations to configure them in tests
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
@@ -13,24 +12,12 @@ import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getUser } from "@/lib/user/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 { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
// Pull in the mocked implementations to configure them in tests import { environmentIdLayoutChecks, getEnvironmentAuth } from "./utils";
import {
environmentIdLayoutChecks,
getEnvironmentAuth,
getEnvironmentLayoutData,
getEnvironmentWithRelations,
} from "./utils";
// Mock all external dependencies // Mock all external dependencies
vi.mock("@/lingodotdev/server", () => ({ vi.mock("@/lingodotdev/server", () => ({
@@ -71,8 +58,6 @@ vi.mock("@/lib/membership/utils", () => ({
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(), getOrganizationByEnvironmentId: vi.fn(),
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
getMonthlyOrganizationResponseCount: vi.fn(),
})); }));
vi.mock("@/lib/project/service", () => ({ vi.mock("@/lib/project/service", () => ({
@@ -83,36 +68,12 @@ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(), 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", () => ({ vi.mock("@formbricks/types/errors", () => ({
AuthorizationError: class AuthorizationError extends Error {}, AuthorizationError: class AuthorizationError extends Error {},
DatabaseError: class DatabaseError extends Error {},
})); }));
describe("utils.ts", () => { describe("utils.ts", () => {
beforeEach(() => { beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
// Provide default mocks for successful scenario // Provide default mocks for successful scenario
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } }); vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } });
vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment); vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment);
@@ -135,16 +96,6 @@ describe("utils.ts", () => {
}); });
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true); vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
vi.mocked(getUser).mockResolvedValue({ id: "user123" } as TUser); 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", () => { describe("getEnvironmentAuth", () => {
@@ -219,434 +170,4 @@ describe("utils.ts", () => {
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found"); 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);
});
});
}); });
+3 -227
View File
@@ -1,30 +1,18 @@
import { Prisma } from "@prisma/client";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { AuthorizationError } from "@formbricks/types/errors";
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 { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { validateInputs } from "@/lib/utils/validate";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { 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 { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; 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 * 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 }; 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 { z } from "zod";
import { TEnvironment, ZEnvironment } from "@formbricks/types/environment"; import { ZEnvironment } from "@formbricks/types/environment";
import { TMembership, ZMembership } from "@formbricks/types/memberships"; import { ZMembership } from "@formbricks/types/memberships";
import { TOrganization, ZOrganization } from "@formbricks/types/organizations"; import { ZOrganization } from "@formbricks/types/organizations";
import { TProject, ZProject } from "@formbricks/types/project"; import { ZProject } from "@formbricks/types/project";
import { TUser, ZUser } from "@formbricks/types/user"; import { ZUser } from "@formbricks/types/user";
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license"; import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
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;
};
export const ZEnvironmentAuth = z.object({ export const ZEnvironmentAuth = z.object({
environment: ZEnvironment, environment: ZEnvironment,
@@ -38,25 +27,3 @@ export const ZEnvironmentAuth = z.object({
}); });
export type TEnvironmentAuth = z.infer<typeof ZEnvironmentAuth>; 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" 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> </div>
<CommandList className="border-0"> <CommandList>
<CommandEmpty> <CommandEmpty>
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div> <div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
</CommandEmpty> </CommandEmpty>
@@ -4,7 +4,7 @@ import { Project } from "@prisma/client";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { ArrowLeftIcon, SettingsIcon } from "lucide-react"; import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation"; 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 toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
@@ -67,7 +67,6 @@ export const SurveyMenuBar = ({
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false); const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false); const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
const [isSurveySaving, setIsSurveySaving] = useState(false); const [isSurveySaving, setIsSurveySaving] = useState(false);
const isSuccessfullySavedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (audiencePrompt && activeId === "settings") { if (audiencePrompt && activeId === "settings") {
@@ -79,21 +78,9 @@ export const SurveyMenuBar = ({
setIsLinkSurvey(localSurvey.type === "link"); setIsLinkSurvey(localSurvey.type === "link");
}, [localSurvey.type]); }, [localSurvey.type]);
// Reset the successfully saved flag when survey prop updates (page refresh complete)
useEffect(() => {
if (isSuccessfullySavedRef.current) {
isSuccessfullySavedRef.current = false;
}
}, [survey]);
useEffect(() => { useEffect(() => {
const warningText = t("environments.surveys.edit.unsaved_changes_warning"); const warningText = t("environments.surveys.edit.unsaved_changes_warning");
const handleWindowClose = (e: BeforeUnloadEvent) => { const handleWindowClose = (e: BeforeUnloadEvent) => {
// Skip warning if we just successfully saved
if (isSuccessfullySavedRef.current) {
return;
}
if (!isEqual(localSurvey, survey)) { if (!isEqual(localSurvey, survey)) {
e.preventDefault(); e.preventDefault();
return (e.returnValue = warningText); return (e.returnValue = warningText);
@@ -262,8 +249,6 @@ export const SurveyMenuBar = ({
if (updatedSurveyResponse?.data) { if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data); setLocalSurvey(updatedSurveyResponse.data);
toast.success(t("environments.surveys.edit.changes_saved")); toast.success(t("environments.surveys.edit.changes_saved"));
// Set flag to prevent beforeunload warning during router.refresh()
isSuccessfullySavedRef.current = true;
router.refresh(); router.refresh();
} else { } else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse); const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
@@ -313,8 +298,6 @@ export const SurveyMenuBar = ({
segment, segment,
}); });
setIsSurveyPublishing(false); setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`); router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -10,8 +10,6 @@ vi.mock("@/lib/constants", async () => {
IS_FORMBRICKS_CLOUD: true, IS_FORMBRICKS_CLOUD: true,
PROJECT_FEATURE_KEYS: { PROJECT_FEATURE_KEYS: {
FREE: "free", FREE: "free",
STARTUP: "startup",
CUSTOM: "custom",
}, },
}; };
}); });
@@ -26,13 +24,8 @@ describe("getSurveyFollowUpsPermission", () => {
expect(result).toBe(false); 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); 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); expect(result).toBe(true);
}); });
@@ -4,6 +4,6 @@ import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@/lib/constants";
export const getSurveyFollowUpsPermission = async ( export const getSurveyFollowUpsPermission = async (
billingPlan: Organization["billing"]["plan"] billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => { ): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return billingPlan === PROJECT_FEATURE_KEYS.CUSTOM; if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
return true; return true;
}; };
@@ -32,16 +32,6 @@ vi.mock("@/lib/styling/constants", () => ({
}, },
})); }));
// Mock recall utility
vi.mock("@/lib/utils/recall", () => ({
recallToHeadline: vi.fn((headline) => headline),
}));
// Mock text content extraction
vi.mock("@formbricks/types/surveys/validation", () => ({
getTextContent: vi.fn((text) => text),
}));
describe("Metadata Utils", () => { describe("Metadata Utils", () => {
// Reset all mocks before each test // Reset all mocks before each test
beforeEach(() => { beforeEach(() => {
@@ -183,75 +173,6 @@ describe("Metadata Utils", () => {
WEBAPP_URL: "https://test.formbricks.com", WEBAPP_URL: "https://test.formbricks.com",
})); }));
}); });
test("handles welcome card headline with HTML content", async () => {
const { getTextContent } = await import("@formbricks/types/surveys/validation");
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
metadata: {},
languages: [],
welcomeCard: {
enabled: true,
timeToFinish: false,
showResponseCount: false,
headline: {
default: "<p>Welcome <strong>Headline</strong></p>",
},
html: {
default: "Welcome Description",
},
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getTextContent).mockReturnValue("Welcome Headline");
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getTextContent).toHaveBeenCalled();
expect(result.title).toBe("Welcome Headline");
});
test("handles welcome card headline with recall variables", async () => {
const { recallToHeadline } = await import("@/lib/utils/recall");
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
metadata: {},
languages: [],
welcomeCard: {
enabled: true,
timeToFinish: false,
showResponseCount: false,
headline: {
default: "Welcome #recall:name/fallback:User#",
},
html: {
default: "Welcome Description",
},
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(recallToHeadline).mockReturnValue({
default: "Welcome @User",
});
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(recallToHeadline).toHaveBeenCalledWith(
mockSurvey.welcomeCard.headline,
mockSurvey,
false,
"default"
);
expect(result.title).toBe("Welcome @User");
});
}); });
describe("getSurveyOpenGraphMetadata", () => { describe("getSurveyOpenGraphMetadata", () => {
@@ -1,10 +1,8 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { COLOR_DEFAULTS } from "@/lib/styling/constants"; import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { recallToHeadline } from "@/lib/utils/recall";
import { getSurvey } from "@/modules/survey/lib/survey"; import { getSurvey } from "@/modules/survey/lib/survey";
type TBasicSurveyMetadata = { type TBasicSurveyMetadata = {
@@ -50,9 +48,7 @@ export const getBasicSurveyMetadata = async (
const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined; const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
const titleFromWelcome = const titleFromWelcome =
welcomeCard?.enabled && welcomeCard.headline welcomeCard?.enabled && welcomeCard.headline
? getTextContent( ? getLocalizedValue(welcomeCard.headline, langCode) || ""
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
) || ""
: undefined; : undefined;
let title = titleFromMetadata || titleFromWelcome || survey.name; let title = titleFromMetadata || titleFromWelcome || survey.name;
+11
View 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 <li
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1", "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",
!isHighlighted && "hover:bg-white hover:outline hover:outline-slate-300",
isActive && "bg-slate-100 outline 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 className
)} )}
{...props} {...props}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Command as CommandPrimitive } from "cmdk"; import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { import {
Dialog, Dialog,
@@ -59,14 +60,17 @@ function CommandInput({
...props ...props
}: React.ComponentProps<typeof CommandPrimitive.Input> & { hidden?: boolean }) { }: React.ComponentProps<typeof CommandPrimitive.Input> & { hidden?: boolean }) {
return ( return (
<CommandPrimitive.Input <div data-slot="command-input-wrapper" className={cn("flex items-center")}>
data-slot="command-input" <SearchIcon className="h-4 w-4 shrink-0 text-slate-500" />
className={cn( <CommandPrimitive.Input
"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", data-slot="command-input"
className 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",
{...props} className
/> )}
{...props}
/>
</div>
); );
} }
@@ -74,10 +78,7 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
return ( return (
<CommandPrimitive.List <CommandPrimitive.List
data-slot="command-list" data-slot="command-list"
className={cn( className={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
"max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden rounded-md border border-slate-300 bg-white",
className
)}
{...props} {...props}
/> />
); );
@@ -115,7 +116,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
<CommandPrimitive.Item <CommandPrimitive.Item
data-slot="command-item" data-slot="command-item"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -136,11 +137,11 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
export { export {
Command, Command,
CommandDialog, CommandDialog,
CommandInput,
CommandList,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput,
CommandItem, CommandItem,
CommandList,
CommandSeparator,
CommandShortcut, CommandShortcut,
CommandSeparator,
}; };
@@ -36,7 +36,7 @@ export const DataTableToolbar = <T,>({
const router = useRouter(); const router = useRouter();
return ( 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 ? ( {table.getFilteredSelectedRowModel().rows.length > 0 ? (
<SelectedRowSettings <SelectedRowSettings
table={table} table={table}
@@ -265,7 +265,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
<button autoFocus className="sr-only" aria-hidden type="button" /> <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"> <CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")} {emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
</CommandEmpty> </CommandEmpty>
@@ -128,8 +128,8 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
</div> </div>
{open && selectableOptions.length > 0 && !disabled && ( {open && selectableOptions.length > 0 && !disabled && (
<div className="relative mt-2"> <div className="relative mt-2">
<CommandList className="border-0"> <CommandList>
<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"> <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"> <CommandGroup className="h-full overflow-auto">
{selectableOptions.map((option) => ( {selectableOptions.map((option) => (
<CommandItem <CommandItem
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props}> {...props}>
@@ -104,7 +104,7 @@ export const TagsCombobox = ({
}} }}
/> />
</div> </div>
<CommandList className="border-0"> <CommandList>
<CommandGroup> <CommandGroup>
{tagsToSearch?.map((tag) => { {tagsToSearch?.map((tag) => {
return ( return (
+2 -2
View File
@@ -5006,7 +5006,7 @@
"tags": ["Management API - Response"] "tags": ["Management API - Response"]
}, },
"post": { "post": {
"description": "Create a user response using the management API. This will trigger the response pipeline, including webhooks, integrations, follow-up emails, and other configured actions.", "description": "Create a user response using the management API",
"parameters": [ "parameters": [
{ {
"example": "{{apiKey}}", "example": "{{apiKey}}",
@@ -5543,7 +5543,7 @@
"tags": ["Management API - Response"] "tags": ["Management API - Response"]
}, },
"put": { "put": {
"description": "Update an existing user response with new data. This will trigger the response pipeline, including webhooks, integrations, follow-up emails (if the response is marked as finished), and other configured actions.", "description": "Update an existing user response with new data",
"parameters": [ "parameters": [
{ {
"example": "{{apiKey}}", "example": "{{apiKey}}",
@@ -692,7 +692,6 @@ export function Survey({
isCurrent={offset === 0} isCurrent={offset === 0}
responseData={responseData} responseData={responseData}
variablesData={currentVariables} variablesData={currentVariables}
isPreviewMode={isPreviewMode}
fullSizeCards={fullSizeCards} fullSizeCards={fullSizeCards}
/> />
); );
@@ -25,7 +25,6 @@ interface WelcomeCardProps {
responseData: TResponseData; responseData: TResponseData;
variablesData: TResponseVariables; variablesData: TResponseVariables;
fullSizeCards: boolean; fullSizeCards: boolean;
isPreviewMode?: boolean;
} }
function TimerIcon() { function TimerIcon() {
@@ -79,7 +78,6 @@ export function WelcomeCard({
responseData, responseData,
variablesData, variablesData,
fullSizeCards, fullSizeCards,
isPreviewMode = false,
}: WelcomeCardProps) { }: WelcomeCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -126,8 +124,7 @@ export function WelcomeCard({
} }
}; };
// Only attach listener when current, link type, and NOT in preview mode if (isCurrent && survey.type === "link") {
if (isCurrent && survey.type === "link" && !isPreviewMode) {
document.addEventListener("keydown", handleEnter); document.addEventListener("keydown", handleEnter);
} else { } else {
document.removeEventListener("keydown", handleEnter); document.removeEventListener("keydown", handleEnter);
@@ -137,8 +134,8 @@ export function WelcomeCard({
document.removeEventListener("keydown", handleEnter); document.removeEventListener("keydown", handleEnter);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- only want to run this effect when isCurrent or isPreviewMode changes // eslint-disable-next-line react-hooks/exhaustive-deps -- only want to run this effect when isCurrent changes
}, [isCurrent, isPreviewMode]); }, [isCurrent]);
return ( return (
<ScrollableContainer fullSizeCards={fullSizeCards}> <ScrollableContainer fullSizeCards={fullSizeCards}>
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; 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 type TOrganizationBillingPlan = z.infer<typeof ZOrganizationBillingPlan>;
export const ZOrganizationBillingPeriod = z.enum(["monthly", "yearly"]); export const ZOrganizationBillingPeriod = z.enum(["monthly", "yearly"]);
-1
View File
@@ -409,7 +409,6 @@ export type TResponseUpdate = z.infer<typeof ZResponseUpdate>;
export const ZResponseTableData = z.object({ export const ZResponseTableData = z.object({
responseId: z.string(), responseId: z.string(),
singleUseId: z.string().nullable(),
createdAt: z.date(), createdAt: z.date(),
status: z.string(), status: z.string(),
verifiedEmail: z.string(), verifiedEmail: z.string(),
+1
View File
@@ -169,6 +169,7 @@
"OIDC_CLIENT_SECRET", "OIDC_CLIENT_SECRET",
"OIDC_DISPLAY_NAME", "OIDC_DISPLAY_NAME",
"OIDC_ISSUER", "OIDC_ISSUER",
"OIDC_ISSUER_INTERNAL",
"OIDC_SIGNING_ALGORITHM", "OIDC_SIGNING_ALGORITHM",
"PASSWORD_RESET_DISABLED", "PASSWORD_RESET_DISABLED",
"PLAYWRIGHT_CI", "PLAYWRIGHT_CI",