mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-03 03:10:00 -06:00
Compare commits
2 Commits
copilot/su
...
fix/oidc-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e5246cc5 | ||
|
|
72767e9336 |
@@ -4,6 +4,7 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -23,6 +24,8 @@ const Page = async (props) => {
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) return notFound();
|
||||
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
@@ -34,10 +37,11 @@ const Page = async (props) => {
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||
<ProjectAndOrgSwitch
|
||||
currentOrganizationId={organization.id}
|
||||
currentOrganizationName={organization.name}
|
||||
organizations={organizations}
|
||||
projects={[]}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={0}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getProjectsByUserId } from "./lib/project";
|
||||
|
||||
const ZCreateProjectAction = z.object({
|
||||
organizationId: ZId,
|
||||
@@ -87,59 +84,3 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches organizations list for switcher dropdown.
|
||||
* Called on-demand when user opens the organization switcher.
|
||||
*/
|
||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetOrganizationsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getOrganizationsByUserId(ctx.user.id);
|
||||
});
|
||||
|
||||
const ZGetProjectsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches projects list for switcher dropdown.
|
||||
* Called on-demand when user opens the project switcher.
|
||||
*/
|
||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetProjectsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Need membership for getProjectsByUserId (1 DB query)
|
||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||
if (!membership) {
|
||||
throw new Error("Membership not found");
|
||||
}
|
||||
|
||||
return await getProjectsByUserId(ctx.user.id, membership);
|
||||
});
|
||||
|
||||
@@ -1,49 +1,104 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
|
||||
interface EnvironmentLayoutProps {
|
||||
layoutData: TEnvironmentLayoutData;
|
||||
environmentId: string;
|
||||
session: Session;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
|
||||
const t = await getTranslate();
|
||||
const [user, environment, organizations, organization] = await Promise.all([
|
||||
getUser(session.user.id),
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationsByUserId(session.user.id),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
// Destructure all data from props (NO database queries)
|
||||
const {
|
||||
user,
|
||||
environment,
|
||||
organization,
|
||||
membership,
|
||||
project, // Current project details
|
||||
environments, // All project environments (for environment switcher)
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
} = layoutData;
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
// Calculate derived values (no queries)
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
if (!currentUserMembership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
|
||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
||||
getProjectsByUserId(user.id, currentUserMembership),
|
||||
getEnvironments(environment.projectId),
|
||||
getAccessControlPermission(organization.billing.plan),
|
||||
]);
|
||||
|
||||
if (!projects || !environments || !organizations) {
|
||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||
}
|
||||
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.project_permission_not_found"));
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
|
||||
// Find the current project from the projects array
|
||||
const project = projects.find((p) => p.id === environment.projectId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
@@ -67,24 +122,26 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
<MainNavigation
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
projects={projects}
|
||||
user={user}
|
||||
project={{ id: project.id, name: project.name }}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
currentProjectId={project.id}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={active}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
membershipRole={membership.role}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
project: { id: string; name: string };
|
||||
projects: { id: string; name: string }[];
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
@@ -52,7 +52,7 @@ export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
user,
|
||||
project,
|
||||
projects,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
@@ -65,6 +65,7 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -9,7 +9,9 @@ import { getAccessFlags } from "@/lib/membership/utils";
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -22,7 +24,9 @@ interface TopControlBarProps {
|
||||
export const TopControlBar = ({
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -42,7 +46,9 @@ export const TopControlBar = ({
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
@@ -25,11 +23,10 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useOrganization } from "../context/environment-context";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||
organizations: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
currentEnvironmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -50,7 +47,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
|
||||
|
||||
export const OrganizationBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
currentOrganizationName,
|
||||
organizations,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
isFormbricksCloud,
|
||||
@@ -63,45 +60,7 @@ export const OrganizationBreadcrumb = ({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Get current organization name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { organization: currentOrganization } = useOrganization();
|
||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||
|
||||
// Lazy-load organizations when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
||||
setIsLoadingOrganizations(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort organizations by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load organizations");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
|
||||
}
|
||||
setIsLoadingOrganizations(false);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isOrganizationDropdownOpen,
|
||||
currentOrganizationId,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
loadError,
|
||||
t,
|
||||
]);
|
||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||
|
||||
if (!currentOrganization) {
|
||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||
@@ -167,7 +126,7 @@ export const OrganizationBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{organizationName}</span>
|
||||
<span>{currentOrganization.name}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -183,52 +142,30 @@ export const OrganizationBreadcrumb = ({
|
||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganizationId}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<div>
|
||||
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.organization_settings")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||
@@ -7,9 +8,9 @@ import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
currentOrganizationName?: string; // Optional: for pages without context
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId?: string;
|
||||
currentProjectName?: string; // Optional: for pages without context
|
||||
projects: { id: string; name: string }[];
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
@@ -17,15 +18,15 @@ interface ProjectAndOrgSwitchProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMember: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
currentOrganizationName,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
currentProjectName,
|
||||
projects,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
@@ -36,6 +37,11 @@ export const ProjectAndOrgSwitch = ({
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
||||
const sortedOrganizations = useMemo(
|
||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations]
|
||||
);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
@@ -44,9 +50,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentOrganizationName={currentOrganizationName}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
organizations={sortedOrganizations}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
@@ -54,9 +60,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentProjectName={currentProjectName}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
projects={sortedProjects}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
@@ -20,11 +18,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useProject } from "../context/environment-context";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
currentProjectName?: string; // Optional: pass directly if context not available
|
||||
projects: { id: string; name: string }[];
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -47,7 +44,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
currentProjectName,
|
||||
projects,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -62,41 +59,9 @@ export const ProjectBreadcrumb = ({
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get current project name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { project: currentProject } = useProject();
|
||||
const projectName = currentProject?.name || currentProjectName || "";
|
||||
|
||||
// Lazy-load projects when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||
setIsLoadingProjects(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load projects");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
}
|
||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
@@ -135,6 +100,8 @@ export const ProjectBreadcrumb = ({
|
||||
},
|
||||
];
|
||||
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||
logger.error(errorMessage);
|
||||
@@ -199,7 +166,7 @@ export const ProjectBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{projectName}</span>
|
||||
<span>{currentProject.name}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -214,48 +181,26 @@ export const ProjectBreadcrumb = ({
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setProjects([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProjectId}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProject.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
@@ -22,44 +20,25 @@ export const useEnvironment = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useProject = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { project: null };
|
||||
}
|
||||
return { project: context.project };
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { organization: null };
|
||||
}
|
||||
return { organization: context.organization };
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project, organization]
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
|
||||
@@ -14,27 +15,46 @@ const EnvLayout = async (props: {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
|
||||
// Check session first (required for userId)
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const [project, environment] = await Promise.all([
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
|
||||
if (!membership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={layoutData.session}
|
||||
user={layoutData.user}
|
||||
organization={layoutData.organization}>
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</EnvironmentIdBaseLayout>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -8,14 +8,7 @@ import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ResponseCardModalProps {
|
||||
responses: TResponse[];
|
||||
@@ -49,37 +42,25 @@ export const ResponseCardModal = ({
|
||||
locale,
|
||||
}: ResponseCardModalProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const idToIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < responses.length; i++) {
|
||||
map.set(responses[i].id, i);
|
||||
}
|
||||
return map;
|
||||
}, [responses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedResponseId) {
|
||||
setOpen(true);
|
||||
const index = idToIndexMap.get(selectedResponseId) ?? -1;
|
||||
const index = responses.findIndex((response) => response.id === selectedResponseId);
|
||||
setCurrentIndex(index);
|
||||
setIsNavigating(false);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [selectedResponseId, idToIndexMap, setOpen]);
|
||||
}, [selectedResponseId, responses, setOpen]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
||||
setIsNavigating(true);
|
||||
setSelectedResponseId(responses[currentIndex + 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIndex !== null && currentIndex > 0) {
|
||||
setIsNavigating(true);
|
||||
setSelectedResponseId(responses[currentIndex - 1].id);
|
||||
}
|
||||
};
|
||||
@@ -91,8 +72,8 @@ export const ResponseCardModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
// If no response is selected or currentIndex is null or invalid, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null;
|
||||
// If no response is selected or currentIndex is null, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
@@ -100,11 +81,6 @@ export const ResponseCardModal = ({
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>Survey Response Details</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogDescription>
|
||||
Response {currentIndex + 1} of {responses.length}
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
@@ -120,16 +96,12 @@ export const ResponseCardModal = ({
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={currentIndex === 0 || isNavigating}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === responses.length - 1 || isNavigating}
|
||||
disabled={currentIndex === responses.length - 1}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<ChevronRight />
|
||||
|
||||
@@ -28,63 +28,60 @@ interface ResponseDataViewProps {
|
||||
quotas: TSurveyQuota[];
|
||||
}
|
||||
|
||||
// Helper function to format array values to record with specified keys
|
||||
const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]): Record<string, string> => {
|
||||
if (!Array.isArray(responseValue)) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (let index = 0; index < responseValue.length; index++) {
|
||||
const curr = responseValue[index];
|
||||
result[keys[index]] = curr || "";
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
return formatArrayToRecord(responseValue, addressKeys);
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return formatArrayToRecord(responseValue, contactInfoKeys);
|
||||
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
const responseData: Record<string, any> = {};
|
||||
let responseData: Record<string, any> = {};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
survey.questions.forEach((question) => {
|
||||
const responseValue = response.data[question.id];
|
||||
switch (question.type) {
|
||||
case "matrix":
|
||||
if (typeof responseValue === "object") {
|
||||
Object.assign(responseData, responseValue);
|
||||
responseData = { ...responseData, ...responseValue };
|
||||
}
|
||||
break;
|
||||
case "address":
|
||||
Object.assign(responseData, formatAddressData(responseValue));
|
||||
responseData = { ...responseData, ...formatAddressData(responseValue) };
|
||||
break;
|
||||
case "contactInfo":
|
||||
Object.assign(responseData, formatContactInfoData(responseValue));
|
||||
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
|
||||
break;
|
||||
default:
|
||||
responseData[question.id] = responseValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (survey.hiddenFields.fieldIds) {
|
||||
for (const fieldId of survey.hiddenFields.fieldIds) {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
}
|
||||
}
|
||||
survey.hiddenFields.fieldIds?.forEach((fieldId) => {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
});
|
||||
|
||||
return responseData;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
const mapResponsesToTableData = (
|
||||
export const mapResponsesToTableData = (
|
||||
responses: TResponseWithQuotas[],
|
||||
survey: TSurvey,
|
||||
t: TFunction
|
||||
@@ -96,7 +93,6 @@ const mapResponsesToTableData = (
|
||||
? t("environments.surveys.responses.completed")
|
||||
: t("environments.surveys.responses.not_completed"),
|
||||
responseId: response.id,
|
||||
singleUseId: response.singleUseId,
|
||||
tags: response.tags,
|
||||
variables: survey.variables.reduce(
|
||||
(acc, curr) => {
|
||||
@@ -130,10 +126,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
quotas,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedResponseId, setSelectedResponseId] = React.useState<string | null>(null);
|
||||
const setSelectedResponseIdTransition = React.useCallback((id: string | null) => {
|
||||
React.startTransition(() => setSelectedResponseId(id));
|
||||
}, []);
|
||||
const data = mapResponsesToTableData(responses, survey, t);
|
||||
|
||||
return (
|
||||
@@ -154,8 +146,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
locale={locale}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
selectedResponseId={selectedResponseId}
|
||||
setSelectedResponseId={setSelectedResponseIdTransition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -122,11 +122,12 @@ export const ResponsePage = ({
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setResponses([]);
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-9 gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
</div>
|
||||
<ResponseDataView
|
||||
|
||||
@@ -39,12 +39,6 @@ import {
|
||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
const SkeletonCell = () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
);
|
||||
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
survey: TSurvey;
|
||||
@@ -61,8 +55,6 @@ interface ResponseTableProps {
|
||||
locale: TUserLocale;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
selectedResponseId: string | null;
|
||||
setSelectedResponseId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ResponseTable = ({
|
||||
@@ -81,13 +73,12 @@ export const ResponseTable = ({
|
||||
locale,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
selectedResponseId,
|
||||
setSelectedResponseId,
|
||||
}: ResponseTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
@@ -95,10 +86,7 @@ export const ResponseTable = ({
|
||||
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
);
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
@@ -122,13 +110,7 @@ export const ResponseTable = ({
|
||||
|
||||
// Memoize table data and columns
|
||||
const tableData: TResponseTableData[] = useMemo(
|
||||
() =>
|
||||
isFetchingFirstPage
|
||||
? Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
|
||||
)
|
||||
: data,
|
||||
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
|
||||
[data, isFetchingFirstPage]
|
||||
);
|
||||
|
||||
@@ -137,7 +119,11 @@ export const ResponseTable = ({
|
||||
isFetchingFirstPage
|
||||
? columns.map((column) => ({
|
||||
...column,
|
||||
cell: SkeletonCell,
|
||||
cell: () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
),
|
||||
}))
|
||||
: columns,
|
||||
[columns, isFetchingFirstPage]
|
||||
@@ -261,8 +247,8 @@ export const ResponseTable = ({
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
{/* disable auto animation if there are more than 200 responses for performance optimizations */}
|
||||
<TableBody ref={responses && responses.length > 200 ? undefined : parent}>
|
||||
|
||||
<TableBody ref={parent}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@@ -275,6 +261,7 @@ export const ResponseTable = ({
|
||||
row={row}
|
||||
isExpanded={isExpanded ?? false}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
|
||||
import { TableCell } from "@/modules/ui/components/table";
|
||||
@@ -11,18 +10,21 @@ interface ResponseTableCellProps {
|
||||
row: Row<TResponseTableData>;
|
||||
isExpanded: boolean;
|
||||
setSelectedResponseId: (responseId: string | null) => void;
|
||||
responses: TResponse[] | null;
|
||||
}
|
||||
|
||||
const ResponseTableCellComponent = ({
|
||||
export const ResponseTableCell = ({
|
||||
cell,
|
||||
row,
|
||||
isExpanded,
|
||||
setSelectedResponseId,
|
||||
responses,
|
||||
}: ResponseTableCellProps) => {
|
||||
// Function to handle cell click
|
||||
const handleCellClick = () => {
|
||||
if (cell.column.id !== "select") {
|
||||
setSelectedResponseId(row.id);
|
||||
const response = responses?.find((response) => response.id === row.id);
|
||||
if (response) setSelectedResponseId(response.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,5 +66,3 @@ const ResponseTableCellComponent = ({
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResponseTableCell = React.memo(ResponseTableCellComponent);
|
||||
|
||||
@@ -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> = {
|
||||
accessorKey: "quota",
|
||||
header: t("common.quota"),
|
||||
@@ -417,7 +409,6 @@ export const generateResponseTableColumns = (
|
||||
// Combine the selection column with the dynamic question columns
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
singleUseIdColumn,
|
||||
dateColumn,
|
||||
...(showQuotasColumn ? [quotasColumn] : []),
|
||||
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">
|
||||
{results.map((result) => {
|
||||
{results.map((result, resultsIdx) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||
return (
|
||||
<Fragment key={result.value}>
|
||||
@@ -107,7 +107,7 @@ export const MultipleChoiceSummary = ({
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{result.value}
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { ResponseFilter } from "./ResponseFilter";
|
||||
|
||||
enum DateSelected {
|
||||
FROM = "common.from",
|
||||
@@ -136,7 +137,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
|
||||
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
|
||||
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
|
||||
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
|
||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||
|
||||
@@ -270,179 +270,201 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
|
||||
useClickOutside(datePickerRef, () => handleDatePickerClose());
|
||||
return (
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsDownloadDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("common.download")}
|
||||
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
</span>
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
<>
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="flex min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span className="text-sm text-slate-700">
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
"focus:bg-muted cursor-pointer outline-none",
|
||||
isDownloading && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isDownloading}
|
||||
data-testid="fb__custom-filter-download-responses-button">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
{isDownloading ? (
|
||||
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange || dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange ? hoveredRange : dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/modules/ui/components/command";
|
||||
@@ -50,160 +48,117 @@ export const QuestionFilterComboBox = ({
|
||||
disabled = false,
|
||||
fieldId,
|
||||
}: QuestionFilterComboBoxProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const commandRef = useRef(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { t } = useTranslation();
|
||||
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||
const commandRef = React.useRef(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
const defaultLanguageCode = "default";
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
const { t } = useTranslation();
|
||||
// multiple when question type is multi selection
|
||||
const isMultiple =
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
|
||||
|
||||
// Check if multiple selection is allowed
|
||||
const isMultiple = useMemo(
|
||||
() =>
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either"),
|
||||
[type, filterValue]
|
||||
);
|
||||
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||
const options = isMultiple
|
||||
? filterComboBoxOptions?.filter(
|
||||
(o) =>
|
||||
!filterComboBoxValue?.includes(
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||
)
|
||||
)
|
||||
: filterComboBoxOptions;
|
||||
|
||||
// Filter out already selected options for multi-select
|
||||
const options = useMemo(() => {
|
||||
if (!isMultiple) return filterComboBoxOptions;
|
||||
|
||||
return filterComboBoxOptions?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
|
||||
// Disable combo box for NPS/Rating when Submitted/Skipped
|
||||
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
|
||||
const isDisabledComboBox =
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
// Check if this is a text input field (URL meta field)
|
||||
// Check if this is a URL field with string comparison operations that require text input
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
|
||||
// Filter options based on search query
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
const filteredOptions = options?.filter((o) =>
|
||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCommandItemSelect = (o: string) => {
|
||||
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
|
||||
if (isMultiple) {
|
||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||
onChangeFilterComboBoxValue(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeFilterComboBoxValue(value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
|
||||
|
||||
const handleOpenDropdown = () => {
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(true);
|
||||
};
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
// Helper to filter out a specific value from the array
|
||||
const getFilteredValues = (valueToRemove: string): string[] => {
|
||||
if (!Array.isArray(filterComboBoxValue)) return [];
|
||||
return filterComboBoxValue.filter((i) => i !== valueToRemove);
|
||||
};
|
||||
|
||||
// Handle removal of a multi-select tag
|
||||
const handleRemoveTag = (e: React.MouseEvent, valueToRemove: string) => {
|
||||
e.stopPropagation();
|
||||
const filteredValues = getFilteredValues(valueToRemove);
|
||||
handleRemoveMultiSelect(filteredValues);
|
||||
};
|
||||
|
||||
// Render a single multi-select tag
|
||||
const renderTag = (value: string, index: number) => (
|
||||
<button
|
||||
key={`${value}-${index}`}
|
||||
type="button"
|
||||
onClick={(e) => handleRemoveTag(e, value)}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
|
||||
{value}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render multi-select tags
|
||||
const renderMultiSelectTags = () => {
|
||||
if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex grow gap-2 overflow-auto">
|
||||
{filterComboBoxValue.map((value, index) => renderTag(value, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate content based on filterComboBoxValue state
|
||||
const renderComboBoxContent = () => {
|
||||
if (!filterComboBoxValue || filterComboBoxValue.length === 0) {
|
||||
return (
|
||||
<p className={clsx("text-sm", isComboBoxDisabled ? "text-slate-300" : "text-slate-400")}>
|
||||
{t("common.select")}...
|
||||
</p>
|
||||
const commandItemOnSelect = (o: string) => {
|
||||
if (!isMultiple) {
|
||||
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
|
||||
} else {
|
||||
onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(filterComboBoxValue)) {
|
||||
return renderMultiSelectTags();
|
||||
if (!isMultiple) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
||||
{filterOptions && filterOptions.length <= 1 ? (
|
||||
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
if (value) setOpen(false);
|
||||
value && setOpen(false);
|
||||
setOpenFilterValue(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
)}>
|
||||
{filterValue ? (
|
||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
{!filterValue ? (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
) : (
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<>
|
||||
{openFilterValue ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
<DropdownMenuContent className="bg-white p-2">
|
||||
{filterOptions?.map((o, index) => (
|
||||
<DropdownMenuItem
|
||||
key={`${o}-${index}`}
|
||||
className="cursor-pointer"
|
||||
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
|
||||
onClick={() => onChangeFilterValue(o)}>
|
||||
{o}
|
||||
</DropdownMenuItem>
|
||||
@@ -211,78 +166,78 @@ export const QuestionFilterComboBox = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isTextInputField ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
||||
disabled={isComboBoxDisabled}
|
||||
placeholder={t("common.enter_url")}
|
||||
disabled={disabled || !filterValue}
|
||||
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
||||
/>
|
||||
) : (
|
||||
<Command ref={commandRef} className="relative h-fit w-full min-w-0 overflow-visible bg-transparent">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isComboBoxDisabled ? -1 : 0}
|
||||
className={clsx(
|
||||
"flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2",
|
||||
isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
)}
|
||||
onClick={handleOpenDropdown}
|
||||
onKeyDown={(e) => {
|
||||
const isActivationKey = e.key === "Enter" || e.key === " ";
|
||||
if (isActivationKey && !isComboBoxDisabled) {
|
||||
e.preventDefault();
|
||||
handleOpenDropdown();
|
||||
}
|
||||
}}>
|
||||
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(!open);
|
||||
}}
|
||||
disabled={isComboBoxDisabled}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandList className="max-h-52">
|
||||
<CommandInput
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={`${t("common.search")}...`}
|
||||
className="border-none"
|
||||
/>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={optionValue}
|
||||
onSelect={() => handleCommandItemSelect(o)}
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{optionValue}
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -112,46 +111,51 @@ const questionIcons = {
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
const getIconBackground = (type: OptionsType | string): string => {
|
||||
const backgroundMap: Record<string, string> = {
|
||||
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
|
||||
[OptionsType.QUESTIONS]: "bg-brand-dark",
|
||||
[OptionsType.TAGS]: "bg-indigo-500",
|
||||
[OptionsType.QUOTAS]: "bg-slate-500",
|
||||
};
|
||||
return backgroundMap[type] ?? "bg-amber-500";
|
||||
};
|
||||
|
||||
const getLabelClassName = (type: OptionsType | string, label?: string): string => {
|
||||
if (type !== OptionsType.META) return "";
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getDisplayIcon = () => {
|
||||
if (!type) return null;
|
||||
if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType);
|
||||
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
|
||||
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
|
||||
if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS);
|
||||
if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS);
|
||||
return null;
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return getIcon(OptionsType.QUOTAS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = () => {
|
||||
if (type === OptionsType.ATTRIBUTES) {
|
||||
return "bg-indigo-500";
|
||||
} else if (type === OptionsType.QUESTIONS) {
|
||||
return "bg-brand-dark";
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return "bg-indigo-500";
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return "bg-slate-500";
|
||||
} else {
|
||||
return "bg-amber-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-white",
|
||||
getIconBackground(type ?? "")
|
||||
)}>
|
||||
{getDisplayIcon()}
|
||||
</span>
|
||||
<p className={clsx("truncate text-sm text-slate-600", getLabelClassName(type ?? "", label))}>
|
||||
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -165,74 +169,64 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
const hasSelection = selected.hasOwnProperty("label");
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<Command
|
||||
ref={commandRef}
|
||||
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center justify-between"
|
||||
onClick={() => !open && setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
!open && setOpen(true);
|
||||
}
|
||||
}}>
|
||||
{!open && hasSelection && <SelectedCommandItem {...selected} />}
|
||||
{(open || !hasSelection) && (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||
{!open && selected.hasOwnProperty("label") && (
|
||||
<SelectedCommandItem
|
||||
label={selected?.label}
|
||||
type={selected?.type}
|
||||
questionType={selected?.questionType}
|
||||
/>
|
||||
)}
|
||||
{(open || !selected.hasOwnProperty("label")) && (
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}>
|
||||
<SelectedCommandItem {...o} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
<div>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o, i) => (
|
||||
<CommandItem
|
||||
key={`${o.label}-${i}`}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,32 +31,6 @@ export type QuestionFilterOptions = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverTriggerButtonProps>(
|
||||
({ isOpen, children, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...props}
|
||||
className="flex min-w-[8rem] cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white p-2 hover:border-slate-400">
|
||||
<span className="text-sm text-slate-700">{children}</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
PopoverTriggerButton.displayName = "PopoverTriggerButton";
|
||||
|
||||
interface ResponseFilterProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
@@ -134,6 +108,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
clearItem();
|
||||
handleApplyFilters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
@@ -152,9 +127,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
const clearedFilters = { filter: [], responseStatus: "all" as const };
|
||||
setFilterValue(clearedFilters);
|
||||
setSelectedFilter(clearedFilters);
|
||||
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
|
||||
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -210,6 +184,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
handleApplyFilters();
|
||||
}
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
@@ -219,26 +196,36 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[300px] rounded-lg border-slate-200 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="font-semibold text-slate-800">
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-slate800 hidden text-lg font-semibold sm:block">
|
||||
{t("environments.surveys.summary.show_all_responses_that_match")}
|
||||
</p>
|
||||
<p className="block text-base text-slate-500 sm:hidden">
|
||||
{t("environments.surveys.summary.show_all_responses_where")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={filterValue.responseStatus ?? "all"}
|
||||
onValueChange={(val) => {
|
||||
handleResponseStatusChange(val as TResponseStatus);
|
||||
}}>
|
||||
<SelectTrigger className="w-full bg-white text-slate-700">
|
||||
}}
|
||||
defaultValue={filterValue.responseStatus}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -298,38 +285,35 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
<p className="block font-light text-slate-500 md:hidden">Delete</p>
|
||||
<TrashIcon
|
||||
className="w-4 cursor-pointer text-slate-500 md:text-black"
|
||||
onClick={() => handleDeleteFilter(i)}
|
||||
aria-label={t("common.delete")}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<div className="my-6 flex items-center">
|
||||
<p className="mr-6 text-base text-slate-600">And</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus width={18} height={18} className="ml-2" />
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyFilters}>
|
||||
{t("common.apply_filters")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
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 { getSurvey } from "@/lib/survey/service";
|
||||
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);
|
||||
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 {
|
||||
response: responses.successResponse(updated),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/res
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
@@ -157,23 +156,6 @@ export const POST = withV1ApiWrapper({
|
||||
const response = await createResponseWithQuotaEvaluation(responseInput);
|
||||
auditLog.targetId = response.id;
|
||||
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 {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
|
||||
3
apps/web/app/s/[surveyId]/loading.tsx
Normal file
3
apps/web/app/s/[surveyId]/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
|
||||
|
||||
export default LinkSurveyLoading;
|
||||
@@ -173,7 +173,6 @@ checksums:
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
|
||||
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
|
||||
@@ -183,8 +182,6 @@ checksums:
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
@@ -331,7 +328,6 @@ checksums:
|
||||
common/segments: 271db72d5b973fbc5fadab216177eaae
|
||||
common/select: 5ac04c47a98deb85906bc02e0de91ab0
|
||||
common/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
common/select_filter: c50082c3981f1161022f9787a19aed71
|
||||
common/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
||||
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
||||
@@ -825,6 +821,7 @@ checksums:
|
||||
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
|
||||
environments/projects_environments_organizations_not_found: 9d450087c4035083f93bda9aa1889c43
|
||||
environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905
|
||||
environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a
|
||||
environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093
|
||||
@@ -1576,8 +1573,6 @@ checksums:
|
||||
environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127
|
||||
environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5
|
||||
environments/surveys/responses/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
|
||||
environments/surveys/responses/an_error_occurred_adding_the_tag: f211ea1ceb8a93b415d88a8deed874ef
|
||||
environments/surveys/responses/an_error_occurred_creating_the_tag: 89689815f8aff6ff3ba821ab599c540c
|
||||
environments/surveys/responses/an_error_occurred_deleting_the_tag: c63f28ac2a4cda558423ea7f975d5b8b
|
||||
environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f
|
||||
environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70
|
||||
@@ -1774,6 +1769,7 @@ checksums:
|
||||
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||
environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515
|
||||
environments/surveys/summary/show_all_responses_that_match: c199f03983d7fcdd5972cc2759558c68
|
||||
environments/surveys/summary/show_all_responses_where: 370a56de4692a588f7ebdbf7f1e28f6f
|
||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd
|
||||
|
||||
@@ -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_SECRET = env.OIDC_CLIENT_SECRET;
|
||||
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_SIGNING_ALGORITHM = env.OIDC_SIGNING_ALGORITHM;
|
||||
|
||||
@@ -182,17 +183,21 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
export enum PROJECT_FEATURE_KEYS {
|
||||
FREE = "free",
|
||||
STARTUP = "startup",
|
||||
CUSTOM = "custom",
|
||||
SCALE = "scale",
|
||||
ENTERPRISE = "enterprise",
|
||||
}
|
||||
|
||||
export enum STRIPE_PROJECT_NAMES {
|
||||
STARTUP = "Formbricks Startup",
|
||||
CUSTOM = "Formbricks Custom",
|
||||
SCALE = "Formbricks Scale",
|
||||
ENTERPRISE = "Formbricks Enterprise",
|
||||
}
|
||||
|
||||
export enum STRIPE_PRICE_LOOKUP_KEYS {
|
||||
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
|
||||
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
|
||||
SCALE_MONTHLY = "formbricks_scale_monthly",
|
||||
SCALE_YEARLY = "formbricks_scale_yearly",
|
||||
}
|
||||
|
||||
export const BILLING_LIMITS = {
|
||||
@@ -206,10 +211,10 @@ export const BILLING_LIMITS = {
|
||||
RESPONSES: 5000,
|
||||
MIU: 7500,
|
||||
},
|
||||
CUSTOM: {
|
||||
PROJECTS: null,
|
||||
RESPONSES: null,
|
||||
MIU: null,
|
||||
SCALE: {
|
||||
PROJECTS: 5,
|
||||
RESPONSES: 10000,
|
||||
MIU: 30000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export const env = createEnv({
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
OIDC_DISPLAY_NAME: z.string().optional(),
|
||||
OIDC_ISSUER: z.string().optional(),
|
||||
OIDC_ISSUER_INTERNAL: z.string().optional(),
|
||||
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
||||
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
|
||||
REDIS_URL:
|
||||
@@ -182,6 +183,7 @@ export const env = createEnv({
|
||||
OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET,
|
||||
OIDC_DISPLAY_NAME: process.env.OIDC_DISPLAY_NAME,
|
||||
OIDC_ISSUER: process.env.OIDC_ISSUER,
|
||||
OIDC_ISSUER_INTERNAL: process.env.OIDC_ISSUER_INTERNAL,
|
||||
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
|
||||
|
||||
@@ -53,9 +53,9 @@ export const I18nProvider = ({ children, language, defaultLanguage }: I18nProvid
|
||||
initializeI18n();
|
||||
}, [locale, defaultLanguage]);
|
||||
|
||||
// Don't render children until i18n is ready to prevent race conditions
|
||||
// Don't render children until i18n is ready to prevent hydration issues
|
||||
if (!isReady) {
|
||||
return null;
|
||||
return <div style={{ visibility: "hidden" }}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_projects": "Fehler beim Laden der Projekte",
|
||||
"finish": "Fertigstellen",
|
||||
"follow_these": "Folge diesen",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segmente",
|
||||
"select": "Auswählen",
|
||||
"select_all": "Alles auswählen",
|
||||
"select_filter": "Filter auswählen",
|
||||
"select_survey": "Umfrage auswählen",
|
||||
"select_teams": "Teams auswählen",
|
||||
"selected": "Ausgewählt",
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projekte, Umgebungen oder Organisationen nicht gefunden",
|
||||
"segments": {
|
||||
"add_filter_below": "Filter unten hinzufügen",
|
||||
"add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen",
|
||||
@@ -986,12 +983,15 @@
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"monthly_identified_users": "Monatlich identifizierte Nutzer",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
|
||||
"premium_support_with_slas": "Premium-Support mit SLAs",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"startup": "Start-up",
|
||||
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
|
||||
"switch_plan": "Plan wechseln",
|
||||
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
|
||||
"team_access_roles": "Rollen für Teammitglieder",
|
||||
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
|
||||
"unlimited_miu": "Unbegrenzte MIU",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresszeile 1",
|
||||
"address_line_2": "Adresszeile 2",
|
||||
"an_error_occurred_adding_the_tag": "Beim Hinzufügen des Tags ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_creating_the_tag": "Beim Erstellen des Tags ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
|
||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||
"expand_rows": "Expand rows",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_projects": "Failed to load projects",
|
||||
"finish": "Finish",
|
||||
"follow_these": "Follow these",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segments",
|
||||
"select": "Select",
|
||||
"select_all": "Select all",
|
||||
"select_filter": "Select filter",
|
||||
"select_survey": "Select Survey",
|
||||
"select_teams": "Select teams",
|
||||
"selected": "Selected",
|
||||
@@ -890,6 +886,7 @@
|
||||
"team_settings_description": "See which teams can access this project."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projects, environments or organizations not found",
|
||||
"segments": {
|
||||
"add_filter_below": "Add filter below",
|
||||
"add_your_first_filter_to_get_started": "Add your first filter to get started",
|
||||
@@ -986,12 +983,15 @@
|
||||
"manage_subscription": "Manage Subscription",
|
||||
"monthly": "Monthly",
|
||||
"monthly_identified_users": "Monthly Identified Users",
|
||||
"per_month": "per month",
|
||||
"per_year": "per year",
|
||||
"plan_upgraded_successfully": "Plan upgraded successfully",
|
||||
"premium_support_with_slas": "Premium support with SLAs",
|
||||
"remove_branding": "Remove Branding",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Everything in Free with additional features.",
|
||||
"switch_plan": "Switch Plan",
|
||||
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
|
||||
"team_access_roles": "Team Access Roles",
|
||||
"unable_to_upgrade_plan": "Unable to upgrade plan",
|
||||
"unlimited_miu": "Unlimited MIU",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Address Line 1",
|
||||
"address_line_2": "Address Line 2",
|
||||
"an_error_occurred_adding_the_tag": "An error occurred adding the tag",
|
||||
"an_error_occurred_creating_the_tag": "An error occurred creating the tag",
|
||||
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
|
||||
@@ -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": "Show all responses where...",
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"account_settings": "Paramètres du compte",
|
||||
"action": "Action",
|
||||
"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",
|
||||
"activity": "Activité",
|
||||
"add": "Ajouter",
|
||||
@@ -127,7 +127,7 @@
|
||||
"all": "Tout",
|
||||
"all_questions": " toutes les questions",
|
||||
"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",
|
||||
"and": "Et",
|
||||
"and_response_limit_of": "et limite de réponse de",
|
||||
@@ -144,7 +144,7 @@
|
||||
"bottom_left": "En bas à gauche",
|
||||
"bottom_right": "En bas à droite",
|
||||
"cancel": "Annuler",
|
||||
"centered_modal": "Au centre",
|
||||
"centered_modal": "Modal centré",
|
||||
"choices": "Choix",
|
||||
"choose_environment": "Choisir l'environnement",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
@@ -180,7 +180,7 @@
|
||||
"created_at": "Créé le",
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"dark_overlay": "Superposition sombre",
|
||||
"date": "Date",
|
||||
"default": "Par défaut",
|
||||
"delete": "Supprimer",
|
||||
@@ -188,7 +188,7 @@
|
||||
"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.",
|
||||
"disable": "Désactiver",
|
||||
"disallow": "Ne pas autoriser",
|
||||
"disallow": "Ne pas permettre",
|
||||
"discard": "Annuler",
|
||||
"dismissed": "Rejeté",
|
||||
"docs": "Documentation",
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
|
||||
"error_rate_limit_title": "Limite de Taux Dépassée",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_projects": "Échec du chargement des projets",
|
||||
"finish": "Terminer",
|
||||
"follow_these": "Suivez ceci",
|
||||
"formbricks_version": "Version de Formbricks",
|
||||
@@ -243,7 +240,7 @@
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"learn_more": "En savoir plus",
|
||||
"light_overlay": "Claire",
|
||||
"light_overlay": "Superposition légère",
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
"link_survey": "Enquête de lien",
|
||||
@@ -252,7 +249,7 @@
|
||||
"loading": "Chargement",
|
||||
"logo": "Logo",
|
||||
"logout": "Déconnexion",
|
||||
"look_and_feel": "Apparence",
|
||||
"look_and_feel": "Apparence et sensation",
|
||||
"manage": "Gérer",
|
||||
"marketing": "Marketing",
|
||||
"maximum": "Max",
|
||||
@@ -272,7 +269,7 @@
|
||||
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
|
||||
"next": "Suivant",
|
||||
"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_quotas_found": "Aucun quota trouvé",
|
||||
"no_result_found": "Aucun résultat trouvé",
|
||||
@@ -293,7 +290,7 @@
|
||||
"option_ids": "Identifiants des options",
|
||||
"or": "ou",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Identifiant de l'organisation",
|
||||
"organization_id": "ID de l'organisation",
|
||||
"organization_not_found": "Organisation non trouvée",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"organization_teams_not_found": "Équipes d'organisation non trouvées",
|
||||
@@ -339,12 +336,12 @@
|
||||
"remove": "Retirer",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
"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",
|
||||
"reset_to_default": "Réinitialiser par défaut",
|
||||
"response": "Réponse",
|
||||
"responses": "Réponses",
|
||||
"restart": "Recommencer",
|
||||
"restart": "Redémarrer",
|
||||
"role": "Rôle",
|
||||
"role_organization": "Rôle (Organisation)",
|
||||
"saas": "SaaS",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segments",
|
||||
"select": "Sélectionner",
|
||||
"select_all": "Sélectionner tout",
|
||||
"select_filter": "Sélectionner un filtre",
|
||||
"select_survey": "Sélectionner l'enquête",
|
||||
"select_teams": "Sélectionner les équipes",
|
||||
"selected": "Sélectionné",
|
||||
@@ -368,7 +364,7 @@
|
||||
"send_test_email": "Envoyer un e-mail de test",
|
||||
"session_not_found": "Session non trouvée",
|
||||
"settings": "Paramètres",
|
||||
"share_feedback": "Partager des commentaires",
|
||||
"share_feedback": "Partager des retours",
|
||||
"show": "Montrer",
|
||||
"show_response_count": "Afficher le nombre de réponses",
|
||||
"shown": "Montré",
|
||||
@@ -379,7 +375,7 @@
|
||||
"something_went_wrong": "Quelque chose s'est mal passé.",
|
||||
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"sort_by": "Trier par",
|
||||
"start_free_trial": "Essayer gratuitement",
|
||||
"start_free_trial": "Commencer l'essai gratuit",
|
||||
"status": "Statut",
|
||||
"step_by_step_manual": "Manuel étape par étape",
|
||||
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
|
||||
@@ -397,12 +393,12 @@
|
||||
"surveys": "Enquêtes",
|
||||
"switch_to": "Passer à {environment}",
|
||||
"table_items_deleted_successfully": "{type}s supprimés avec succès",
|
||||
"table_settings": "Paramètres du tableau",
|
||||
"tags": "Balises",
|
||||
"table_settings": "Réglages de table",
|
||||
"tags": "Étiquettes",
|
||||
"targeting": "Ciblage",
|
||||
"team": "Équipe",
|
||||
"team_access": "Accès",
|
||||
"team_id": "Identifiant de l'équipe",
|
||||
"team_access": "Accès Équipe",
|
||||
"team_id": "Équipe ID",
|
||||
"team_name": "Nom de l'équipe",
|
||||
"teams": "Contrôle d'accès",
|
||||
"teams_not_found": "Équipes non trouvées",
|
||||
@@ -410,8 +406,8 @@
|
||||
"time": "Temps",
|
||||
"time_to_finish": "Temps de finir",
|
||||
"title": "Titre",
|
||||
"top_left": "En haut à gauche",
|
||||
"top_right": "En haut à droite",
|
||||
"top_left": "Haut Gauche",
|
||||
"top_right": "Haut Droit",
|
||||
"try_again": "Réessayer",
|
||||
"type": "Type",
|
||||
"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_at": "Mis à jour à",
|
||||
"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",
|
||||
"user": "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.",
|
||||
"webhook": "Webhook",
|
||||
"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_survey": "Sondage de site web",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
@@ -446,7 +442,7 @@
|
||||
},
|
||||
"emails": {
|
||||
"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_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.",
|
||||
@@ -521,7 +517,7 @@
|
||||
"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à",
|
||||
"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",
|
||||
"click": "Cliquez",
|
||||
"contains": "Contient",
|
||||
@@ -529,19 +525,19 @@
|
||||
"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.",
|
||||
"does_not_contain": "Ne contient pas",
|
||||
"does_not_exactly_match": "Ne correspond pas exactement à",
|
||||
"eg_clicked_download": "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_install_app": "Exemple : Installer l'application",
|
||||
"does_not_exactly_match": "Ne correspond pas exactement",
|
||||
"eg_clicked_download": "Par exemple, cliqué sur Télécharger",
|
||||
"eg_download_cta_click_on_home": "Par exemple, cliquez sur le CTA de téléchargement sur la page d'accueil",
|
||||
"eg_install_app": "Par exemple, installer l'application",
|
||||
"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_url": "Exemple : https://app.com/dashboard",
|
||||
"exactly_matches": "Correspond exactement à",
|
||||
"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": "par exemple https://app.com/dashboard",
|
||||
"exactly_matches": "Correspondance exacte",
|
||||
"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 ?",
|
||||
"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_text": "Si l'utilisateur clique sur un bouton comportant du texte",
|
||||
"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 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",
|
||||
"inner_text": "Texte interne",
|
||||
"invalid_action_type_code": "Type d'action invalide pour action code",
|
||||
@@ -549,27 +545,27 @@
|
||||
"invalid_css_selector": "Sélecteur CSS invalide",
|
||||
"invalid_match_type": "L'option sélectionnée n'est pas disponible.",
|
||||
"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_to_specific_pages": "Sur certaines pages",
|
||||
"limit_the_pages_on_which_this_action_gets_captured": "Limiter les pages sur lesquelles cette action est capturée",
|
||||
"limit_to_specific_pages": "Limiter à des pages spécifiques",
|
||||
"matches_regex": "Correspond à l'expression régulière",
|
||||
"on_all_pages": "Sur toutes les pages",
|
||||
"page_filter": "Filtrage des pages",
|
||||
"page_filter": "Filtre de page",
|
||||
"page_view": "Vue de page",
|
||||
"select_match_type": "Sélectionner le type de match",
|
||||
"starts_with": "Commence par",
|
||||
"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_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_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_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_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 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 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.",
|
||||
"track_new_user_action": "Suivi des actions d'un utilisateur",
|
||||
"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_new_user_action": "Suivre l'action des nouveaux utilisateurs",
|
||||
"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",
|
||||
"user_actions": "Actions utilisateur",
|
||||
"user_clicked_download_button": "L'utilisateur a cliqué sur le bouton Télécharger",
|
||||
"what_did_your_user_do": "Qu'a fait l'utilisateur ?",
|
||||
"user_actions": "Actions de l'utilisateur",
|
||||
"user_clicked_download_button": "L'utilisateur a cliqué sur le bouton de téléchargement",
|
||||
"what_did_your_user_do": "Que fait votre 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",
|
||||
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
|
||||
@@ -589,7 +585,7 @@
|
||||
"contacts": {
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"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",
|
||||
"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.}}",
|
||||
@@ -604,23 +600,23 @@
|
||||
"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_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_duplicates_description": "Que faire si un contact existe déjà ?",
|
||||
"upload_contacts_modal_duplicates_overwrite_description": "Les contacts existants sont écrasés.",
|
||||
"upload_contacts_modal_duplicates_overwrite_title": "Écraser",
|
||||
"upload_contacts_modal_duplicates_skip_description": "Les contacts en double sont ignorés.",
|
||||
"upload_contacts_modal_duplicates_skip_title": "Ignorer",
|
||||
"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": "Écrase les contacts existants",
|
||||
"upload_contacts_modal_duplicates_overwrite_title": "Sélectionner",
|
||||
"upload_contacts_modal_duplicates_skip_description": "Ignore les contacts en double",
|
||||
"upload_contacts_modal_duplicates_skip_title": "Sauter",
|
||||
"upload_contacts_modal_duplicates_title": "Doublons",
|
||||
"upload_contacts_modal_duplicates_update_description": "Les contacts existants sont mis à jour.",
|
||||
"upload_contacts_modal_duplicates_update_title": "Mettre à jour",
|
||||
"upload_contacts_modal_duplicates_update_description": "Mise à jour des contacts existants",
|
||||
"upload_contacts_modal_duplicates_update_title": "Mise à jour",
|
||||
"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_upload_btn": "Importer des contacts"
|
||||
},
|
||||
"formbricks_logo": "Logo Formbricks",
|
||||
"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",
|
||||
"airtable": {
|
||||
"airtable_base": "Base Airtable",
|
||||
@@ -639,13 +635,13 @@
|
||||
"sync_responses_with_airtable": "Synchroniser les réponses avec un Airtable",
|
||||
"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}",
|
||||
"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.",
|
||||
"delete_integration": "Supprimer l'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": {
|
||||
"connect_with_google_sheets": "Se connecter à Google Sheets",
|
||||
"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_removed_successfully": "Intégration supprimée 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",
|
||||
"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": {
|
||||
"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",
|
||||
@@ -698,7 +694,7 @@
|
||||
"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."
|
||||
},
|
||||
"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.",
|
||||
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
|
||||
"slack": {
|
||||
@@ -720,9 +716,9 @@
|
||||
"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_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.",
|
||||
"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": {
|
||||
"add_webhook": "Ajouter un Webhook",
|
||||
"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_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.",
|
||||
"zapier_integration_description": "Intégrez Formbricks à plus de 5 000 applications via Zapier."
|
||||
"website_or_app_integration_description": "Intégrez Formbricks dans votre site Web ou votre application.",
|
||||
"zapier_integration_description": "Intégrez Formbricks avec plus de 5000 applications via Zapier."
|
||||
},
|
||||
"project": {
|
||||
"api_keys": {
|
||||
@@ -773,39 +769,39 @@
|
||||
"unable_to_delete_api_key": "Impossible de supprimer la clé API"
|
||||
},
|
||||
"app-connection": {
|
||||
"app_connection": "Connexion d'une application",
|
||||
"app_connection_description": "Vous pouvez connecter une application à Formbricks.",
|
||||
"app_connection": "Connexion d'application",
|
||||
"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_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_description": "Cet identifiant unique est attribué à votre environnement Formbricks.",
|
||||
"environment_id": "Votre identifiant d'environnement",
|
||||
"environment_id_description": "Cet identifiant identifie de manière unique cet environnement Formbricks.",
|
||||
"formbricks_sdk_connected": "Le SDK Formbricks est 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_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.",
|
||||
"receiving_data": "Réception des données 💃🕺",
|
||||
"recheck": "Réessayer",
|
||||
"setup_alert_description": "Suivez les indications de ce tutoriel pour connecter votre application ou votre site Web en moins de cinq minutes.",
|
||||
"setup_alert_title": "Connexion"
|
||||
"recheck": "Re-vérifier",
|
||||
"setup_alert_description": "Suivez ce tutoriel étape par étape pour connecter votre application ou site web en moins de 5 minutes.",
|
||||
"setup_alert_title": "Comment connecter"
|
||||
},
|
||||
"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_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_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",
|
||||
"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_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.",
|
||||
"recontact_waiting_time": "Délai avant nouveau contact",
|
||||
"recontact_waiting_time_settings_description": "Vous pouvez contrôler la fréquence à laquelle les utilisateurs sont sollicités pour répondre aux enquêtes.",
|
||||
"recontact_waiting_time": "Temps d'attente pour le recontact",
|
||||
"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.",
|
||||
"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",
|
||||
"whats_your_project_called": "Quel est le nom de votre projet ?"
|
||||
"whats_your_project_called": "Comment s'appelle votre projet ?"
|
||||
},
|
||||
"languages": {
|
||||
"add_language": "Ajouter une langue",
|
||||
@@ -822,8 +818,8 @@
|
||||
"language": "Langue",
|
||||
"language_deleted_successfully": "Langue supprimée avec succès",
|
||||
"languages_updated_successfully": "Langues mises à jour avec succès",
|
||||
"multi_language_surveys": "Enquêtes multilingues",
|
||||
"multi_language_surveys_description": "Vous pouvez ajouter des langues pour créer des enquêtes multilingues.",
|
||||
"multi_language_surveys": "Sondages 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.",
|
||||
"please_select_a_language": "Veuillez sélectionner une langue.",
|
||||
"remove_language": "Supprimer la langue",
|
||||
@@ -834,25 +830,25 @@
|
||||
"look": {
|
||||
"add_background_color": "Ajouter une couleur de fond",
|
||||
"add_background_color_description": "Ajoutez une couleur de fond au conteneur du logo.",
|
||||
"app_survey_placement": "Emplacement des enquêtes",
|
||||
"app_survey_placement_settings_description": "Vous pouvez choisir l'emplacement des enquêtes dans votre application ou sur votre site Web.",
|
||||
"app_survey_placement": "Placement de l'enquête dans l'application",
|
||||
"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",
|
||||
"email_customization": "Personnalisation des e-mails",
|
||||
"email_customization_description": "Vous pouvez modifier l'apparence des e-mails envoyés par Formbricks en votre nom.",
|
||||
"enable_custom_styling": "Activer la personnalisation",
|
||||
"enable_custom_styling_description": "Permet aux utilisateurs de remplacer ce thème par un autre dans l'éditeur d'enquête.",
|
||||
"email_customization_description": "Modifiez l'apparence des e-mails envoyés par Formbricks en votre nom.",
|
||||
"enable_custom_styling": "Activer le style personnalisé",
|
||||
"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_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_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.",
|
||||
"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_updated_successfully": "Logo mis à jour avec succès",
|
||||
"logo_upload_failed": "Échec du téléchargement du logo. Veuillez réessayer.",
|
||||
"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_confirmation": "Êtes-vous sûr de vouloir supprimer le logo ?",
|
||||
"replace_logo": "Remplacer le logo",
|
||||
@@ -862,16 +858,16 @@
|
||||
"show_powered_by_formbricks": "Afficher la signature \"Propulsé par Formbricks",
|
||||
"styling_updated_successfully": "Style mis à jour avec succès",
|
||||
"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": {
|
||||
"add": "Ajouter",
|
||||
"add_tag": "Ajouter une étiquette",
|
||||
"count": "Compter",
|
||||
"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_description": "Vous pouvez fusionner et supprimer des balises de réponse.",
|
||||
"manage_tags_description": "Fusionner et supprimer les balises de réponse.",
|
||||
"merge": "Fusionner",
|
||||
"no_tag_found": "Aucun tag trouvé",
|
||||
"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.",
|
||||
"permission": "Permission",
|
||||
"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": {
|
||||
"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 :",
|
||||
"clone_and_edit_segment": "Cloner et modifier le segment",
|
||||
"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",
|
||||
"desktop": "Ordinateur",
|
||||
"desktop": "Bureau",
|
||||
"devices": "Appareils",
|
||||
"edit_segment": "Modifier le segment",
|
||||
"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",
|
||||
"no_attributes_yet": "Aucun attribut pour le moment !",
|
||||
"no_filters_yet": "Il n'y a pas encore de filtres !",
|
||||
"no_segments_yet": "Aucun segment n'est actuellement enregistré.",
|
||||
"person_and_attributes": "Personne et attributs",
|
||||
"no_segments_yet": "Vous n'avez actuellement aucun segment enregistré.",
|
||||
"person_and_attributes": "Personne et Attributs",
|
||||
"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.",
|
||||
"pre_segment_users": "Précisez vos utilisateurs à l'avance avec des filtres d'attributs.",
|
||||
@@ -929,7 +926,7 @@
|
||||
"segment_id": "ID de segment",
|
||||
"segment_saved_successfully": "Segment enregistré 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",
|
||||
"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.",
|
||||
@@ -948,59 +945,62 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
"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": {
|
||||
"1000_monthly_responses": "1 000 réponses mensuelles",
|
||||
"1_project": "1 projet",
|
||||
"2000_contacts": "2 000 contacts",
|
||||
"3_projects": "3 projets",
|
||||
"5000_monthly_responses": "5 000 réponses mensuelles",
|
||||
"7500_contacts": "7 500 contacts",
|
||||
"all_integrations": "Tout type d'intégration",
|
||||
"annually": "Annuel",
|
||||
"api_webhooks": "API et webhooks",
|
||||
"app_surveys": "Enquêtes d'application",
|
||||
"1000_monthly_responses": "1000 Réponses Mensuelles",
|
||||
"1_project": "1 Projet",
|
||||
"2000_contacts": "2 000 Contacts",
|
||||
"3_projects": "3 Projets",
|
||||
"5000_monthly_responses": "5,000 Réponses Mensuelles",
|
||||
"7500_contacts": "7 500 Contacts",
|
||||
"all_integrations": "Toutes les intégrations",
|
||||
"annually": "Annuellement",
|
||||
"api_webhooks": "API et Webhooks",
|
||||
"app_surveys": "Sondages d'application",
|
||||
"attribute_based_targeting": "Ciblage basé sur les attributs",
|
||||
"current": "Actuel",
|
||||
"current_plan": "Forfait actuel",
|
||||
"current_plan": "Plan actuel",
|
||||
"current_tier_limit": "Limite de niveau actuel",
|
||||
"custom": "Personnalisé et Échelle",
|
||||
"custom_contacts_limit": "Personnalisation du nombre de contacts",
|
||||
"custom_project_limit": "Personnalisation du nombre de projets",
|
||||
"custom_response_limit": "Personnalisation des réponses",
|
||||
"email_embedded_surveys": "Sondages intégrés à des e-mails",
|
||||
"custom_contacts_limit": "Limite de contacts personnalisé",
|
||||
"custom_project_limit": "Limite de projet personnalisé",
|
||||
"custom_response_limit": "Limite de réponse personnalisé",
|
||||
"email_embedded_surveys": "Sondages intégrés par e-mail",
|
||||
"email_follow_ups": "Relances par e-mail",
|
||||
"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 !",
|
||||
"everything_in_free": "Toutes les options du forfait Gratuit",
|
||||
"everything_in_startup": "Toutes les options du forfait Initial",
|
||||
"everything_in_free": "Tout est gratuit",
|
||||
"everything_in_startup": "Tout dans le Startup",
|
||||
"free": "Gratuit",
|
||||
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
|
||||
"get_2_months_free": "Obtenez 2 mois gratuits",
|
||||
"hosted_in_frankfurt": "Hébergement à Francfort",
|
||||
"ios_android_sdks": "SDK iOS et Android pour les enquêtes sur mobile",
|
||||
"link_surveys": "Enquêtes par lien (partageables)",
|
||||
"hosted_in_frankfurt": "Hébergé à Francfort",
|
||||
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
|
||||
"link_surveys": "Sondages par lien (partageables)",
|
||||
"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_subscription": "Gérer l'abonnement",
|
||||
"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",
|
||||
"premium_support_with_slas": "Assistance premium avec accord de niveau de service",
|
||||
"remove_branding": "Suppression du logo",
|
||||
"startup": "Initial",
|
||||
"premium_support_with_slas": "Soutien premium avec SLA",
|
||||
"remove_branding": "Supprimer la marque",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
|
||||
"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",
|
||||
"unlimited_miu": "MIU Illimité",
|
||||
"unlimited_projects": "Projets illimités",
|
||||
"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",
|
||||
"upgrade": "Mise à niveau",
|
||||
"uptime_sla_99": "Disponibilité de 99 %",
|
||||
"uptime_sla_99": "SLA de disponibilité (99%)",
|
||||
"website_surveys": "Sondages de site web"
|
||||
},
|
||||
"enterprise": {
|
||||
@@ -1031,15 +1031,15 @@
|
||||
"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_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_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_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_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_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.",
|
||||
@@ -1056,16 +1056,16 @@
|
||||
"logo_removed_successfully": "Logo supprimé avec succès",
|
||||
"logo_saved_successfully": "Logo enregistré avec succès",
|
||||
"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_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.",
|
||||
"organization_created_successfully": "Organisation créé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_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_updated_successfully": "Nom de l'organisation mis à jour avec succès",
|
||||
"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."
|
||||
},
|
||||
"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)",
|
||||
"every_response": "Chaque réponse",
|
||||
"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",
|
||||
"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",
|
||||
"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 !",
|
||||
@@ -1097,7 +1097,7 @@
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Conséquences de la suppression de compte",
|
||||
"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_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
|
||||
"delete_account": "Supprimer le compte",
|
||||
@@ -1124,7 +1124,7 @@
|
||||
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
|
||||
"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_undo": "Cette opération est irréversible."
|
||||
"warning_cannot_undo": "Ceci ne peut pas être annulé"
|
||||
},
|
||||
"teams": {
|
||||
"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_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_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_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_radius_of_the_card_and_the_inputs": "Vous pouvez arrondir la bordure des encadrés et des champs de saisie.",
|
||||
"change_the_brand_color_of_the_survey": "Vous pouvez modifier la couleur dominante d'une enquête.",
|
||||
"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": "Changez le rayon de bordure de la carte et des champs de saisie.",
|
||||
"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_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.",
|
||||
"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.",
|
||||
@@ -1416,8 +1416,8 @@
|
||||
"includes_one_of": "Comprend un de",
|
||||
"initial_value": "Valeur initiale",
|
||||
"inner_text": "Texte interne",
|
||||
"input_border_color": "Couleur de la bordure des champs de saisie",
|
||||
"input_color": "Couleur d'arrière-plan des champs de saisie",
|
||||
"input_border_color": "Couleur de bordure d'entrée",
|
||||
"input_color": "Couleur d'entrée",
|
||||
"insert_link": "Insérer un lien",
|
||||
"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.",
|
||||
@@ -1494,7 +1494,7 @@
|
||||
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
|
||||
"publish": "Publier",
|
||||
"question": "Question",
|
||||
"question_color": "Couleur des questions",
|
||||
"question_color": "Couleur de la question",
|
||||
"question_deleted": "Question supprimée.",
|
||||
"question_duplicated": "Question dupliquée.",
|
||||
"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_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
|
||||
"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.",
|
||||
"rows": "Lignes",
|
||||
"save_and_close": "Enregistrer et fermer",
|
||||
@@ -1587,7 +1587,7 @@
|
||||
"starts_with": "Commence par",
|
||||
"state": "État",
|
||||
"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.",
|
||||
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
|
||||
"subheading": "Sous-titre",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Ligne d'adresse 1",
|
||||
"address_line_2": "Ligne d'adresse 2",
|
||||
"an_error_occurred_adding_the_tag": "Une erreur est survenue lors de l'ajout de l'étiquette",
|
||||
"an_error_occurred_creating_the_tag": "Une erreur est survenue lors de la création de l'étiquette",
|
||||
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
|
||||
"browser": "Navigateur",
|
||||
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
"show_all_responses_where": "Afficher toutes les réponses où...",
|
||||
"starts": "Commence",
|
||||
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
|
||||
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
|
||||
@@ -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_placeholder": "Entrez votre réponse ici...",
|
||||
"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_name": "Tout créer moi-même",
|
||||
"custom_survey_description": "Créer une enquête sans modèle.",
|
||||
"custom_survey_name": "Commencer à zéro",
|
||||
"custom_survey_question_1_headline": "Que voudriez-vous savoir ?",
|
||||
"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é.",
|
||||
@@ -2393,7 +2392,7 @@
|
||||
"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_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_4_button_label": "Demander une fonctionnalité",
|
||||
"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_upper_label": "Très bien",
|
||||
"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_headline": "Souhaitez-vous être informé ?",
|
||||
"preview_survey_question_2_headline": "Vous voulez rester informé ?",
|
||||
"preview_survey_welcome_card_headline": "Bienvenue !",
|
||||
"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.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "編集",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
"environment_not_found": "環境が見つかりません",
|
||||
"environment_notice": "現在、{environment} 環境にいます。",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
|
||||
"error_rate_limit_title": "レート制限を超えました",
|
||||
"expand_rows": "行を展開",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
|
||||
"finish": "完了",
|
||||
"follow_these": "こちらの手順に従って",
|
||||
"formbricks_version": "Formbricksバージョン",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "セグメント",
|
||||
"select": "選択",
|
||||
"select_all": "すべて選択",
|
||||
"select_filter": "フィルターを選択",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_teams": "チームを選択",
|
||||
"selected": "選択済み",
|
||||
@@ -890,6 +886,7 @@
|
||||
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "プロジェクト、環境、または組織が見つかりません",
|
||||
"segments": {
|
||||
"add_filter_below": "下にフィルターを追加",
|
||||
"add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください",
|
||||
@@ -986,12 +983,15 @@
|
||||
"manage_subscription": "サブスクリプションを管理",
|
||||
"monthly": "月間",
|
||||
"monthly_identified_users": "月間識別ユーザー数",
|
||||
"per_month": "月",
|
||||
"per_year": "年",
|
||||
"plan_upgraded_successfully": "プランを正常にアップグレードしました",
|
||||
"premium_support_with_slas": "SLA付きプレミアムサポート",
|
||||
"remove_branding": "ブランディングを削除",
|
||||
"startup": "スタートアップ",
|
||||
"startup_description": "無料プランのすべての機能に追加機能。",
|
||||
"switch_plan": "プランを切り替え",
|
||||
"switch_plan_confirmation_text": "本当に {plan} プランに切り替えますか? {price} {period} が請求されます。",
|
||||
"team_access_roles": "チームアクセスロール",
|
||||
"unable_to_upgrade_plan": "プランをアップグレードできません",
|
||||
"unlimited_miu": "無制限のMIU",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "住所1",
|
||||
"address_line_2": "住所2",
|
||||
"an_error_occurred_adding_the_tag": "タグの追加中にエラーが発生しました",
|
||||
"an_error_occurred_creating_the_tag": "タグの作成中にエラーが発生しました",
|
||||
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
|
||||
"browser": "ブラウザ",
|
||||
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "連携を設定",
|
||||
"share_survey": "フォームを共有",
|
||||
"show_all_responses_that_match": "一致するすべての回答を表示",
|
||||
"show_all_responses_where": "以下のすべての回答を表示...",
|
||||
"starts": "開始",
|
||||
"starts_tooltip": "フォームが開始された回数。",
|
||||
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Terminar",
|
||||
"follow_these": "Siga esses",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segmentos",
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_survey": "Selecionar Pesquisa",
|
||||
"select_teams": "Selecionar times",
|
||||
"selected": "Selecionado",
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
|
||||
"segments": {
|
||||
"add_filter_below": "Adicionar filtro abaixo",
|
||||
"add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar",
|
||||
@@ -986,12 +983,15 @@
|
||||
"manage_subscription": "Gerenciar Assinatura",
|
||||
"monthly": "mensal",
|
||||
"monthly_identified_users": "Usuários Identificados Mensalmente",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"remove_branding": "Remover Marca",
|
||||
"startup": "startup",
|
||||
"startup_description": "Tudo no Grátis com recursos adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipe",
|
||||
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Complemento",
|
||||
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a tag",
|
||||
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a tag",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
|
||||
"browser": "navegador",
|
||||
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostre todas as respostas onde...",
|
||||
"starts": "começa",
|
||||
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
|
||||
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"create_an_account": "Criar uma conta",
|
||||
"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",
|
||||
"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_with_email": "Iniciar sessão com Email",
|
||||
"lost_access": "Perdeu o acesso?",
|
||||
@@ -113,7 +113,7 @@
|
||||
"account_settings": "Configurações da conta",
|
||||
"action": "Ação",
|
||||
"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",
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
@@ -127,14 +127,14 @@
|
||||
"all": "Todos",
|
||||
"all_questions": "Todas as perguntas",
|
||||
"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",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anónimo",
|
||||
"api_keys": "Chaves API",
|
||||
"app": "Aplicação",
|
||||
"app_survey": "Inquérito (app)",
|
||||
"app_survey": "Inquérito da Aplicação",
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Tem a certeza?",
|
||||
"attributes": "Atributos",
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Concluir",
|
||||
"follow_these": "Siga estes",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
@@ -246,8 +243,8 @@
|
||||
"light_overlay": "Sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
"link_survey": "Inquérito (link)",
|
||||
"link_surveys": "Inquéritos (links)",
|
||||
"link_survey": "Ligar Inquérito",
|
||||
"link_surveys": "Ligar Inquéritos",
|
||||
"load_more": "Carregar mais",
|
||||
"loading": "A carregar",
|
||||
"logo": "Logótipo",
|
||||
@@ -301,7 +298,7 @@
|
||||
"others": "Outros",
|
||||
"overview": "Visão geral",
|
||||
"password": "Palavra-passe",
|
||||
"paused": "Em pausa",
|
||||
"paused": "Pausado",
|
||||
"pending_downgrade": "Rebaixamento Pendente",
|
||||
"people_manager": "Gestor de Pessoas",
|
||||
"person": "Pessoa",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segmentos",
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_survey": "Selecionar Inquérito",
|
||||
"select_teams": "Selecionar equipas",
|
||||
"selected": "Selecionado",
|
||||
@@ -378,7 +374,7 @@
|
||||
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
|
||||
"something_went_wrong": "Algo correu mal",
|
||||
"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",
|
||||
"status": "Estado",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
@@ -538,7 +534,7 @@
|
||||
"enter_url": "por exemplo, https://app.com/dashboard",
|
||||
"exactly_matches": "Corresponde exatamente",
|
||||
"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?",
|
||||
"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",
|
||||
@@ -607,12 +603,12 @@
|
||||
"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_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_title": "Gravar por cima",
|
||||
"upload_contacts_modal_duplicates_skip_description": "Ignorar os contactos duplicados",
|
||||
"upload_contacts_modal_duplicates_overwrite_description": "Sobrescreve os contactos existentes",
|
||||
"upload_contacts_modal_duplicates_overwrite_title": "Sobrescrever",
|
||||
"upload_contacts_modal_duplicates_skip_description": "Ignora os contactos duplicados",
|
||||
"upload_contacts_modal_duplicates_skip_title": "Saltar",
|
||||
"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_pick_different_file": "Escolher um ficheiro diferente",
|
||||
"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"
|
||||
},
|
||||
"app-connection": {
|
||||
"app_connection": "Conexão de aplicação",
|
||||
"app_connection_description": "Conecte a sua aplicação ao Formbricks",
|
||||
"app_connection": "Ligação de Aplicação",
|
||||
"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_title": "As alterações serão refletidas após 5 minutos devido ao armazenamento em cache.",
|
||||
"environment_id": "O seu identificador",
|
||||
"environment_id_description": "Este id identifica o seu espaço Formbricks.",
|
||||
"environment_id": "O Seu ID de Ambiente",
|
||||
"environment_id_description": "Este id identifica de forma única este ambiente Formbricks.",
|
||||
"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",
|
||||
"how_to_setup": "Como configurar",
|
||||
"how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.",
|
||||
"receiving_data": "A receber dados 💃🕺",
|
||||
"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"
|
||||
},
|
||||
"general": {
|
||||
@@ -800,23 +796,23 @@
|
||||
"project_deleted_successfully": "Projeto eliminado com sucesso",
|
||||
"project_name_settings_description": "Altere o nome dos seus projetos.",
|
||||
"project_name_updated_successfully": "Nome do projeto atualizado com sucesso",
|
||||
"recontact_waiting_time": "Tempo de espera de recontacto",
|
||||
"recontact_waiting_time_settings_description": "Controle a regularidade com que os utilizadores podem ser inquiridos em todos os inquéritos da aplicação.",
|
||||
"recontact_waiting_time": "Tempo de Espera para Recontacto",
|
||||
"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.",
|
||||
"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",
|
||||
"whats_your_project_called": "Como se chama o seu projeto?"
|
||||
},
|
||||
"languages": {
|
||||
"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)",
|
||||
"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_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.",
|
||||
"duplicate_language_or_language_id": "Idioma ou ID de idioma duplicado",
|
||||
"edit_languages": "Editar línguas",
|
||||
"edit_languages": "Editar idiomas",
|
||||
"identifier": "Identificador (ISO)",
|
||||
"incomplete_translations": "Traduções incompletas",
|
||||
"language": "Idioma",
|
||||
@@ -834,8 +830,8 @@
|
||||
"look": {
|
||||
"add_background_color": "Adicionar cor de fundo",
|
||||
"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_settings_description": "Altere o local onde os inquéritos serão exibidos na sua aplicação ou site.",
|
||||
"app_survey_placement": "Colocação do Inquérito da Aplicação",
|
||||
"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",
|
||||
"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.",
|
||||
@@ -845,14 +841,14 @@
|
||||
"failed_to_update_logo": "Falha ao atualizar o logótipo",
|
||||
"formbricks_branding": "Marca Formbricks",
|
||||
"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.",
|
||||
"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_updated_successfully": "Logótipo atualizado com sucesso",
|
||||
"logo_upload_failed": "Falha no carregamento do logótipo. Por favor, tente novamente.",
|
||||
"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_confirmation": "Tem a certeza de que quer remover o logótipo?",
|
||||
"replace_logo": "Substituir Logotipo",
|
||||
@@ -869,9 +865,9 @@
|
||||
"add_tag": "Adicionar Etiqueta",
|
||||
"count": "Contagem",
|
||||
"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_description": "Junte e remova etiquetas de resposta",
|
||||
"manage_tags_description": "Fundir e remover etiquetas de resposta.",
|
||||
"merge": "Fundir",
|
||||
"no_tag_found": "Nenhuma etiqueta encontrada",
|
||||
"search_tags": "Procurar Etiquetas...",
|
||||
@@ -890,6 +886,7 @@
|
||||
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
|
||||
"segments": {
|
||||
"add_filter_below": "Adicionar filtro abaixo",
|
||||
"add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar",
|
||||
@@ -948,24 +945,24 @@
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave API",
|
||||
"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": {
|
||||
"1000_monthly_responses": "1000 Respostas Mensais",
|
||||
"1_project": "1 Projeto",
|
||||
"2000_contacts": "2000 Contactos",
|
||||
"2000_contacts": "2,000 Contactos",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5000 Respostas Mensais",
|
||||
"7500_contacts": "7500 Contactos",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"7500_contacts": "7,500 Contactos",
|
||||
"all_integrations": "Todas as Integrações",
|
||||
"annually": "Anual",
|
||||
"annually": "Anualmente",
|
||||
"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",
|
||||
"current": "Atual",
|
||||
"current_plan": "Plano Atual",
|
||||
"current_tier_limit": "Limite Atual do Nível",
|
||||
"custom": "Personalizado",
|
||||
"custom": "Personalizado e Escala",
|
||||
"custom_contacts_limit": "Limite de Contactos Personalizado",
|
||||
"custom_project_limit": "Limite de Projeto Personalizado",
|
||||
"custom_response_limit": "Limite de Resposta Personalizado",
|
||||
@@ -973,25 +970,28 @@
|
||||
"email_follow_ups": "Acompanhamentos por Email",
|
||||
"enterprise_description": "Suporte premium e limites personalizados.",
|
||||
"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_startup": "Tudo incluído no Plano Para começar",
|
||||
"everything_in_free": "Tudo em Gratuito",
|
||||
"everything_in_startup": "Tudo em Startup",
|
||||
"free": "Grátis",
|
||||
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
|
||||
"get_2_months_free": "Obtenha 2 meses grátis",
|
||||
"hosted_in_frankfurt": "Hospedado em Frankfurt",
|
||||
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
|
||||
"link_surveys": "Inquéritos por link (partilháveis)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Saltar Perguntas, Campos Ocultos, Inquéritos Regulares, etc.",
|
||||
"link_surveys": "Ligar Inquéritos (Partilhável)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
|
||||
"manage_card_details": "Gerir Detalhes do Cartão",
|
||||
"manage_subscription": "Gerir Subscrição",
|
||||
"monthly": "Mensal",
|
||||
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"remove_branding": "Possibilidade de remover o logo",
|
||||
"remove_branding": "Remover Marca",
|
||||
"startup": "Inicialização",
|
||||
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipa",
|
||||
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
@@ -1001,7 +1001,7 @@
|
||||
"unlimited_team_members": "Membros da Equipa Ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"uptime_sla_99": "SLA de Tempo de Atividade (99%)",
|
||||
"website_surveys": "Inquéritos (site)"
|
||||
"website_surveys": "Inquéritos do Website"
|
||||
},
|
||||
"enterprise": {
|
||||
"audit_logs": "Registos de Auditoria",
|
||||
@@ -1056,7 +1056,7 @@
|
||||
"logo_removed_successfully": "Logótipo removido com sucesso",
|
||||
"logo_saved_successfully": "Logótipo guardado com sucesso",
|
||||
"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_invited_successfully": "Membro convidado com sucesso",
|
||||
"once_its_gone_its_gone": "Uma vez que se vai, já era.",
|
||||
@@ -1097,7 +1097,7 @@
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Consequências da eliminação da conta",
|
||||
"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_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
|
||||
"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.",
|
||||
"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_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.",
|
||||
"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.",
|
||||
@@ -1165,7 +1165,7 @@
|
||||
"team_settings_description": "Gerir membros da equipa, direitos de acesso e mais.",
|
||||
"team_updated_successfully": "Equipa atualizada com sucesso",
|
||||
"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_title": "Desbloqueie as Equipas 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",
|
||||
"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...",
|
||||
"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",
|
||||
"underline": "Sublinhar",
|
||||
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Endereço Linha 2",
|
||||
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a etiqueta",
|
||||
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a etiqueta",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
|
||||
"browser": "Navegador",
|
||||
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostrar todas as respostas onde...",
|
||||
"starts": "Começa",
|
||||
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
|
||||
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
|
||||
@@ -2170,7 +2169,7 @@
|
||||
"consent_description": "Pedir para concordar com os termos, condições ou uso de dados",
|
||||
"contact_info": "Informações de Contacto",
|
||||
"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_question_10_headline": "Tem mais algum comentário, pergunta ou preocupação?",
|
||||
"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_placeholder": "Escreva a sua resposta aqui...",
|
||||
"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_question_1_headline": "O que gostaria de saber?",
|
||||
"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_question_1_headline": "$[projectName] torna fácil para mim [ADD GOAL]",
|
||||
"customer_effort_score_question_1_lower_label": "Discordo totalmente",
|
||||
@@ -2266,7 +2265,7 @@
|
||||
"default_welcome_card_button_label": "Seguinte",
|
||||
"default_welcome_card_headline": "Bem-vindo!",
|
||||
"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_question_1_choice_1": "Sim 👍",
|
||||
"docs_feedback_question_1_choice_2": "Não 👎",
|
||||
@@ -2274,7 +2273,7 @@
|
||||
"docs_feedback_question_2_headline": "Por favor, elabore:",
|
||||
"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_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_2": "Não",
|
||||
"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_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?",
|
||||
"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_question_1_button_label": "Vamos a isso!",
|
||||
"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_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",
|
||||
@@ -2354,17 +2353,17 @@
|
||||
"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_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_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_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_3_headline": "Adorável! Há mais alguma coisa que gostaria que abordássemos?",
|
||||
"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_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_lower_label": "Não é 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?",
|
||||
"feature_chaser_description": "Acompanhe os utilizadores que acabaram de usar uma funcionalidade específica.",
|
||||
"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_upper_label": "Muito importante",
|
||||
"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_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_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_3_button_label": "Sim, notifique-me",
|
||||
"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_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_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_name": "Identificar Oportunidades de Venda Adicional",
|
||||
"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_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_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_6_headline": "O que foi? Por favor, explique:",
|
||||
"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_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_question_1_headline": "Como classificaria a newsletter desta semana?",
|
||||
"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_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_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_name": "Melhorar a Conversão de Inquéritos",
|
||||
"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 Testes",
|
||||
"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_3": "É muito caro para o que faz",
|
||||
"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_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_2_button_label": "Seguinte",
|
||||
"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_name": "Sugestão de Entrevista",
|
||||
"interview_prompt_question_1_button_label": "Reservar horário",
|
||||
"interview_prompt_question_1_headline": "Tem 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_headline": "Tens 15 minutos para falar 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_name": "Verificação de Retenção a Longo Prazo",
|
||||
"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_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_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:",
|
||||
"matrix": "Matriz",
|
||||
"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_question_1_headline": "Quão relevantes são estes resultados de pesquisa?",
|
||||
"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_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_task_accomplishment_description": "Veja se as pessoas realizam as suas tarefas a tempo. As pessoas bem-sucedidas são melhores clientes.",
|
||||
"measure_task_accomplishment_name": "Meça o cumprimento das tarefas",
|
||||
"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": "Medir Realização de Tarefas",
|
||||
"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_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_2_headline": "Quão fácil foi alcançar o seu objetivo?",
|
||||
"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...",
|
||||
"multi_select": "Seleção Múltipla",
|
||||
"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_question_1_choice_1": "PostHog",
|
||||
"new_integration_survey_question_1_choice_2": "Segmento",
|
||||
@@ -2605,7 +2604,7 @@
|
||||
"new_integration_survey_question_1_headline": "Quais outras ferramentas está a utilizar?",
|
||||
"next": "Seguinte",
|
||||
"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_name": "Net Promoter Score (NPS)",
|
||||
"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_4": "Proprietário do Produto",
|
||||
"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_2_choice_1": "só eu",
|
||||
"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_welcome_card_headline": "Bem-vindo!",
|
||||
"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_question_1_choice_1": "Funcionalidade 1",
|
||||
"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_4": "Proprietário do Produto",
|
||||
"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_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]?",
|
||||
@@ -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?",
|
||||
"ranking": "Classificação",
|
||||
"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_question_1_headline": "Quão fácil ou difícil foi concluir o checkout?",
|
||||
"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_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_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_subheader": "Ajude-nos a melhorar a sua experiência.",
|
||||
"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_2_button_label": "Claro!",
|
||||
"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_2": "Encontrei um site melhor",
|
||||
"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)",
|
||||
"strongly_agree": "Concordo 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_name": "Apoio no trabalho",
|
||||
"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": "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_lower_label": "Não 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_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_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_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_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...",
|
||||
"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.",
|
||||
@@ -2906,6 +2905,6 @@
|
||||
"usability_question_8_headline": "Usar o sistema pareceu complicado.",
|
||||
"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_score_name": "System Usability Score (SUS)"
|
||||
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "Editare",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
"environment_not_found": "Mediul nu a fost găsit",
|
||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
|
||||
"error_rate_limit_title": "Limită de cereri depășită",
|
||||
"expand_rows": "Extinde rândurile",
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_projects": "Nu s-a reușit încărcarea proiectelor",
|
||||
"finish": "Finalizează",
|
||||
"follow_these": "Urmați acestea",
|
||||
"formbricks_version": "Versiunea Formbricks",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "Segment",
|
||||
"select": "Selectați",
|
||||
"select_all": "Selectați toate",
|
||||
"select_filter": "Selectați filtrul",
|
||||
"select_survey": "Selectați chestionar",
|
||||
"select_teams": "Selectați echipele",
|
||||
"selected": "Selectat",
|
||||
@@ -890,6 +886,7 @@
|
||||
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "Proiecte, medii sau organizații nu găsite",
|
||||
"segments": {
|
||||
"add_filter_below": "Adăugați un filtru mai jos",
|
||||
"add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe",
|
||||
@@ -986,12 +983,15 @@
|
||||
"manage_subscription": "Gestionați abonamentul",
|
||||
"monthly": "Lunar",
|
||||
"monthly_identified_users": "Utilizatori identificați lunar",
|
||||
"per_month": "pe lună",
|
||||
"per_year": "pe an",
|
||||
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
|
||||
"premium_support_with_slas": "Suport premium cu SLA-uri",
|
||||
"remove_branding": "Eliminare branding",
|
||||
"startup": "Pornire",
|
||||
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
|
||||
"switch_plan": "Schimbă planul",
|
||||
"switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.",
|
||||
"team_access_roles": "Roluri acces echipă",
|
||||
"unable_to_upgrade_plan": "Nu se poate upgrada planul",
|
||||
"unlimited_miu": "MIU Nelimitat",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresă Linie 1",
|
||||
"address_line_2": "Adresă Linie 2",
|
||||
"an_error_occurred_adding_the_tag": "A apărut o eroare la adăugarea etichetei",
|
||||
"an_error_occurred_creating_the_tag": "A apărut o eroare la crearea etichetei",
|
||||
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "Configurare integrare",
|
||||
"share_survey": "Distribuie chestionarul",
|
||||
"show_all_responses_that_match": "Afișează toate răspunsurile care corespund",
|
||||
"show_all_responses_where": "Afișează toate răspunsurile unde...",
|
||||
"starts": "Începuturi",
|
||||
"starts_tooltip": "Număr de ori când sondajul a fost început.",
|
||||
"survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "编辑",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
"enterprise_license": "企业 许可证",
|
||||
"environment_not_found": "环境 未找到",
|
||||
"environment_notice": "你 目前 位于 {environment} 环境。",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
|
||||
"error_rate_limit_title": "速率 限制 超过",
|
||||
"expand_rows": "展开 行",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_projects": "加载项目失败",
|
||||
"finish": "完成",
|
||||
"follow_these": "遵循 这些",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "细分",
|
||||
"select": "选择",
|
||||
"select_all": "选择 全部",
|
||||
"select_filter": "选择过滤器",
|
||||
"select_survey": "选择 调查",
|
||||
"select_teams": "选择 团队",
|
||||
"selected": "已选择",
|
||||
@@ -890,6 +886,7 @@
|
||||
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "项目 、 环境 或 组织 未 找到",
|
||||
"segments": {
|
||||
"add_filter_below": "在下方添加 过滤器",
|
||||
"add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始",
|
||||
@@ -986,12 +983,15 @@
|
||||
"manage_subscription": "管理 订阅",
|
||||
"monthly": "每月",
|
||||
"monthly_identified_users": "每月 已识别的 用户",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_upgraded_successfully": "计划 升级 成功",
|
||||
"premium_support_with_slas": "优质支持与 SLAs",
|
||||
"remove_branding": "移除 品牌",
|
||||
"startup": "初创企业",
|
||||
"startup_description": "包含免费版的所有功能以及附加功能.",
|
||||
"switch_plan": "切换 计划",
|
||||
"switch_plan_confirmation_text": "你确定要切换到 {plan} 计划吗?你将被收取 {price} {period} 。",
|
||||
"team_access_roles": "团队访问角色",
|
||||
"unable_to_upgrade_plan": "无法升级计划",
|
||||
"unlimited_miu": "无限 MIU",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "地址 第1行",
|
||||
"address_line_2": "地址 第2行",
|
||||
"an_error_occurred_adding_the_tag": "添加标签时发生错误",
|
||||
"an_error_occurred_creating_the_tag": "创建标签时发生错误",
|
||||
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
|
||||
"browser": "浏览器",
|
||||
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "设置 集成",
|
||||
"share_survey": "分享 问卷调查",
|
||||
"show_all_responses_that_match": "显示所有匹配的响应",
|
||||
"show_all_responses_where": "显示所有的响应,条件为...",
|
||||
"starts": "开始",
|
||||
"starts_tooltip": "调查 被 开始 的 次数",
|
||||
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",
|
||||
|
||||
@@ -200,7 +200,6 @@
|
||||
"edit": "編輯",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
@@ -210,8 +209,6 @@
|
||||
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
||||
"error_rate_limit_title": "限流超過",
|
||||
"expand_rows": "展開列",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_projects": "無法載入專案",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
@@ -358,7 +355,6 @@
|
||||
"segments": "區隔",
|
||||
"select": "選擇",
|
||||
"select_all": "全選",
|
||||
"select_filter": "選擇篩選器",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_teams": "選擇 團隊",
|
||||
"selected": "已選取",
|
||||
@@ -890,6 +886,7 @@
|
||||
"team_settings_description": "查看哪些團隊可以存取此專案。"
|
||||
}
|
||||
},
|
||||
"projects_environments_organizations_not_found": "找不到專案、環境或組織",
|
||||
"segments": {
|
||||
"add_filter_below": "在下方新增篩選器",
|
||||
"add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用",
|
||||
@@ -986,12 +983,15 @@
|
||||
"manage_subscription": "管理訂閱",
|
||||
"monthly": "每月",
|
||||
"monthly_identified_users": "每月識別使用者",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_upgraded_successfully": "方案已成功升級",
|
||||
"premium_support_with_slas": "具有 SLA 的頂級支援",
|
||||
"remove_branding": "移除品牌",
|
||||
"startup": "啟動版",
|
||||
"startup_description": "免費方案中的所有功能以及其他功能。",
|
||||
"switch_plan": "切換方案",
|
||||
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
|
||||
"team_access_roles": "團隊存取角色",
|
||||
"unable_to_upgrade_plan": "無法升級方案",
|
||||
"unlimited_miu": "無限 MIU",
|
||||
@@ -1664,8 +1664,6 @@
|
||||
"responses": {
|
||||
"address_line_1": "地址 1",
|
||||
"address_line_2": "地址 2",
|
||||
"an_error_occurred_adding_the_tag": "新增標籤時發生錯誤",
|
||||
"an_error_occurred_creating_the_tag": "建立標籤時發生錯誤",
|
||||
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
|
||||
"browser": "瀏覽器",
|
||||
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
|
||||
@@ -1882,6 +1880,7 @@
|
||||
"setup_integrations": "設定整合",
|
||||
"share_survey": "分享問卷",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
"show_all_responses_where": "顯示所有回應,其中...",
|
||||
"starts": "開始次數",
|
||||
"starts_tooltip": "問卷已開始的次數。",
|
||||
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { AlertCircleIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
@@ -40,19 +39,14 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [tagsState, setTagsState] = useState(tags);
|
||||
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
|
||||
const [isLoadingTagOperation, setIsLoadingTagOperation] = useState(false);
|
||||
|
||||
const onDelete = async (tagId: string) => {
|
||||
setIsLoadingTagOperation(true);
|
||||
const deleteTagResponse = await deleteTagOnResponseAction({ responseId, tagId });
|
||||
if (deleteTagResponse?.data) {
|
||||
try {
|
||||
await deleteTagOnResponseAction({ responseId, tagId });
|
||||
updateFetchedResponses();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
|
||||
logger.error({ errorMessage }, "Error deleting tag");
|
||||
} catch (e) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_deleting_the_tag"));
|
||||
}
|
||||
setIsLoadingTagOperation(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,70 +60,72 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
}, [tagIdToHighlight]);
|
||||
|
||||
const handleCreateTag = async (tagName: string) => {
|
||||
setIsLoadingTagOperation(true);
|
||||
const newTagResponse = await createTagAction({ environmentId, tagName });
|
||||
setOpen(false);
|
||||
|
||||
if (!newTagResponse?.data) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
|
||||
return;
|
||||
}
|
||||
const createTagResponse = await createTagAction({
|
||||
environmentId,
|
||||
tagName: tagName?.trim() ?? "",
|
||||
});
|
||||
|
||||
if (!newTagResponse.data.ok) {
|
||||
const errorMessage = newTagResponse.data.error;
|
||||
if (errorMessage?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <SettingsIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
if (createTagResponse?.data?.ok) {
|
||||
const tag = createTagResponse.data.data;
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
},
|
||||
]);
|
||||
|
||||
const createTagToResponseActionResponse = await createTagToResponseAction({
|
||||
responseId,
|
||||
tagId: tag.id,
|
||||
});
|
||||
|
||||
if (createTagToResponseActionResponse?.data) {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
} else {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
|
||||
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newTag = newTagResponse.data.data;
|
||||
const createTagToResponseResponse = await createTagToResponseAction({ responseId, tagId: newTag.id });
|
||||
if (createTagToResponseResponse?.data) {
|
||||
setTagsState((prevTags) => [...prevTags, { tagId: newTag.id, tagName: newTag.name }]);
|
||||
setTagIdToHighlight(newTag.id);
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createTagToResponseResponse);
|
||||
logger.error({ errorMessage });
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsLoadingTagOperation(false);
|
||||
};
|
||||
if (
|
||||
createTagResponse?.data?.ok === false &&
|
||||
createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS
|
||||
) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
|
||||
const handleAddTag = async (tagId: string) => {
|
||||
setIsLoadingTagOperation(true);
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId,
|
||||
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
await createTagToResponseAction({ responseId, tagId });
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_adding_the_tag"));
|
||||
console.error("Error adding tag:", error);
|
||||
// Revert the tag if the action failed
|
||||
setTagsState((prevTags) => prevTags.filter((tag) => tag.tagId !== tagId));
|
||||
} finally {
|
||||
setIsLoadingTagOperation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = getFormattedErrorMessage(createTagResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
|
||||
duration: 2000,
|
||||
});
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-3">
|
||||
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer p-0"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environmentId}/project/tags`);
|
||||
}}>
|
||||
<SettingsIcon className="h-5 w-5 text-slate-500 hover:text-slate-600" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tagsState?.map((tag) => (
|
||||
<Tag
|
||||
@@ -140,35 +136,37 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
tags={tagsState}
|
||||
setTagsState={setTagsState}
|
||||
highlight={tagIdToHighlight === tag.tagId}
|
||||
allowDelete={!isReadOnly && !isLoadingTagOperation}
|
||||
allowDelete={!isReadOnly}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isReadOnly && (
|
||||
<TagsCombobox
|
||||
open={open && !isLoadingTagOperation}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
|
||||
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
|
||||
createTag={handleCreateTag}
|
||||
addTag={handleAddTag}
|
||||
addTag={(tagId) => {
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId,
|
||||
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
createTagToResponseAction({ responseId, tagId }).then(() => {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environmentId}/project/tags`);
|
||||
}}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -41,58 +42,46 @@ export const SingleResponseCard = ({
|
||||
setSelectedResponseId,
|
||||
locale,
|
||||
}: SingleResponseCardProps) => {
|
||||
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
|
||||
const hasQuotas = (response.quotas && response.quotas.length > 0) ?? false;
|
||||
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
|
||||
const { t } = useTranslation();
|
||||
const environmentId = survey.environmentId;
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const skippedQuestions: string[][] = useMemo(() => {
|
||||
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
|
||||
if (temp.length > 0) {
|
||||
if (shouldReverse) temp.reverse();
|
||||
result.push([...temp]);
|
||||
temp.length = 0;
|
||||
let skippedQuestions: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
if (response.finished) {
|
||||
survey.questions.forEach((question) => {
|
||||
if (!isValidValue(response.data[question.id])) {
|
||||
temp.push(question.id);
|
||||
} else if (temp.length > 0) {
|
||||
skippedQuestions.push([...temp]);
|
||||
temp = [];
|
||||
}
|
||||
};
|
||||
|
||||
const processFinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
if (isValidValue(response.data[question.id])) {
|
||||
flushTemp(temp, result);
|
||||
} else {
|
||||
temp.push(question.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for (let index = survey.questions.length - 1; index >= 0; index--) {
|
||||
const question = survey.questions[index];
|
||||
if (
|
||||
!response.data[question.id] &&
|
||||
(skippedQuestions.length === 0 ||
|
||||
(skippedQuestions.length > 0 && !isValidValue(response.data[question.id])))
|
||||
) {
|
||||
temp.push(question.id);
|
||||
} else if (temp.length > 0) {
|
||||
temp.reverse();
|
||||
skippedQuestions.push([...temp]);
|
||||
temp = [];
|
||||
}
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const processUnfinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (let index = survey.questions.length - 1; index >= 0; index--) {
|
||||
const question = survey.questions[index];
|
||||
const hasNoData = !response.data[question.id];
|
||||
const shouldSkip = hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
|
||||
|
||||
if (shouldSkip) {
|
||||
temp.push(question.id);
|
||||
} else {
|
||||
flushTemp(temp, result, true);
|
||||
}
|
||||
}
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return response.finished ? processFinishedResponse() : processUnfinishedResponse();
|
||||
}, [response.id, response.finished, response.data, survey.questions]);
|
||||
}
|
||||
}
|
||||
// Handle the case where the last entries are empty
|
||||
if (temp.length > 0) {
|
||||
skippedQuestions.push(temp);
|
||||
}
|
||||
|
||||
const handleDeleteResponse = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -102,6 +91,7 @@ export const SingleResponseCard = ({
|
||||
}
|
||||
await deleteResponseAction({ responseId: response.id, decrementQuotas });
|
||||
updateResponseList?.([response.id]);
|
||||
router.refresh();
|
||||
if (setSelectedResponseId) setSelectedResponseId(null);
|
||||
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
|
||||
setDeleteDialogOpen(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
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 { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
|
||||
@@ -52,8 +52,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateResponse",
|
||||
summary: "Update a response",
|
||||
description:
|
||||
"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.",
|
||||
description: "Updates a response in the database.",
|
||||
tags: ["Management API - Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
@@ -62,10 +61,10 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The response fields to update",
|
||||
description: "The response to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponseUpdateInput,
|
||||
schema: ZResponseInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
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 { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
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>> => {
|
||||
try {
|
||||
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 { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { deleteDisplay } from "../display";
|
||||
import {
|
||||
deleteResponse,
|
||||
getResponse,
|
||||
getResponseForPipeline,
|
||||
updateResponse,
|
||||
updateResponseWithQuotaEvaluation,
|
||||
} from "../response";
|
||||
import { deleteResponse, getResponse, updateResponse, updateResponseWithQuotaEvaluation } from "../response";
|
||||
import { getSurveyQuestions } from "../survey";
|
||||
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", () => {
|
||||
test("delete the response, delete the display and remove uploaded files", async () => {
|
||||
vi.mocked(prisma.response.delete).mockResolvedValue(response);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
@@ -8,7 +7,6 @@ import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteResponse,
|
||||
getResponse,
|
||||
getResponseForPipeline,
|
||||
updateResponseWithQuotaEvaluation,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
|
||||
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,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: body ? "params" : "body", issue: "missing" }],
|
||||
details: [{ field: !body ? "body" : "params", issue: "missing" }],
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
auditLog.oldObject = existingResponse.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 = {
|
||||
operationId: "createResponse",
|
||||
summary: "Create a response",
|
||||
description:
|
||||
"Creates a response in the database. This will trigger the response pipeline, including webhooks, integrations, follow-up emails, and other configured actions.",
|
||||
description: "Creates a response in the database.",
|
||||
tags: ["Management API - Responses"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -2,13 +2,11 @@ import "server-only";
|
||||
import { Prisma, Response } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationBilling,
|
||||
@@ -56,7 +54,6 @@ export const createResponse = async (
|
||||
const {
|
||||
surveyId,
|
||||
displayId,
|
||||
userId,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
@@ -70,17 +67,6 @@ export const createResponse = async (
|
||||
} = responseInput;
|
||||
|
||||
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 = {};
|
||||
if (initialTtc) {
|
||||
if (finished) {
|
||||
@@ -97,14 +83,6 @@ export const createResponse = async (
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
finished,
|
||||
data,
|
||||
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 { NextRequest } from "next/server";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
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 { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
auditLog.targetId = createResponseResult.data.id;
|
||||
auditLog.newObject = createResponseResult.data;
|
||||
|
||||
@@ -32,20 +32,16 @@ export const ZResponseInput = ZResponse.pick({
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
})
|
||||
.partial({
|
||||
displayId: true,
|
||||
singleUseId: true,
|
||||
endingId: true,
|
||||
language: true,
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
})
|
||||
.extend({
|
||||
userId: z.string().optional(),
|
||||
});
|
||||
}).partial({
|
||||
displayId: true,
|
||||
singleUseId: true,
|
||||
endingId: true,
|
||||
language: true,
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export type TResponseInput = z.infer<typeof ZResponseInput>;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
|
||||
import { createBrevoCustomer } from "./brevo";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
debug: true,
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
id: "credentials",
|
||||
|
||||
@@ -1,67 +1,43 @@
|
||||
import Stripe from "stripe";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
if (!checkoutSession.metadata?.organizationId)
|
||||
if (!checkoutSession.metadata || !checkoutSession.metadata.organizationId)
|
||||
throw new ResourceNotFoundError("No organizationId found in checkout session", checkoutSession.id);
|
||||
|
||||
const organization = await getOrganization(checkoutSession.metadata.organizationId);
|
||||
const stripeSubscriptionObject = await stripe.subscriptions.retrieve(
|
||||
checkoutSession.subscription as string
|
||||
);
|
||||
const { customer: stripeCustomer } = (await stripe.checkout.sessions.retrieve(checkoutSession.id, {
|
||||
expand: ["customer"],
|
||||
})) as { customer: Stripe.Customer };
|
||||
|
||||
const organization = await getOrganization(checkoutSession.metadata!.organizationId);
|
||||
if (!organization)
|
||||
throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId);
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string, {
|
||||
expand: ["items.data.price"],
|
||||
});
|
||||
|
||||
let period: "monthly" | "yearly" = "monthly";
|
||||
|
||||
if (subscription.items?.data && subscription.items.data.length > 0) {
|
||||
const firstItem = subscription.items.data[0];
|
||||
const interval = firstItem.price?.recurring?.interval;
|
||||
period = interval === "year" ? "yearly" : "monthly";
|
||||
}
|
||||
|
||||
await updateOrganization(checkoutSession.metadata.organizationId, {
|
||||
billing: {
|
||||
...organization.billing,
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
plan: PROJECT_FEATURE_KEYS.STARTUP,
|
||||
period,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.STARTUP.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.STARTUP.RESPONSES,
|
||||
miu: BILLING_LIMITS.STARTUP.MIU,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
await stripe.subscriptions.update(stripeSubscriptionObject.id, {
|
||||
metadata: {
|
||||
organizationId: organization.id,
|
||||
responses: checkoutSession.metadata.responses,
|
||||
miu: checkoutSession.metadata.miu,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
organizationId: checkoutSession.metadata.organizationId,
|
||||
plan: PROJECT_FEATURE_KEYS.STARTUP,
|
||||
period,
|
||||
checkoutSessionId: checkoutSession.id,
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
name: organization.name,
|
||||
metadata: { organizationId: organization.id },
|
||||
invoice_settings: {
|
||||
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
|
||||
},
|
||||
"Subscription activated"
|
||||
);
|
||||
|
||||
const stripeCustomer = await stripe.customers.retrieve(checkoutSession.customer as string);
|
||||
if (stripeCustomer && !stripeCustomer.deleted) {
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
name: organization.name,
|
||||
metadata: { organizationId: organization.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -52,12 +52,13 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
|
||||
t("environments.settings.billing.7500_contacts"),
|
||||
t("environments.settings.billing.3_projects"),
|
||||
t("environments.settings.billing.remove_branding"),
|
||||
t("environments.settings.billing.email_follow_ups"),
|
||||
t("environments.settings.billing.attribute_based_targeting"),
|
||||
],
|
||||
};
|
||||
|
||||
const customPlan: TPricingPlan = {
|
||||
id: "custom",
|
||||
id: "enterprise",
|
||||
name: t("environments.settings.billing.custom"),
|
||||
featured: false,
|
||||
CTA: t("common.request_pricing"),
|
||||
@@ -68,7 +69,6 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
|
||||
},
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_startup"),
|
||||
t("environments.settings.billing.email_follow_ups"),
|
||||
t("environments.settings.billing.custom_response_limit"),
|
||||
t("environments.settings.billing.custom_contacts_limit"),
|
||||
t("environments.settings.billing.custom_project_limit"),
|
||||
|
||||
@@ -16,17 +16,22 @@ export const createSubscription = async (
|
||||
try {
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new Error("Organization not found.");
|
||||
let isNewOrganization =
|
||||
!organization.billing.stripeCustomerId ||
|
||||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
|
||||
|
||||
const priceObject = (
|
||||
await stripe.prices.list({
|
||||
lookup_keys: [priceLookupKey],
|
||||
expand: ["data.product"],
|
||||
})
|
||||
).data[0];
|
||||
|
||||
if (!priceObject) throw new Error("Price not found");
|
||||
const responses = parseInt((priceObject.product as Stripe.Product).metadata.responses);
|
||||
const miu = parseInt((priceObject.product as Stripe.Product).metadata.miu);
|
||||
|
||||
// Always create a checkout session - let Stripe handle existing customers
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||
mode: "subscription",
|
||||
line_items: [
|
||||
{
|
||||
@@ -36,20 +41,63 @@ export const createSubscription = async (
|
||||
],
|
||||
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
|
||||
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
|
||||
customer: organization.billing.stripeCustomerId ?? undefined,
|
||||
allow_promotion_codes: true,
|
||||
subscription_data: {
|
||||
metadata: { organizationId },
|
||||
trial_period_days: 15,
|
||||
trial_period_days: 30,
|
||||
},
|
||||
metadata: { organizationId },
|
||||
metadata: { organizationId, responses, miu },
|
||||
billing_address_collection: "required",
|
||||
automatic_tax: { enabled: true },
|
||||
tax_id_collection: { enabled: true },
|
||||
payment_method_data: { allow_redisplay: "always" },
|
||||
...(!isNewOrganization && {
|
||||
customer: organization.billing.stripeCustomerId ?? undefined,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// if the organization has never purchased a plan then we just create a new session and store their stripe customer id
|
||||
if (isNewOrganization) {
|
||||
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
}
|
||||
|
||||
const existingSubscription = await stripe.subscriptions.list({
|
||||
customer: organization.billing.stripeCustomerId as string,
|
||||
});
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
if (existingSubscription.data?.length > 0) {
|
||||
const existingSubscriptionItem = existingSubscription.data[0].items.data[0];
|
||||
|
||||
await stripe.subscriptions.update(existingSubscription.data[0].id, {
|
||||
items: [
|
||||
{
|
||||
id: existingSubscriptionItem.id,
|
||||
deleted: true,
|
||||
},
|
||||
{
|
||||
price: priceObject.id,
|
||||
},
|
||||
],
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
} else {
|
||||
// Create a new checkout again if there is no active subscription
|
||||
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: "Congrats! Added to your existing subscription!",
|
||||
newPlan: false,
|
||||
url: "",
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(err, "Error creating subscription");
|
||||
return {
|
||||
|
||||
@@ -1,68 +1,32 @@
|
||||
import Stripe from "stripe";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
|
||||
export const handleInvoiceFinalized = async (event: Stripe.Event) => {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const subscriptionId = invoice.subscription as string;
|
||||
if (!subscriptionId) {
|
||||
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
|
||||
return { status: 400, message: "No subscription ID found in invoice" };
|
||||
const stripeSubscriptionDetails = invoice.subscription_details;
|
||||
const organizationId = stripeSubscriptionDetails?.metadata?.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new Error("No organizationId found in subscription");
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const organizationId = subscription.metadata?.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
logger.warn(
|
||||
{
|
||||
subscriptionId,
|
||||
invoiceId: invoice.id,
|
||||
},
|
||||
"No organizationId found in subscription metadata"
|
||||
);
|
||||
return { status: 400, message: "No organizationId found in subscription" };
|
||||
}
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const periodStartTimestamp = invoice.lines.data[0]?.period?.start;
|
||||
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
|
||||
|
||||
await updateOrganization(organizationId, {
|
||||
billing: {
|
||||
...organization.billing,
|
||||
periodStart,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
organizationId,
|
||||
periodStart,
|
||||
invoiceId: invoice.id,
|
||||
},
|
||||
"Billing period updated successfully"
|
||||
);
|
||||
|
||||
return { status: 200, message: "Billing period updated successfully" };
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating billing period", {
|
||||
invoiceId: invoice.id,
|
||||
subscriptionId,
|
||||
});
|
||||
return { status: 500, message: "Error updating billing period" };
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const periodStartTimestamp = invoice.lines.data[0].period.start;
|
||||
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
|
||||
|
||||
await updateOrganization(organizationId, {
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
stripeCustomerId: invoice.customer as string,
|
||||
periodStart,
|
||||
},
|
||||
});
|
||||
|
||||
return { status: 200, message: "success" };
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
|
||||
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized";
|
||||
import { handleSubscriptionCreatedOrUpdated } from "@/modules/ee/billing/api/lib/subscription-created-or-updated";
|
||||
import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
@@ -19,7 +20,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
if (err instanceof Error) logger.error(err, "Error in Stripe webhook handler");
|
||||
if (err! instanceof Error) logger.error(err, "Error in Stripe webhook handler");
|
||||
return { status: 400, message: `Webhook Error: ${errorMessage}` };
|
||||
}
|
||||
|
||||
@@ -27,6 +28,11 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
await handleCheckoutSessionCompleted(event);
|
||||
} else if (event.type === "invoice.finalized") {
|
||||
await handleInvoiceFinalized(event);
|
||||
} else if (
|
||||
event.type === "customer.subscription.created" ||
|
||||
event.type === "customer.subscription.updated"
|
||||
) {
|
||||
await handleSubscriptionCreatedOrUpdated(event);
|
||||
} else if (event.type === "customer.subscription.deleted") {
|
||||
await handleSubscriptionDeleted(event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Stripe from "stripe";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TOrganizationBillingPeriod,
|
||||
TOrganizationBillingPlan,
|
||||
ZOrganizationBillingPeriod,
|
||||
ZOrganizationBillingPlan,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
|
||||
export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) => {
|
||||
const stripeSubscriptionObject = event.data.object as Stripe.Subscription;
|
||||
const organizationId = stripeSubscriptionObject.metadata.organizationId;
|
||||
|
||||
if (
|
||||
!["active", "trialing"].includes(stripeSubscriptionObject.status) ||
|
||||
stripeSubscriptionObject.cancel_at_period_end
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!organizationId) {
|
||||
logger.error({ event, organizationId }, "No organizationId found in subscription");
|
||||
return { status: 400, message: "skipping, no organizationId found" };
|
||||
}
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
|
||||
const subscriptionPrice = stripeSubscriptionObject.items.data[0].price;
|
||||
const product = await stripe.products.retrieve(subscriptionPrice.product as string);
|
||||
|
||||
if (!product)
|
||||
throw new ResourceNotFoundError(
|
||||
"Product not found",
|
||||
stripeSubscriptionObject.items.data[0].price.product.toString()
|
||||
);
|
||||
|
||||
let period: TOrganizationBillingPeriod = "monthly";
|
||||
const periodParsed = ZOrganizationBillingPeriod.safeParse(subscriptionPrice.metadata.period);
|
||||
if (periodParsed.success) {
|
||||
period = periodParsed.data;
|
||||
}
|
||||
|
||||
let updatedBillingPlan: TOrganizationBillingPlan = organization.billing.plan;
|
||||
|
||||
let responses: number | null = null;
|
||||
let miu: number | null = null;
|
||||
let projects: number | null = null;
|
||||
|
||||
if (product.metadata.responses === "unlimited") {
|
||||
responses = null;
|
||||
} else if (parseInt(product.metadata.responses) > 0) {
|
||||
responses = parseInt(product.metadata.responses);
|
||||
} else {
|
||||
logger.error({ responses: product.metadata.responses }, "Invalid responses metadata in product");
|
||||
throw new Error("Invalid responses metadata in product");
|
||||
}
|
||||
|
||||
if (product.metadata.miu === "unlimited") {
|
||||
miu = null;
|
||||
} else if (parseInt(product.metadata.miu) > 0) {
|
||||
miu = parseInt(product.metadata.miu);
|
||||
} else {
|
||||
logger.error({ miu: product.metadata.miu }, "Invalid miu metadata in product");
|
||||
throw new Error("Invalid miu metadata in product");
|
||||
}
|
||||
|
||||
if (product.metadata.projects === "unlimited") {
|
||||
projects = null;
|
||||
} else if (parseInt(product.metadata.projects) > 0) {
|
||||
projects = parseInt(product.metadata.projects);
|
||||
} else {
|
||||
logger.error({ projects: product.metadata.projects }, "Invalid projects metadata in product");
|
||||
throw new Error("Invalid projects metadata in product");
|
||||
}
|
||||
|
||||
const plan = ZOrganizationBillingPlan.parse(product.metadata.plan);
|
||||
|
||||
switch (plan) {
|
||||
case PROJECT_FEATURE_KEYS.FREE:
|
||||
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
|
||||
break;
|
||||
|
||||
case PROJECT_FEATURE_KEYS.STARTUP:
|
||||
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
|
||||
break;
|
||||
|
||||
case PROJECT_FEATURE_KEYS.ENTERPRISE:
|
||||
updatedBillingPlan = PROJECT_FEATURE_KEYS.ENTERPRISE;
|
||||
break;
|
||||
}
|
||||
|
||||
await updateOrganization(organizationId, {
|
||||
billing: {
|
||||
...organization.billing,
|
||||
stripeCustomerId: stripeSubscriptionObject.customer as string,
|
||||
plan: updatedBillingPlan,
|
||||
period,
|
||||
limits: {
|
||||
projects,
|
||||
monthly: {
|
||||
responses,
|
||||
miu,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await stripe.customers.update(stripeSubscriptionObject.customer as string, {
|
||||
name: organization.name,
|
||||
metadata: { organizationId: organization.id },
|
||||
invoice_settings: {
|
||||
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -30,12 +30,4 @@ export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
|
||||
period: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
organizationId,
|
||||
subscriptionId: stripeSubscriptionObject.id,
|
||||
},
|
||||
"Subscription cancelled - downgraded to FREE plan"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ interface PricingCardProps {
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
CUSTOM: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,21 +33,17 @@ export const PricingCard = ({
|
||||
}: PricingCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
|
||||
const displayPrice = (() => {
|
||||
if (plan.id === projectFeatureKeys.CUSTOM) {
|
||||
return plan.price.monthly;
|
||||
}
|
||||
return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
})();
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
|
||||
const isCurrentPlan = useMemo(() => {
|
||||
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) {
|
||||
if (
|
||||
organization.billing.plan === projectFeatureKeys.ENTERPRISE &&
|
||||
plan.id === projectFeatureKeys.ENTERPRISE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -57,7 +53,7 @@ export const PricingCard = ({
|
||||
organization.billing.plan,
|
||||
plan.id,
|
||||
planPeriod,
|
||||
projectFeatureKeys.CUSTOM,
|
||||
projectFeatureKeys.ENTERPRISE,
|
||||
projectFeatureKeys.FREE,
|
||||
]);
|
||||
|
||||
@@ -66,7 +62,7 @@ export const PricingCard = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.id === projectFeatureKeys.CUSTOM) {
|
||||
if (plan.id === projectFeatureKeys.ENTERPRISE) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -101,7 +97,7 @@ export const PricingCard = ({
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setContactModalOpen(true);
|
||||
setUpgradeModalOpen(true);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{t("environments.settings.billing.switch_plan")}
|
||||
@@ -119,7 +115,7 @@ export const PricingCard = ({
|
||||
plan.featured,
|
||||
plan.href,
|
||||
plan.id,
|
||||
projectFeatureKeys.CUSTOM,
|
||||
projectFeatureKeys.ENTERPRISE,
|
||||
projectFeatureKeys.FREE,
|
||||
projectFeatureKeys.STARTUP,
|
||||
t,
|
||||
@@ -155,9 +151,13 @@ export const PricingCard = ({
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-4xl font-bold tracking-tight"
|
||||
)}>
|
||||
{displayPrice}
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE
|
||||
? planPeriod === "monthly"
|
||||
? plan.price.monthly
|
||||
: plan.price.yearly
|
||||
: plan.price.monthly}
|
||||
</p>
|
||||
{plan.id !== projectFeatureKeys.CUSTOM && (
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE && (
|
||||
<div className="text-sm leading-5">
|
||||
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
|
||||
/ {planPeriod === "monthly" ? "Month" : "Year"}
|
||||
@@ -203,13 +203,28 @@ export const PricingCard = ({
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
title="Please reach out to us"
|
||||
open={contactModalOpen}
|
||||
setOpen={setContactModalOpen}
|
||||
onConfirm={() => setContactModalOpen(false)}
|
||||
buttonText="Close"
|
||||
title={t("environments.settings.billing.switch_plan")}
|
||||
buttonText={t("common.confirm")}
|
||||
onConfirm={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
setUpgradeModalOpen(false);
|
||||
}}
|
||||
open={upgradeModalOpen}
|
||||
setOpen={setUpgradeModalOpen}
|
||||
body={t("environments.settings.billing.switch_plan_confirmation_text", {
|
||||
plan: plan.name,
|
||||
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
|
||||
period:
|
||||
planPeriod === "monthly"
|
||||
? t("environments.settings.billing.per_month")
|
||||
: t("environments.settings.billing.per_year"),
|
||||
})}
|
||||
buttonVariant="default"
|
||||
body="To switch your billing rhythm, please reach out to hola@formbricks.com"
|
||||
buttonLoading={loading}
|
||||
closeOnOutsideClick={false}
|
||||
hideCloseButton
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ interface PricingTableProps {
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
CUSTOM: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
hasBillingRights: boolean;
|
||||
}
|
||||
@@ -127,11 +127,11 @@ export const PricingTable = ({
|
||||
};
|
||||
|
||||
const responsesUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null;
|
||||
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.responses === null;
|
||||
const peopleUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null;
|
||||
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.miu === null;
|
||||
const projectsUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.projects === null;
|
||||
organization.billing.plan === "enterprise" && organization.billing.limits.projects === null;
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const UploadContactsAttributeCombobox = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="border-0">
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{keys.map((tag) => {
|
||||
return (
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("License Utils", () => {
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("License Utils", () => {
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -154,17 +154,27 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, accessControl enabled and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, accessControl: true },
|
||||
});
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, accessControl enabled but plan is not CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, accessControl enabled and plan is ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, accessControl: true },
|
||||
});
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, accessControl enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
@@ -174,16 +184,6 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, accessControl: true },
|
||||
});
|
||||
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getAccessControlPermission(mockOrganization.billing.plan);
|
||||
@@ -211,7 +211,7 @@ describe("License Utils", () => {
|
||||
test("should return true if license active and plan is not FREE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -243,17 +243,27 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, multiLanguageSurveys: true },
|
||||
});
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, multiLanguageSurveys enabled but plan is not CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, multiLanguageSurveys: true },
|
||||
});
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, multiLanguageSurveys enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
@@ -263,16 +273,6 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, multiLanguageSurveys: true },
|
||||
});
|
||||
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
|
||||
@@ -420,17 +420,17 @@ describe("License Utils", () => {
|
||||
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true; // reset for other tests
|
||||
});
|
||||
|
||||
test("should return true if license active, feature enabled, and plan is CUSTOM (cloud)", async () => {
|
||||
test("should return true if license active, feature enabled, and plan is SCALE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, spamProtection: true },
|
||||
});
|
||||
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.SCALE);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, feature enabled, but plan is not CUSTOM (cloud)", async () => {
|
||||
test("should return false if license active, feature enabled, but plan is not SCALE or ENTERPRISE (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
|
||||
@@ -111,7 +111,9 @@ export const getIsSpamProtectionEnabled = async (
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return (
|
||||
license.active && !!license.features?.spamProtection && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM
|
||||
license.active &&
|
||||
!!license.features?.spamProtection &&
|
||||
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +122,11 @@ export const getIsSpamProtectionEnabled = async (
|
||||
|
||||
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
|
||||
const license = await getEnterpriseLicense();
|
||||
if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
|
||||
if (IS_FORMBRICKS_CLOUD)
|
||||
return (
|
||||
license.active &&
|
||||
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return license.active;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
OIDC_CLIENT_SECRET,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_ISSUER,
|
||||
OIDC_ISSUER_INTERNAL,
|
||||
OIDC_SIGNING_ALGORITHM,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
@@ -39,7 +40,11 @@ export const getSSOProviders = () => [
|
||||
type: "oauth" as const,
|
||||
clientId: OIDC_CLIENT_ID || "",
|
||||
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" } },
|
||||
idToken: true,
|
||||
client: {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Pull in the mocked implementations to configure them in tests
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
@@ -13,24 +12,12 @@ import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
// Pull in the mocked implementations to configure them in tests
|
||||
import {
|
||||
environmentIdLayoutChecks,
|
||||
getEnvironmentAuth,
|
||||
getEnvironmentLayoutData,
|
||||
getEnvironmentWithRelations,
|
||||
} from "./utils";
|
||||
import { environmentIdLayoutChecks, getEnvironmentAuth } from "./utils";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
@@ -71,8 +58,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
@@ -83,36 +68,12 @@ vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getAccessControlPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/errors", () => ({
|
||||
AuthorizationError: class AuthorizationError extends Error {},
|
||||
DatabaseError: class DatabaseError extends Error {},
|
||||
}));
|
||||
|
||||
describe("utils.ts", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Provide default mocks for successful scenario
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } });
|
||||
vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment);
|
||||
@@ -135,16 +96,6 @@ describe("utils.ts", () => {
|
||||
});
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user123" } as TUser);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "none",
|
||||
} as any);
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(0);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("getEnvironmentAuth", () => {
|
||||
@@ -219,434 +170,4 @@ describe("utils.ts", () => {
|
||||
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironmentWithRelations", () => {
|
||||
const mockPrismaData = {
|
||||
id: "env123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
type: "production" as const,
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight" as const,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
{
|
||||
id: "env123",
|
||||
type: "production" as const,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
{
|
||||
id: "env456",
|
||||
type: "development" as const,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
],
|
||||
organization: {
|
||||
id: "org123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { plan: "free" },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockPrismaData as any);
|
||||
});
|
||||
|
||||
test("returns combined environment, project, organization, and membership data", async () => {
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.environment.id).toBe("env123");
|
||||
expect(result!.environment.type).toBe("production");
|
||||
expect(result!.project.id).toBe("proj123");
|
||||
expect(result!.project.name).toBe("Test Project");
|
||||
expect(result!.organization.id).toBe("org123");
|
||||
expect(result!.organization.name).toBe("Test Organization");
|
||||
expect(result!.environments).toHaveLength(2);
|
||||
expect(result!.membership).toEqual({
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches only current user's membership using database-level filtering", async () => {
|
||||
await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "env123" },
|
||||
select: expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
organization: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
memberships: expect.objectContaining({
|
||||
where: { userId: "user123" },
|
||||
take: 1,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when environment not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null membership when user has no membership", async () => {
|
||||
const dataWithoutMembership = {
|
||||
...mockPrismaData,
|
||||
project: {
|
||||
...mockPrismaData.project,
|
||||
organization: {
|
||||
...mockPrismaData.project.organization,
|
||||
memberships: [], // No memberships
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(dataWithoutMembership as any);
|
||||
|
||||
const result = await getEnvironmentWithRelations("env123", "user123");
|
||||
|
||||
expect(result!.membership).toBeNull();
|
||||
});
|
||||
|
||||
test("throws error on database failure", async () => {
|
||||
// Mock a database error
|
||||
const dbError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.environment.findUnique).mockRejectedValueOnce(dbError);
|
||||
|
||||
// Verify function throws (specific error type depends on Prisma error detection)
|
||||
await expect(getEnvironmentWithRelations("env123", "user123")).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Note: Input validation for environmentId and userId is handled by
|
||||
// getEnvironmentLayoutData (the parent function), not here.
|
||||
// See getEnvironmentLayoutData tests for validation coverage.
|
||||
});
|
||||
|
||||
describe("getEnvironmentLayoutData", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
||||
id: "env123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
{
|
||||
id: "env123",
|
||||
type: "production",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
],
|
||||
organization: {
|
||||
id: "org123",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
userId: "user123",
|
||||
organizationId: "org123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
test("returns complete layout data on success", async () => {
|
||||
const result = await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.session).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.environment).toBeDefined();
|
||||
expect(result.project).toBeDefined();
|
||||
expect(result.organization).toBeDefined();
|
||||
expect(result.environments).toBeDefined();
|
||||
expect(result.membership).toBeDefined();
|
||||
expect(result.isAccessControlAllowed).toBeDefined();
|
||||
expect(result.projectPermission).toBeDefined();
|
||||
expect(result.license).toBeDefined();
|
||||
expect(result.peopleCount).toBe(0);
|
||||
expect(result.responseCount).toBe(0);
|
||||
});
|
||||
|
||||
test("validates environmentId input", async () => {
|
||||
await expect(getEnvironmentLayoutData("", "user123")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("validates userId input", async () => {
|
||||
await expect(getEnvironmentLayoutData("env123", "")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("throws error if session not found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.session_not_found");
|
||||
});
|
||||
|
||||
test("throws error if userId doesn't match session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "different-user" } } as any);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("User ID mismatch");
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.user_not_found");
|
||||
});
|
||||
|
||||
test("throws error if environment data not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user has no environment access", async () => {
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
test("throws error if membership not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
name: "Test Project",
|
||||
organizationId: "org123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org123",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [], // No membership
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
|
||||
"common.membership_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches user before auth check, then environment data after authorization", async () => {
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
// User is fetched first (needed for auth check)
|
||||
expect(getUser).toHaveBeenCalledWith("user123");
|
||||
// Environment data is fetched after authorization passes
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "env123" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches permissions and license data in parallel", async () => {
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(getAccessControlPermission).toHaveBeenCalled();
|
||||
expect(getProjectPermissionByUserId).toHaveBeenCalledWith("user123", "proj123");
|
||||
expect(getEnterpriseLicense).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fetches cloud metrics when IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
// Mock IS_FORMBRICKS_CLOUD to be true
|
||||
const constantsMock = await import("@/lib/constants");
|
||||
vi.mocked(constantsMock).IS_FORMBRICKS_CLOUD = true;
|
||||
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
expect(getMonthlyActiveOrganizationPeopleCount).toHaveBeenCalledWith("org123");
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith("org123");
|
||||
});
|
||||
|
||||
test("caches results per environmentId and userId", async () => {
|
||||
// Call twice with same parameters
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
// Due to React.cache, database should only be queried once
|
||||
// Note: React.cache behavior is per-request in production, but in tests
|
||||
// we can verify the function was called multiple times
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns different data for different environmentIds", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj123",
|
||||
name: "Project 1",
|
||||
organizationId: "org123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org123",
|
||||
name: "Org 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "free", limits: {} },
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user123", organizationId: "org123", role: "owner", accepted: true }],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result1 = await getEnvironmentLayoutData("env123", "user123");
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
|
||||
id: "env456",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "proj456",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "proj456",
|
||||
name: "Project 2",
|
||||
organizationId: "org456",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 7,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
organization: {
|
||||
id: "org456",
|
||||
name: "Org 2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "pro", limits: {} },
|
||||
isAIEnabled: true,
|
||||
whitelabel: true,
|
||||
memberships: [{ userId: "user123", organizationId: "org456", role: "member", accepted: true }],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result2 = await getEnvironmentLayoutData("env456", "user123");
|
||||
|
||||
expect(result1.environment.id).not.toBe(result2.environment.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError, DatabaseError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { TEnvironmentAuth, TEnvironmentLayoutData } from "../types/environment-auth";
|
||||
import { TEnvironmentAuth } from "../types/environment-auth";
|
||||
|
||||
/**
|
||||
* Common utility to fetch environment data and perform authorization checks
|
||||
@@ -115,215 +103,3 @@ export const environmentIdLayoutChecks = async (environmentId: string) => {
|
||||
|
||||
return { t, session, user, organization };
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches environment with related project, organization, environments, and current user's membership
|
||||
* in a single optimized database query.
|
||||
* Returns data with proper types matching TEnvironment, TProject, TOrganization.
|
||||
*
|
||||
* Note: Validation is handled by parent function (getEnvironmentLayoutData)
|
||||
*/
|
||||
export const getEnvironmentWithRelations = reactCache(async (environmentId: string, userId: string) => {
|
||||
try {
|
||||
const data = await prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
// Environment fields
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
type: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
// Project via relation (nested select)
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
languages: true,
|
||||
recontactDays: true,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
// All project environments
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
},
|
||||
// Organization via relation
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
billing: true,
|
||||
isAIEnabled: true,
|
||||
whitelabel: true,
|
||||
// Current user's membership only (filtered at DB level)
|
||||
memberships: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
accepted: true,
|
||||
role: true,
|
||||
},
|
||||
take: 1, // Only need one result
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Extract and return properly typed data
|
||||
return {
|
||||
environment: {
|
||||
id: data.id,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
type: data.type,
|
||||
projectId: data.projectId,
|
||||
appSetupCompleted: data.appSetupCompleted,
|
||||
},
|
||||
project: {
|
||||
id: data.project.id,
|
||||
createdAt: data.project.createdAt,
|
||||
updatedAt: data.project.updatedAt,
|
||||
name: data.project.name,
|
||||
organizationId: data.project.organizationId,
|
||||
languages: data.project.languages,
|
||||
recontactDays: data.project.recontactDays,
|
||||
linkSurveyBranding: data.project.linkSurveyBranding,
|
||||
inAppSurveyBranding: data.project.inAppSurveyBranding,
|
||||
config: data.project.config,
|
||||
placement: data.project.placement,
|
||||
clickOutsideClose: data.project.clickOutsideClose,
|
||||
darkOverlay: data.project.darkOverlay,
|
||||
styling: data.project.styling,
|
||||
logo: data.project.logo,
|
||||
environments: data.project.environments,
|
||||
},
|
||||
organization: {
|
||||
id: data.project.organization.id,
|
||||
createdAt: data.project.organization.createdAt,
|
||||
updatedAt: data.project.organization.updatedAt,
|
||||
name: data.project.organization.name,
|
||||
billing: data.project.organization.billing,
|
||||
isAIEnabled: data.project.organization.isAIEnabled,
|
||||
whitelabel: data.project.organization.whitelabel,
|
||||
},
|
||||
environments: data.project.environments,
|
||||
membership: data.project.organization.memberships[0] || null, // First (and only) membership or null
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting environment with relations");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches all data required for environment layout rendering.
|
||||
* Consolidates multiple queries and eliminates duplicates.
|
||||
* Does NOT fetch switcher data (organizations/projects lists) - those are lazy-loaded.
|
||||
*
|
||||
* Note: userId is included in cache key to make it explicit that results are user-specific,
|
||||
* even though React.cache() is per-request and doesn't leak across users.
|
||||
*/
|
||||
export const getEnvironmentLayoutData = reactCache(
|
||||
async (environmentId: string, userId: string): Promise<TEnvironmentLayoutData> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
validateInputs([userId, ZId]);
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
// Verify userId matches session (safety check)
|
||||
if (session.user.id !== userId) {
|
||||
throw new Error("User ID mismatch with session");
|
||||
}
|
||||
|
||||
// Get user first (lightweight query needed for subsequent checks)
|
||||
const user = await getUser(userId); // 1 DB query
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
// Authorization check before expensive data fetching
|
||||
const hasAccess = await hasUserEnvironmentAccess(userId, environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError(t("common.not_authorized"));
|
||||
}
|
||||
|
||||
const relationData = await getEnvironmentWithRelations(environmentId, userId);
|
||||
if (!relationData) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const { environment, project, organization, environments, membership } = relationData;
|
||||
|
||||
// Validate user's membership was found
|
||||
if (!membership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
|
||||
// Fetch remaining data in parallel
|
||||
const [isAccessControlAllowed, projectPermission, license] = await Promise.all([
|
||||
getAccessControlPermission(organization.billing.plan), // No DB query (logic only)
|
||||
getProjectPermissionByUserId(userId, environment.projectId), // 1 DB query
|
||||
getEnterpriseLicense(), // Externally cached
|
||||
]);
|
||||
|
||||
// Conditional queries for Formbricks Cloud
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
environments,
|
||||
membership,
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { Session } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { TEnvironment, ZEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject, ZProject } from "@formbricks/types/project";
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
// Type for the enterprise license returned by getEnterpriseLicense()
|
||||
type TEnterpriseLicense = {
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
};
|
||||
import { ZEnvironment } from "@formbricks/types/environment";
|
||||
import { ZMembership } from "@formbricks/types/memberships";
|
||||
import { ZOrganization } from "@formbricks/types/organizations";
|
||||
import { ZProject } from "@formbricks/types/project";
|
||||
import { ZUser } from "@formbricks/types/user";
|
||||
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
environment: ZEnvironment,
|
||||
@@ -38,25 +27,3 @@ export const ZEnvironmentAuth = z.object({
|
||||
});
|
||||
|
||||
export type TEnvironmentAuth = z.infer<typeof ZEnvironmentAuth>;
|
||||
|
||||
/**
|
||||
* Complete layout data type for environment pages.
|
||||
* Includes all data needed for layout rendering.
|
||||
*
|
||||
* Note: organizations and projects lists are NOT included - they are lazy-loaded
|
||||
* in switcher dropdowns only when needed.
|
||||
*/
|
||||
export type TEnvironmentLayoutData = {
|
||||
session: Session;
|
||||
user: TUser;
|
||||
environment: TEnvironment;
|
||||
project: TProject; // Current project with full details
|
||||
organization: TOrganization;
|
||||
environments: TEnvironment[]; // All project environments for switcher
|
||||
membership: TMembership;
|
||||
isAccessControlAllowed: boolean;
|
||||
projectPermission: TTeamPermission | null;
|
||||
license: TEnterpriseLicense;
|
||||
peopleCount: number;
|
||||
responseCount: number;
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) =>
|
||||
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="border-0">
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Project } from "@prisma/client";
|
||||
import { isEqual } from "lodash";
|
||||
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
@@ -67,7 +67,6 @@ export const SurveyMenuBar = ({
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const isSuccessfullySavedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
@@ -79,21 +78,9 @@ export const SurveyMenuBar = ({
|
||||
setIsLinkSurvey(localSurvey.type === "link");
|
||||
}, [localSurvey.type]);
|
||||
|
||||
// Reset the successfully saved flag when survey prop updates (page refresh complete)
|
||||
useEffect(() => {
|
||||
if (isSuccessfullySavedRef.current) {
|
||||
isSuccessfullySavedRef.current = false;
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
useEffect(() => {
|
||||
const warningText = t("environments.surveys.edit.unsaved_changes_warning");
|
||||
const handleWindowClose = (e: BeforeUnloadEvent) => {
|
||||
// Skip warning if we just successfully saved
|
||||
if (isSuccessfullySavedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEqual(localSurvey, survey)) {
|
||||
e.preventDefault();
|
||||
return (e.returnValue = warningText);
|
||||
@@ -262,8 +249,6 @@ export const SurveyMenuBar = ({
|
||||
if (updatedSurveyResponse?.data) {
|
||||
setLocalSurvey(updatedSurveyResponse.data);
|
||||
toast.success(t("environments.surveys.edit.changes_saved"));
|
||||
// Set flag to prevent beforeunload warning during router.refresh()
|
||||
isSuccessfullySavedRef.current = true;
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
|
||||
@@ -313,8 +298,6 @@ export const SurveyMenuBar = ({
|
||||
segment,
|
||||
});
|
||||
setIsSurveyPublishing(false);
|
||||
// Set flag to prevent beforeunload warning during navigation
|
||||
isSuccessfullySavedRef.current = true;
|
||||
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -10,8 +10,6 @@ vi.mock("@/lib/constants", async () => {
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
PROJECT_FEATURE_KEYS: {
|
||||
FREE: "free",
|
||||
STARTUP: "startup",
|
||||
CUSTOM: "custom",
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -26,13 +24,8 @@ describe("getSurveyFollowUpsPermission", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for startup plan on Formbricks Cloud", async () => {
|
||||
test("should return true for non-free plan on Formbricks Cloud", async () => {
|
||||
const result = await getSurveyFollowUpsPermission("startup" as TOrganizationBillingPlan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for custom plan on Formbricks Cloud", async () => {
|
||||
const result = await getSurveyFollowUpsPermission("custom" as TOrganizationBillingPlan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@/lib/constants";
|
||||
export const getSurveyFollowUpsPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
// Reset all mocks before each test
|
||||
beforeEach(() => {
|
||||
@@ -183,75 +173,6 @@ describe("Metadata Utils", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
|
||||
type TBasicSurveyMetadata = {
|
||||
@@ -50,9 +48,7 @@ export const getBasicSurveyMetadata = async (
|
||||
const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
|
||||
const titleFromWelcome =
|
||||
welcomeCard?.enabled && welcomeCard.headline
|
||||
? getTextContent(
|
||||
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
|
||||
) || ""
|
||||
? getLocalizedValue(welcomeCard.headline, langCode) || ""
|
||||
: undefined;
|
||||
let title = titleFromMetadata || titleFromWelcome || survey.name;
|
||||
|
||||
|
||||
11
apps/web/modules/survey/link/loading.tsx
Normal file
11
apps/web/modules/survey/link/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
export const LinkSurveyLoading = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex h-1/2 w-3/4 flex-col sm:w-1/2 lg:w-1/4">
|
||||
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
|
||||
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-slate-200 text-slate-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -35,10 +35,9 @@ const BreadcrumbItem = React.forwardRef<
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1",
|
||||
!isHighlighted && "hover:bg-white hover:outline hover:outline-slate-300",
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
|
||||
isActive && "bg-slate-100 outline outline-slate-300",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:bg-red-700 hover:outline-red-800",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -59,14 +60,17 @@ function CommandInput({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input> & { hidden?: boolean }) {
|
||||
return (
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"outline-hidden flex h-9 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div data-slot="command-input-wrapper" className={cn("flex items-center")}>
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,10 +78,7 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden rounded-md border border-slate-300 bg-white",
|
||||
className
|
||||
)}
|
||||
className={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -115,7 +116,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[selected=true]:cursor-pointer data-[selected=true]:bg-slate-100 data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -136,11 +137,11 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const DataTableToolbar = <T,>({
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||
<div className="sticky top-0 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
|
||||
<SelectedRowSettings
|
||||
table={table}
|
||||
|
||||
@@ -265,7 +265,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
||||
<button autoFocus className="sr-only" aria-hidden type="button" />
|
||||
)}
|
||||
|
||||
<CommandList className="border-0 p-1">
|
||||
<CommandList className="p-1">
|
||||
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
|
||||
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -128,8 +128,8 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
</div>
|
||||
{open && selectableOptions.length > 0 && !disabled && (
|
||||
<div className="relative mt-2">
|
||||
<CommandList className="border-0">
|
||||
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandList>
|
||||
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md border bg-white shadow-md outline-none">
|
||||
<CommandGroup className="h-full overflow-auto">
|
||||
{selectableOptions.map((option) => (
|
||||
<CommandItem
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const TagsCombobox = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="border-0">
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tagsToSearch?.map((tag) => {
|
||||
return (
|
||||
|
||||
@@ -5006,7 +5006,7 @@
|
||||
"tags": ["Management API - Response"]
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"example": "{{apiKey}}",
|
||||
@@ -5543,7 +5543,7 @@
|
||||
"tags": ["Management API - Response"]
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"example": "{{apiKey}}",
|
||||
|
||||
@@ -692,7 +692,6 @@ export function Survey({
|
||||
isCurrent={offset === 0}
|
||||
responseData={responseData}
|
||||
variablesData={currentVariables}
|
||||
isPreviewMode={isPreviewMode}
|
||||
fullSizeCards={fullSizeCards}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ interface WelcomeCardProps {
|
||||
responseData: TResponseData;
|
||||
variablesData: TResponseVariables;
|
||||
fullSizeCards: boolean;
|
||||
isPreviewMode?: boolean;
|
||||
}
|
||||
|
||||
function TimerIcon() {
|
||||
@@ -79,7 +78,6 @@ export function WelcomeCard({
|
||||
responseData,
|
||||
variablesData,
|
||||
fullSizeCards,
|
||||
isPreviewMode = false,
|
||||
}: WelcomeCardProps) {
|
||||
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" && !isPreviewMode) {
|
||||
if (isCurrent && survey.type === "link") {
|
||||
document.addEventListener("keydown", handleEnter);
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
@@ -137,8 +134,8 @@ export function WelcomeCard({
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only want to run this effect when isCurrent or isPreviewMode changes
|
||||
}, [isCurrent, isPreviewMode]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only want to run this effect when isCurrent changes
|
||||
}, [isCurrent]);
|
||||
|
||||
return (
|
||||
<ScrollableContainer fullSizeCards={fullSizeCards}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOrganizationBillingPlan = z.enum(["free", "startup", "custom"]);
|
||||
export const ZOrganizationBillingPlan = z.enum(["free", "startup", "scale", "enterprise"]);
|
||||
export type TOrganizationBillingPlan = z.infer<typeof ZOrganizationBillingPlan>;
|
||||
|
||||
export const ZOrganizationBillingPeriod = z.enum(["monthly", "yearly"]);
|
||||
|
||||
@@ -409,7 +409,6 @@ export type TResponseUpdate = z.infer<typeof ZResponseUpdate>;
|
||||
|
||||
export const ZResponseTableData = z.object({
|
||||
responseId: z.string(),
|
||||
singleUseId: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
status: z.string(),
|
||||
verifiedEmail: z.string(),
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_DISPLAY_NAME",
|
||||
"OIDC_ISSUER",
|
||||
"OIDC_ISSUER_INTERNAL",
|
||||
"OIDC_SIGNING_ALGORITHM",
|
||||
"PASSWORD_RESET_DISABLED",
|
||||
"PLAYWRIGHT_CI",
|
||||
|
||||
Reference in New Issue
Block a user