mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 06:30:51 -06:00
Compare commits
15 Commits
cursor/add
...
better-ee-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
256a0ec81a | ||
|
|
58ab40ab8e | ||
|
|
6999abba3b | ||
|
|
9ae66f44ae | ||
|
|
7933d0077a | ||
|
|
cc8289fa33 | ||
|
|
c458051839 | ||
|
|
718a199d5b | ||
|
|
5ab9fdf1e3 | ||
|
|
5741209aa9 | ||
|
|
35d0d8ed54 | ||
|
|
5bce5c0a3b | ||
|
|
c61212964c | ||
|
|
b8d41a6e9b | ||
|
|
eedd5200a4 |
@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
|
||||
|
||||
```typescript
|
||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
||||
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
||||
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
||||
|
||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
||||
|
||||
// HTTP cache headers (seconds)
|
||||
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
||||
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-slate-50">
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
/>
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
@@ -25,11 +25,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<EnvironmentIdBaseLayout>
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface PosthogIdentifyProps {
|
||||
session: Session;
|
||||
user: TUser;
|
||||
environmentId?: string;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
organizationBilling?: TOrganizationBilling;
|
||||
isPosthogEnabled: boolean;
|
||||
}
|
||||
|
||||
export const PosthogIdentify = ({
|
||||
session,
|
||||
user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
organizationBilling,
|
||||
isPosthogEnabled,
|
||||
}: PosthogIdentifyProps) => {
|
||||
const posthog = usePostHog();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPosthogEnabled && session.user && posthog) {
|
||||
posthog.identify(session.user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
if (environmentId) {
|
||||
posthog.group("environment", environmentId, { name: environmentId });
|
||||
}
|
||||
if (organizationId) {
|
||||
posthog.group("organization", organizationId, {
|
||||
name: organizationName,
|
||||
plan: organizationBilling?.plan,
|
||||
responseLimit: organizationBilling?.limits.monthly.responses,
|
||||
miuLimit: organizationBilling?.limits.monthly.miu,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
posthog,
|
||||
session.user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
organizationBilling,
|
||||
user.name,
|
||||
user.email,
|
||||
isPosthogEnabled,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -24,11 +24,7 @@ const EnvLayout = async (props: {
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={layoutData.session}
|
||||
user={layoutData.user}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentIdBaseLayout>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -16,7 +15,6 @@ interface AirtableWrapperProps {
|
||||
airtableArray: TIntegrationItem[];
|
||||
airtableIntegration?: TIntegrationAirtable;
|
||||
surveys: TSurvey[];
|
||||
environment: TEnvironment;
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
@@ -27,7 +25,6 @@ export const AirtableWrapper = ({
|
||||
airtableArray,
|
||||
airtableIntegration,
|
||||
surveys,
|
||||
environment,
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
@@ -48,7 +45,6 @@ export const AirtableWrapper = ({
|
||||
<ManageIntegration
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
airtableIntegration={airtableIntegration}
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -15,12 +14,11 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
environment: TEnvironment;
|
||||
environmentId: string;
|
||||
setIsConnected: (data: boolean) => void;
|
||||
surveys: TSurvey[];
|
||||
@@ -29,7 +27,7 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -132,12 +130,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const Page = async (props) => {
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -60,7 +60,6 @@ export const GoogleSheetWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
@@ -15,10 +14,9 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||
setIsConnected: (v: boolean) => void;
|
||||
@@ -27,7 +25,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
googleSheetIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -90,12 +87,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
@@ -12,11 +11,10 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
notionIntegration: TIntegrationNotion;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -28,7 +26,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
notionIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -101,12 +98,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -64,7 +64,6 @@ export const NotionWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
notionIntegration={notionIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
@@ -12,10 +11,9 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
slackIntegration: TIntegrationSlack;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -29,7 +27,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
slackIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -106,12 +103,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -78,7 +78,6 @@ export const SlackWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
slackIntegration={slackIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -14,7 +13,6 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -23,43 +21,39 @@ const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
if (!organizationId) {
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
|
||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||
|
||||
return (
|
||||
@@ -74,7 +68,6 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { CSSProperties, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ClickableBarSegmentProps {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const ClickableBarSegment = ({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
style,
|
||||
}: ClickableBarSegmentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className={className} style={style} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { SatisfactionSmiley } from "./SatisfactionSmiley";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface NPSSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryNps;
|
||||
@@ -29,9 +30,19 @@ interface NPSSummaryProps {
|
||||
) => void;
|
||||
}
|
||||
|
||||
const calculateNPSOpacity = (rating: number): number => {
|
||||
if (rating <= 6) {
|
||||
return 0.3 + (rating / 6) * 0.3;
|
||||
}
|
||||
if (rating <= 8) {
|
||||
return 0.6 + ((rating - 6) / 2) * 0.2;
|
||||
}
|
||||
return 0.8 + ((rating - 8) / 2) * 0.2;
|
||||
};
|
||||
|
||||
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const applyFilter = (group: string) => {
|
||||
const filters = {
|
||||
@@ -72,28 +83,21 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionSmiley percentage={questionSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("environments.surveys.summary.promoters")}:{" "}
|
||||
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("environments.surveys.summary.promotersTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("environments.surveys.summary.promoters")}:{" "}
|
||||
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.grouped")}
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.individual")}
|
||||
@@ -101,7 +105,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="grouped" className="mt-4">
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
@@ -136,51 +140,47 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((choice) => {
|
||||
// Calculate opacity: 0-6 detractors (low opacity), 7-8 passives (medium), 9-10 promoters (high)
|
||||
const opacity =
|
||||
choice.rating <= 6
|
||||
? 0.3 + (choice.rating / 6) * 0.3 // 0.3 to 0.6
|
||||
: choice.rating <= 8
|
||||
? 0.6 + ((choice.rating - 6) / 2) * 0.2 // 0.6 to 0.8
|
||||
: 0.8 + ((choice.rating - 8) / 2) * 0.2; // 0.8 to 1.0
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((choice) => {
|
||||
const opacity = calculateNPSOpacity(choice.rating);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={choice.rating}
|
||||
className="group flex cursor-pointer flex-col items-center"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
choice.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||
<div
|
||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
||||
style={{
|
||||
height: `${Math.max(choice.percentage, 2)}%`,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
|
||||
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
|
||||
<div className="mb-1 flex items-center space-x-1">
|
||||
<div className="text-base font-medium text-slate-700">{choice.count}</div>
|
||||
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600">
|
||||
{convertFloatToNDecimal(choice.percentage, 1)}%
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={choice.rating}
|
||||
className="group flex cursor-pointer flex-col items-center"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
choice.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||
<div
|
||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
||||
style={{
|
||||
height: `${Math.max(choice.percentage, 2)}%`,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
|
||||
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
|
||||
<div className="mb-1 flex items-center space-x-1">
|
||||
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
|
||||
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
|
||||
{convertFloatToNDecimal(choice.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ export const QuestionSummaryHeader = ({
|
||||
{t("environments.surveys.edit.optional")}
|
||||
</div>
|
||||
)}
|
||||
<IdBadge id={questionSummary.question.id} />
|
||||
</div>
|
||||
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
|
||||
interface RatingScaleLegendProps {
|
||||
scale: TSurveyRatingQuestion["scale"];
|
||||
range: number;
|
||||
}
|
||||
|
||||
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
|
||||
return (
|
||||
<div className="mt-3 flex w-full items-start justify-between px-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
|
||||
<span className="text-xs text-slate-500">1</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-slate-500">{range}</span>
|
||||
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,9 +14,11 @@ import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { SatisfactionSmiley } from "./SatisfactionSmiley";
|
||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface RatingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRating;
|
||||
@@ -32,7 +34,7 @@ interface RatingSummaryProps {
|
||||
|
||||
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = questionSummary.question.scale;
|
||||
@@ -54,29 +56,23 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionSmiley percentage={questionSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
|
||||
{t("environments.surveys.summary.satisfied")}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("environments.surveys.summary.csatTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
|
||||
{t("environments.surveys.summary.satisfied")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.grouped")}
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.individual")}
|
||||
@@ -84,53 +80,58 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="grouped" className="mt-4">
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
{questionSummary.responseCount === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.no_responses_found")}
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.no_responses_found")}
|
||||
</p>
|
||||
</div>
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||
{questionSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
// Calculate opacity based on rating position (higher rating = higher opacity)
|
||||
const range = questionSummary.question.range;
|
||||
const opacity = 0.3 + (result.rating / range) * 0.7; // Range from 30% to 100%
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||
{questionSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={result.rating}
|
||||
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight:
|
||||
index < questionSummary.choices.length - 1
|
||||
? "1px solid rgb(226, 232, 240)"
|
||||
: "none",
|
||||
}}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`h-full ${index === 0 ? "rounded-tl-lg" : ""} ${
|
||||
index === questionSummary.choices.length - 1 ? "rounded-tr-lg" : ""
|
||||
} bg-brand-dark`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
const range = questionSummary.question.range;
|
||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === questionSummary.choices.length - 1;
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={result.rating}
|
||||
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
|
||||
}}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
||||
{questionSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
@@ -152,6 +153,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={false}
|
||||
variant="aggregated"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-600">
|
||||
@@ -161,13 +163,10 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{questionSummary.question.scale === "star" && (
|
||||
<div className="mt-3 flex w-full items-center justify-center space-x-3 px-1">
|
||||
<StarIcon className="h-6 w-6 text-slate-300" />
|
||||
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
|
||||
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -177,49 +176,44 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={result.rating}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={questionSummary.question.isColorCodingEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
<div key={result.rating}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={questionSummary.question.isColorCodingEnabled}
|
||||
variant="individual"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{questionSummary.question.scale === "star" && (
|
||||
<div className="mt-5 flex w-full items-center justify-center space-x-3 px-1">
|
||||
<StarIcon className="h-6 w-6 text-slate-300" />
|
||||
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
|
||||
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
interface SatisfactionIndicatorProps {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
|
||||
let colorClass = "";
|
||||
|
||||
if (percentage > 80) {
|
||||
colorClass = "bg-emerald-500";
|
||||
} else if (percentage >= 55) {
|
||||
colorClass = "bg-orange-500";
|
||||
} else {
|
||||
colorClass = "bg-rose-500";
|
||||
}
|
||||
|
||||
return <div className={`h-3 w-3 rounded-full ${colorClass}`} />;
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
interface SatisfactionSmileyProps {
|
||||
percentage: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SatisfactionSmiley = ({ percentage, className }: SatisfactionSmileyProps) => {
|
||||
let colorClass = "";
|
||||
|
||||
if (percentage > 80) {
|
||||
colorClass = "bg-emerald-500";
|
||||
} else if (percentage >= 55) {
|
||||
colorClass = "bg-orange-500";
|
||||
} else {
|
||||
colorClass = "bg-rose-500";
|
||||
}
|
||||
|
||||
return <div className={`h-3 w-3 rounded-full ${colorClass} ${className || ""}`} />;
|
||||
};
|
||||
@@ -3,9 +3,14 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
@@ -29,7 +34,7 @@ import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/
|
||||
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
import { AddressSummary } from "./AddressSummary";
|
||||
|
||||
@@ -54,7 +59,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||
const value = {
|
||||
id: questionId,
|
||||
label: getLocalizedValue(label, "default"),
|
||||
label: getTextContent(getLocalizedValue(label, "default")),
|
||||
questionType: questionType,
|
||||
type: OptionsType.QUESTIONS,
|
||||
};
|
||||
@@ -103,12 +108,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
) : summary.length === 0 ? (
|
||||
<SkeletonLoader type="summary" />
|
||||
) : responseCount === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||
/>
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
|
||||
@@ -29,7 +29,6 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -48,7 +47,6 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
@@ -169,7 +167,7 @@ export const SurveyAnalysisCTA = ({
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
|
||||
@@ -2353,14 +2353,61 @@ describe("NPS question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "nps-q1": 0 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "nps-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "nps-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r4", data: { "nps-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r5", data: { "nps-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "nps-q1": 0 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "nps-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "nps-q1": 7 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
data: { "nps-q1": 9 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r5",
|
||||
data: { "nps-q1": 10 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2413,7 +2460,9 @@ describe("NPS question type tests", () => {
|
||||
|
||||
const responses: any[] = [];
|
||||
|
||||
const dropOff = [{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2670,12 +2719,41 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2705,12 +2783,41 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2740,12 +2847,41 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2775,12 +2911,41 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 6 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2810,12 +2975,41 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 6 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 7 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2845,13 +3039,51 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 8 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "rating-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r4", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 8 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 9 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 10 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
data: { "rating-q1": 7 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2881,11 +3113,31 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2915,12 +3167,41 @@ describe("Rating question type tests", () => {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{ id: "r1", data: { "rating-q1": 1 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 1 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
@@ -2951,7 +3232,9 @@ describe("Rating question type tests", () => {
|
||||
|
||||
const responses: any[] = [];
|
||||
|
||||
const dropOff = [{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
|
||||
@@ -532,7 +532,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
rating: parseInt(label),
|
||||
rating: Number.parseInt(label),
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
@@ -616,7 +616,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
// Build choices array with individual score breakdown
|
||||
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
|
||||
rating: parseInt(rating),
|
||||
rating: Number.parseInt(rating),
|
||||
count,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
|
||||
}));
|
||||
|
||||
@@ -70,7 +70,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Suspense } from "react";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
@@ -21,20 +18,9 @@ const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
<Suspense>
|
||||
<PostHogPageview
|
||||
posthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
postHogApiHost={POSTHOG_API_HOST}
|
||||
postHogApiKey={POSTHOG_API_KEY}
|
||||
/>
|
||||
</Suspense>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<>
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
</PHProvider>
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Organization } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
|
||||
export const handleBillingLimitsCheck = async (
|
||||
environmentId: string,
|
||||
organizationId: string,
|
||||
organizationBilling: Organization["billing"]
|
||||
): Promise<void> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return;
|
||||
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
const responsesLimit = organizationBilling.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organizationBilling.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
|
||||
@@ -58,19 +54,7 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: { responses: monthlyResponseLimit, miu: null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
|
||||
}
|
||||
}
|
||||
// Limit check completed
|
||||
|
||||
return isLimitReached;
|
||||
};
|
||||
@@ -111,10 +95,7 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
||||
}
|
||||
|
||||
// check organization subscriptions and response limits
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
|
||||
@@ -8,16 +8,11 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
withCache: vi.fn(),
|
||||
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
IS_PRODUCTION: true,
|
||||
IS_POSTHOG_CONFIGURED: false,
|
||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||
}));
|
||||
|
||||
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
|
||||
expect(result.data).toEqual(expectedData);
|
||||
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
||||
expect(prisma.environment.update).not.toHaveBeenCalled();
|
||||
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if environment not found", async () => {
|
||||
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
|
||||
expect(result.data).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(result.data.surveys).toEqual([]);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: mockOrganization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: mockOrganization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
|
||||
@@ -256,21 +237,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(result.data.surveys).toEqual(mockSurveys);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle error when sending Posthog limit reached event", async () => {
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("Posthog failed");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
expect(result.data.surveys).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
|
||||
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
|
||||
@@ -313,7 +279,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
// Should return surveys even with high count since limit is null (unlimited)
|
||||
expect(result.data.surveys).toEqual(mockSurveys);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should propagate database update errors", async () => {
|
||||
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
|
||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
|
||||
});
|
||||
|
||||
test("should propagate PostHog event capture errors", async () => {
|
||||
const incompleteEnvironmentData = {
|
||||
...mockEnvironmentStateData,
|
||||
environment: {
|
||||
...mockEnvironmentStateData.environment,
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
|
||||
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
|
||||
|
||||
// Should throw error since Promise.all will fail if PostHog event capture fails
|
||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
|
||||
});
|
||||
|
||||
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import "server-only";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { getEnvironmentStateData } from "./data";
|
||||
|
||||
/**
|
||||
@@ -33,13 +28,10 @@ export const getEnvironmentState = async (
|
||||
// Handle app setup completion update if needed
|
||||
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
prisma.environment.update({
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
}),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
await prisma.environment.update({
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Check monthly response limits for Formbricks Cloud
|
||||
@@ -50,23 +42,7 @@ export const getEnvironmentState = async (
|
||||
isMonthlyResponsesLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
// Send plan limits event if needed
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
// Limit check completed
|
||||
}
|
||||
|
||||
// Build the response data
|
||||
|
||||
@@ -70,12 +70,12 @@ export const GET = withV1ApiWrapper({
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
// Cache headers aligned with Redis cache TTL (1 minute)
|
||||
// max-age=60: 1min browser cache
|
||||
// s-maxage=60: 1min Cloudflare CDN cache
|
||||
// stale-while-revalidate=60: 1min stale serving during revalidation
|
||||
// stale-if-error=60: 1min stale serving on origin errors
|
||||
"public, s-maxage=60, max-age=60, stale-while-revalidate=60, stale-if-error=60"
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||
@@ -28,18 +27,10 @@ vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/utils", () => ({
|
||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telemetry", () => ({
|
||||
captureTelemetry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
@@ -145,26 +136,6 @@ describe("createResponse", () => {
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: "free",
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
@@ -186,20 +157,6 @@ describe("createResponse", () => {
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("PostHog error");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation", () => {
|
||||
|
||||
@@ -6,11 +6,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -83,7 +81,6 @@ export const createResponse = async (
|
||||
tx: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -121,8 +118,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -172,11 +171,6 @@ export const POST = withV1ApiWrapper({
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: responseData.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -96,9 +95,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
@@ -118,10 +114,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/service");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -234,10 +228,9 @@ describe("Response Lib Tests", () => {
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should check response limit and not send event if limit not reached", async () => {
|
||||
test("should check response limit if limit not reached", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
@@ -251,32 +244,6 @@ describe("Response Lib Tests", () => {
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
const posthogError = new Error("Posthog error");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
// Expecting successful response creation despite PostHog error
|
||||
const response = await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
expect(response).toEqual(mockResponse); // Should still return the created response
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,14 +8,12 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -93,7 +91,6 @@ export const createResponse = async (
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -131,8 +128,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContact } from "./contact";
|
||||
@@ -49,9 +47,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -166,9 +162,7 @@ describe("createResponse V2", () => {
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: null,
|
||||
@@ -183,26 +177,6 @@ describe("createResponse V2", () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: "free",
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
@@ -225,20 +199,6 @@ describe("createResponse V2", () => {
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("PostHog error");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
await createResponse(mockResponseInput, mockTx); // Should not throw
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
|
||||
test("should correctly map prisma tags to response tags", async () => {
|
||||
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
||||
const prismaResponseWithTags = {
|
||||
|
||||
@@ -6,12 +6,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContact } from "./contact";
|
||||
@@ -91,7 +89,6 @@ export const createResponse = async (
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -129,8 +126,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -148,11 +147,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(environmentId, "response created", {
|
||||
surveyId: responseData.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
|
||||
@@ -126,6 +126,7 @@ checksums:
|
||||
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
|
||||
common/clear_selection: af5d720527735d4253e289400d29ec9e
|
||||
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||
common/click_to_filter: 527714113ca5fd3504e7d0bd31bca303
|
||||
common/clicks: f9e154545f87d8ede27b529e5fdf2015
|
||||
common/close: 2c2e22f8424a1031de89063bd0022e16
|
||||
common/code: 343bc5386149b97cece2b093c39034b2
|
||||
@@ -183,6 +184,7 @@ checksums:
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
@@ -191,6 +193,7 @@ checksums:
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
|
||||
common/general: b891e8f15579fc5d97bcaf3637f5ae59
|
||||
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
|
||||
common/go_back: b917ea82facb90c88c523b255d29f84b
|
||||
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
|
||||
common/hidden: fa290c6ada5869d744ed35e9cca64699
|
||||
@@ -492,6 +495,7 @@ checksums:
|
||||
environments/actions/add_css_class_or_id: cfc4d88412c5b9ef1157e28db4afdcc5
|
||||
environments/actions/add_regular_expression_here: 797fde3681996b85bc63c3550dec1fd4
|
||||
environments/actions/add_url: 8eba7972136a42da78a8fa4798da8e87
|
||||
environments/actions/and: 53e8eb67a396fcb5e419bb4cbf0008df
|
||||
environments/actions/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||
environments/actions/contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/actions/create_action: 3abcc6dbbca18d3218ba49f90c4a66fd
|
||||
@@ -522,6 +526,7 @@ checksums:
|
||||
environments/actions/limit_to_specific_pages: f8ba95b2fc68d965689594b8a545417c
|
||||
environments/actions/matches_regex: 208b4d02b38714b4523923239e4a66b0
|
||||
environments/actions/on_all_pages: ccb8ee531a55e21eb8157c36fa75ad9a
|
||||
environments/actions/or: 0208d355f231c386b19390f0bea41b95
|
||||
environments/actions/page_filter: fe98a0bcbedb938e58cc3730589caa95
|
||||
environments/actions/page_view: 019c12b6739f6f7b1500f96ee275d47c
|
||||
environments/actions/select_match_type: b555dce1cb5c61538d3fbd792b2c71a2
|
||||
@@ -558,9 +563,18 @@ checksums:
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616
|
||||
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
|
||||
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
|
||||
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
|
||||
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
|
||||
environments/contacts/please_select_a_survey: 465aa7048773079c8ffdde8b333b78eb
|
||||
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
@@ -723,8 +737,8 @@ checksums:
|
||||
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
|
||||
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
|
||||
environments/project/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
|
||||
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
|
||||
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
|
||||
environments/project/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
|
||||
environments/project/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
|
||||
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
|
||||
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
|
||||
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
@@ -809,7 +823,6 @@ checksums:
|
||||
environments/project/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
|
||||
environments/project/tags/count: 9c5848662eb8024ddf360f7e4001a968
|
||||
environments/project/tags/delete_tag_confirmation: a9fb98064cd156242899643f3d2ef032
|
||||
environments/project/tags/empty_message: da71bd7c7b5bf634469d20e010d25503
|
||||
environments/project/tags/manage_tags: 2761d558b82b6104befbc240ae2379c6
|
||||
environments/project/tags/manage_tags_description: ce7cc42da3646fba960502d7e4e49cd2
|
||||
environments/project/tags/merge: 95051c859b8778be51226b43be6f1075
|
||||
@@ -1598,7 +1611,7 @@ checksums:
|
||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||
environments/surveys/responses/person_attributes: 8f7f8a9040ce8efb3cb54ce33b590866
|
||||
environments/surveys/responses/person_attributes: 07ae67ae73d7a2a7c67008694a83f0a3
|
||||
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
|
||||
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
|
||||
@@ -1693,6 +1706,7 @@ checksums:
|
||||
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
|
||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: 5bddf0d4f771efd06d58441d11fa5091
|
||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
|
||||
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
|
||||
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
|
||||
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
|
||||
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
|
||||
@@ -1716,7 +1730,6 @@ checksums:
|
||||
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
|
||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||
environments/surveys/summary/go_to_setup_checklist: d70bd018d651d01c41ae10370e71d0be
|
||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||
@@ -1748,7 +1761,7 @@ checksums:
|
||||
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
|
||||
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
|
||||
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
|
||||
environments/surveys/summary/install_widget: 55d403de32e3d0da7513ab199f1d1934
|
||||
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
|
||||
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
|
||||
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
|
||||
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
|
||||
@@ -1761,6 +1774,7 @@ checksums:
|
||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
environments/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
|
||||
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
|
||||
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
|
||||
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
|
||||
@@ -1770,6 +1784,7 @@ checksums:
|
||||
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
|
||||
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
|
||||
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
|
||||
environments/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
|
||||
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
|
||||
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
|
||||
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||
@@ -1785,7 +1800,6 @@ checksums:
|
||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||
environments/surveys/summary/waiting_for_response: 0194a84e0850b8e98435632d5331a916
|
||||
environments/surveys/summary/whats_next: d920145bfa2147014062f6f2d1d451a4
|
||||
environments/surveys/summary/your_survey_is_public: 3f5cb5949a5f4020a3d4d74fdfc95e83
|
||||
environments/surveys/summary/youre_not_plugged_in_yet: 9217467742cdcf7edf8d59cc1472ede6
|
||||
|
||||
@@ -218,10 +218,6 @@ export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
|
||||
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
|
||||
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
|
||||
|
||||
export const POSTHOG_API_KEY = env.POSTHOG_API_KEY;
|
||||
export const POSTHOG_API_HOST = env.POSTHOG_API_HOST;
|
||||
export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST);
|
||||
|
||||
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||
|
||||
@@ -59,8 +59,6 @@ export const env = createEnv({
|
||||
? z.string().optional()
|
||||
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
|
||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
POSTHOG_API_HOST: z.string().optional(),
|
||||
POSTHOG_API_KEY: z.string().optional(),
|
||||
PRIVACY_URL: z
|
||||
.string()
|
||||
.url()
|
||||
@@ -103,7 +101,6 @@ export const env = createEnv({
|
||||
}
|
||||
)
|
||||
.optional(),
|
||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
TERMS_URL: z
|
||||
.string()
|
||||
.url()
|
||||
@@ -172,8 +169,6 @@ export const env = createEnv({
|
||||
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
|
||||
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
|
||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -206,7 +201,6 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@formbricks/types/environment";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getOrganizationsByUserId } from "../organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { getUserProjects } from "../project/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -173,10 +172,6 @@ export const createEnvironment = async (
|
||||
},
|
||||
});
|
||||
|
||||
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return environment;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_POSTHOG_CONFIGURED, IS_PRODUCTION, POSTHOG_API_HOST, POSTHOG_API_KEY } from "./constants";
|
||||
|
||||
const enabled = IS_PRODUCTION && IS_POSTHOG_CONFIGURED;
|
||||
|
||||
export const capturePosthogEnvironmentEvent = async (
|
||||
environmentId: string,
|
||||
eventName: string,
|
||||
properties: any = {}
|
||||
) => {
|
||||
if (!enabled || typeof POSTHOG_API_HOST !== "string" || typeof POSTHOG_API_KEY !== "string") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = new PostHog(POSTHOG_API_KEY, {
|
||||
host: POSTHOG_API_HOST,
|
||||
});
|
||||
client.capture({
|
||||
// workaround with a static string as exaplained in PostHog docs: https://posthog.com/docs/product-analytics/group-analytics
|
||||
distinctId: "environmentEvents",
|
||||
event: eventName,
|
||||
groups: { environment: environmentId },
|
||||
properties,
|
||||
});
|
||||
await client.shutdown();
|
||||
} catch (error) {
|
||||
logger.error(error, "error sending posthog event");
|
||||
}
|
||||
};
|
||||
|
||||
export const sendPlanLimitsReachedEventToPosthogWeekly = async (
|
||||
environmentId: string,
|
||||
billing: {
|
||||
plan: TOrganizationBillingPlan;
|
||||
limits: TOrganizationBillingPlanLimits;
|
||||
}
|
||||
) =>
|
||||
await cache.withCache(
|
||||
async () => {
|
||||
try {
|
||||
await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", {
|
||||
...billing,
|
||||
});
|
||||
return "success";
|
||||
} catch (error) {
|
||||
logger.error(error, "error sending plan limits reached event to posthog weekly");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createCacheKey.custom("analytics", environmentId, `plan_limits_${billing.plan}`),
|
||||
60 * 60 * 24 * 7 * 1000 // 7 days in milliseconds
|
||||
);
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getOrganizationByEnvironmentId,
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { evaluateLogic } from "@/lib/surveyLogic/utils";
|
||||
import {
|
||||
mockActionClass,
|
||||
@@ -44,11 +43,6 @@ vi.mock("@/lib/organization/service", () => ({
|
||||
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock posthogServer
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
capturePosthogEnvironmentEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock actionClass service
|
||||
vi.mock("@/lib/actionClass/service", () => ({
|
||||
getActionClasses: vi.fn(),
|
||||
@@ -646,7 +640,6 @@ describe("Tests for createSurvey", () => {
|
||||
expect(prisma.survey.create).toHaveBeenCalled();
|
||||
expect(result.name).toEqual(mockSurveyOutput.name);
|
||||
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
|
||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates a private segment for app surveys", async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getActionClasses } from "../actionClass/service";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
|
||||
@@ -673,11 +672,6 @@ export const createSurvey = async (
|
||||
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
||||
surveyId: survey.id,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return transformedSurvey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/* We use this telemetry service to better understand how Formbricks is being used
|
||||
and how we can improve it. All data including the IP address is collected anonymously
|
||||
and we cannot trace anything back to you or your customers. If you still want to
|
||||
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION } from "./constants";
|
||||
import { env } from "./env";
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
// We are using the hashed CRON_SECRET as the distinct identifier for the instance for telemetry.
|
||||
// The hash cannot be traced back to the original value or the instance itself.
|
||||
// This is to ensure that the telemetry data is anonymous but still unique to the instance.
|
||||
const getTelemetryId = (): string => {
|
||||
return crypto.createHash("sha256").update(env.CRON_SECRET).digest("hex");
|
||||
};
|
||||
|
||||
export const captureTelemetry = async (eventName: string, properties = {}) => {
|
||||
if (env.TELEMETRY_DISABLED !== "1" && IS_PRODUCTION) {
|
||||
try {
|
||||
await fetch("https://telemetry.formbricks.com/capture/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret
|
||||
event: eventName,
|
||||
properties: {
|
||||
distinct_id: getTelemetryId(),
|
||||
...properties,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, "error sending telemetry");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Filter löschen",
|
||||
"clear_selection": "Auswahl aufheben",
|
||||
"click": "Klick",
|
||||
"click_to_filter": "Klicken zum Filtern",
|
||||
"clicks": "Klicks",
|
||||
"close": "Schließen",
|
||||
"code": "Code",
|
||||
@@ -210,6 +211,7 @@
|
||||
"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_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_projects": "Fehler beim Laden der Projekte",
|
||||
"finish": "Fertigstellen",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Name",
|
||||
"gathering_responses": "Antworten sammeln",
|
||||
"general": "Allgemein",
|
||||
"generate": "Generieren",
|
||||
"go_back": "Geh zurück",
|
||||
"go_to_dashboard": "Zum Dashboard gehen",
|
||||
"hidden": "Versteckt",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "CSS-Klasse oder ID hinzufügen",
|
||||
"add_regular_expression_here": "Fügen Sie hier einen regulären Ausdruck hinzu",
|
||||
"add_url": "URL hinzufügen",
|
||||
"and": "UND",
|
||||
"click": "Klicken",
|
||||
"contains": "enthält",
|
||||
"create_action": "Aktion erstellen",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Auf bestimmte Seiten beschränken",
|
||||
"matches_regex": "Entspricht Regex",
|
||||
"on_all_pages": "Auf allen Seiten",
|
||||
"or": "ODER",
|
||||
"page_filter": "Seitenfilter",
|
||||
"page_view": "Seitenansicht",
|
||||
"select_match_type": "Wähle den Spieltyp aus",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
|
||||
"generate_personal_link": "Persönlichen Link generieren",
|
||||
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
|
||||
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
|
||||
"no_published_surveys": "Keine veröffentlichten Umfragen",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"not_provided": "Nicht angegeben",
|
||||
"personal_link_generated": "Persönlicher Link erfolgreich generiert",
|
||||
"personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}",
|
||||
"personal_survey_link": "Link zur persönlichen Umfrage",
|
||||
"please_select_a_survey": "Bitte wähle eine Umfrage aus",
|
||||
"search_contact": "Kontakt suchen",
|
||||
"select_a_survey": "Wähle eine Umfrage aus",
|
||||
"select_attribute": "Attribut auswählen",
|
||||
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
|
||||
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "App-Verbindung",
|
||||
"app_connection_description": "Verbinde deine App oder Website mit Formbricks.",
|
||||
"cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 5 Minuten dauern, bis diese Änderungen in deiner lokalen App, die das Formbricks SDK verwendet, angezeigt werden. Diese Verzögerung ist auf eine Einschränkung unseres aktuellen Caching-Systems zurückzuführen. Wir arbeiten aktiv an einer Überarbeitung des Cache und werden in Formbricks 4.0 eine Lösung veröffentlichen.",
|
||||
"cache_update_delay_title": "Änderungen werden aufgrund von Caching nach 5 Minuten angezeigt",
|
||||
"cache_update_delay_description": "Wenn Sie Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornehmen, kann es bis zu 1 Minute dauern, bis diese Änderungen in Ihrer lokalen App, die das Formbricks SDK ausführt, erscheinen.",
|
||||
"cache_update_delay_title": "Änderungen werden aufgrund von Caching nach etwa 1 Minute angezeigt",
|
||||
"environment_id": "Deine Umgebungs-ID",
|
||||
"environment_id_description": "Diese ID identifiziert eindeutig diese Formbricks Umgebung.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "Tag hinzufügen",
|
||||
"count": "zählen",
|
||||
"delete_tag_confirmation": "Bist Du sicher, dass Du diesen Tag löschen möchtest?",
|
||||
"empty_message": "Markiere eine Antwort, um deine Liste der Tags hier zu finden.",
|
||||
"manage_tags": "Tags verwalten",
|
||||
"manage_tags_description": "Zusammenführen und Antwort-Tags entfernen.",
|
||||
"merge": "Zusammenführen",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Nachname",
|
||||
"not_completed": "Nicht abgeschlossen ⏳",
|
||||
"os": "Betriebssystem",
|
||||
"person_attributes": "Personenattribute",
|
||||
"person_attributes": "Personenattribute zum Zeitpunkt der Einreichung",
|
||||
"phone": "Telefon",
|
||||
"respondent_skipped_questions": "Der Befragte hat diese Fragen übersprungen.",
|
||||
"response_deleted_successfully": "Antwort erfolgreich gelöscht.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Filter hinzugefügt für Antworten, bei denen die Antwort auf Frage {questionIdx} {filterComboBoxValue} - {filterValue} ist",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filter hinzugefügt für Antworten, bei denen die Frage {questionIdx} übersprungen wurde",
|
||||
"aggregated": "Aggregiert",
|
||||
"all_responses_csv": "Alle Antworten (CSV)",
|
||||
"all_responses_excel": "Alle Antworten (Excel)",
|
||||
"all_time": "Gesamt",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
|
||||
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
|
||||
"generating_qr_code": "QR-Code wird generiert",
|
||||
"go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste 👉",
|
||||
"impressions": "Eindrücke",
|
||||
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "Beinhaltet alles",
|
||||
"includes_either": "Beinhaltet entweder",
|
||||
"install_widget": "Formbricks Widget installieren",
|
||||
"individual": "Individuell",
|
||||
"is_equal_to": "Ist gleich",
|
||||
"is_less_than": "ist weniger als",
|
||||
"last_30_days": "Letzte 30 Tage",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
"promoters": "Promotoren",
|
||||
"qr_code": "QR-Code",
|
||||
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
|
||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
|
||||
"reset_survey": "Umfrage zurücksetzen",
|
||||
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
|
||||
"satisfied": "Zufrieden",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
"waiting_for_response": "Warte auf eine Antwort 🧘♂️",
|
||||
"whats_next": "Was kommt als Nächstes?",
|
||||
"your_survey_is_public": "Deine Umfrage ist öffentlich",
|
||||
"youre_not_plugged_in_yet": "Du bist noch nicht verbunden!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Clear filters",
|
||||
"clear_selection": "Clear selection",
|
||||
"click": "Click",
|
||||
"click_to_filter": "Click to filter",
|
||||
"clicks": "Clicks",
|
||||
"close": "Close",
|
||||
"code": "Code",
|
||||
@@ -210,6 +211,7 @@
|
||||
"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_copy_to_clipboard": "Failed to copy to clipboard",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_projects": "Failed to load projects",
|
||||
"finish": "Finish",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Full name",
|
||||
"gathering_responses": "Gathering responses",
|
||||
"general": "General",
|
||||
"generate": "Generate",
|
||||
"go_back": "Go Back",
|
||||
"go_to_dashboard": "Go to Dashboard",
|
||||
"hidden": "Hidden",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "Add CSS class or id",
|
||||
"add_regular_expression_here": "Add a regular expression here",
|
||||
"add_url": "Add URL",
|
||||
"and": "AND",
|
||||
"click": "Click",
|
||||
"contains": "Contains",
|
||||
"create_action": "Create action",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Limit to specific pages",
|
||||
"matches_regex": "Matches regex",
|
||||
"on_all_pages": "On all pages",
|
||||
"or": "OR",
|
||||
"page_filter": "Page filter",
|
||||
"page_view": "Page View",
|
||||
"select_match_type": "Select match type",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
"generate_personal_link": "Generate Personal Link",
|
||||
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
|
||||
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
|
||||
"no_published_surveys": "No published surveys",
|
||||
"no_responses_found": "No responses found",
|
||||
"not_provided": "Not provided",
|
||||
"personal_link_generated": "Personal link generated successfully",
|
||||
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
|
||||
"personal_survey_link": "Personal Survey Link",
|
||||
"please_select_a_survey": "Please select a survey",
|
||||
"search_contact": "Search contact",
|
||||
"select_a_survey": "Select a survey",
|
||||
"select_attribute": "Select Attribute",
|
||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "App Connection",
|
||||
"app_connection_description": "Connect your app or website to Formbricks.",
|
||||
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 5 minutes for those changes to appear in your local app running the Formbricks SDK. This delay is due to a limitation in our current caching system. We’re actively reworking the cache and will release a fix in Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Changes will be reflected after 5 minutes due to caching",
|
||||
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 1 minute for those changes to appear in your local app running the Formbricks SDK.",
|
||||
"cache_update_delay_title": "Changes will be reflected after ~1 minute due to caching",
|
||||
"environment_id": "Your Environment ID",
|
||||
"environment_id_description": "This id uniquely identifies this Formbricks environment.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK is connected",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "Add Tag",
|
||||
"count": "Count",
|
||||
"delete_tag_confirmation": "Are you sure you want to delete this tag?",
|
||||
"empty_message": "Tag a submission to find your list of tags here.",
|
||||
"manage_tags": "Manage Tags",
|
||||
"manage_tags_description": "Merge and remove response tags.",
|
||||
"merge": "Merge",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Last Name",
|
||||
"not_completed": "Not Completed ⏳",
|
||||
"os": "OS",
|
||||
"person_attributes": "Person attributes",
|
||||
"person_attributes": "Person attributes at time of submission",
|
||||
"phone": "Phone",
|
||||
"respondent_skipped_questions": "Respondent skipped these questions.",
|
||||
"response_deleted_successfully": "Response deleted successfully.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Added filter for responses where answer to question {questionIdx} is {filterComboBoxValue} - {filterValue} ",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Added filter for responses where answer to question {questionIdx} is skipped",
|
||||
"aggregated": "Aggregated",
|
||||
"all_responses_csv": "All responses (CSV)",
|
||||
"all_responses_excel": "All responses (Excel)",
|
||||
"all_time": "All time",
|
||||
@@ -1812,7 +1826,6 @@
|
||||
"configure_alerts": "Configure alerts",
|
||||
"congrats": "Congrats! Your survey is live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
||||
"csatTooltip": "Customer Satisfaction Score measures the percentage of respondents who gave top ratings (based on the rating scale).",
|
||||
"current_count": "Current count",
|
||||
"custom_range": "Custom range...",
|
||||
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
|
||||
@@ -1826,8 +1839,6 @@
|
||||
"filtered_responses_csv": "Filtered responses (CSV)",
|
||||
"filtered_responses_excel": "Filtered responses (Excel)",
|
||||
"generating_qr_code": "Generating QR code",
|
||||
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
|
||||
"grouped": "Grouped",
|
||||
"impressions": "Impressions",
|
||||
"impressions_tooltip": "Number of times the survey has been viewed.",
|
||||
"in_app": {
|
||||
@@ -1862,7 +1873,6 @@
|
||||
"includes_all": "Includes all",
|
||||
"includes_either": "Includes either",
|
||||
"individual": "Individual",
|
||||
"install_widget": "Install Formbricks Widget",
|
||||
"is_equal_to": "Is equal to",
|
||||
"is_less_than": "Is less than",
|
||||
"last_30_days": "Last 30 days",
|
||||
@@ -1876,7 +1886,6 @@
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"promoters": "Promoters",
|
||||
"promotersTooltip": "Percentage of respondents who scored 9-10, indicating high likelihood to recommend.",
|
||||
"qr_code": "QR code",
|
||||
"qr_code_description": "Responses collected via QR code are anonymous.",
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
@@ -1902,7 +1911,6 @@
|
||||
"ttc_tooltip": "Average time to complete the question.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"use_personal_links": "Use personal links",
|
||||
"waiting_for_response": "Waiting for a response \uD83E\uDDD8♂️",
|
||||
"whats_next": "What's next?",
|
||||
"your_survey_is_public": "Your survey is public",
|
||||
"youre_not_plugged_in_yet": "You're not plugged in yet!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Borrar filtros",
|
||||
"clear_selection": "Borrar selección",
|
||||
"click": "Clic",
|
||||
"click_to_filter": "Haz clic para filtrar",
|
||||
"clicks": "Clics",
|
||||
"close": "Cerrar",
|
||||
"code": "Código",
|
||||
@@ -210,6 +211,7 @@
|
||||
"error_rate_limit_description": "Número máximo de solicitudes alcanzado. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error_rate_limit_title": "Límite de frecuencia excedido",
|
||||
"expand_rows": "Expandir filas",
|
||||
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
|
||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||
"failed_to_load_projects": "Error al cargar proyectos",
|
||||
"finish": "Finalizar",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Nombre completo",
|
||||
"gathering_responses": "Recopilando respuestas",
|
||||
"general": "General",
|
||||
"generate": "Generar",
|
||||
"go_back": "Volver",
|
||||
"go_to_dashboard": "Ir al panel de control",
|
||||
"hidden": "Oculto",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "Añadir clase CSS o id",
|
||||
"add_regular_expression_here": "Añade una expresión regular aquí",
|
||||
"add_url": "Añadir URL",
|
||||
"and": "Y",
|
||||
"click": "Clic",
|
||||
"contains": "Contiene",
|
||||
"create_action": "Crear acción",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Limitar a páginas específicas",
|
||||
"matches_regex": "Coincide con regex",
|
||||
"on_all_pages": "En todas las páginas",
|
||||
"or": "O",
|
||||
"page_filter": "Filtro de página",
|
||||
"page_view": "Vista de página",
|
||||
"select_match_type": "Seleccionar tipo de coincidencia",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
||||
"delete_contact_confirmation": "Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá. Si este contacto tiene respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.} other {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con estos contactos. Cualquier segmentación y personalización basada en los datos de estos contactos se perderá. Si estos contactos tienen respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.}}",
|
||||
"generate_personal_link": "Generar enlace personal",
|
||||
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
|
||||
"no_published_link_surveys_available": "No hay encuestas de enlace publicadas disponibles. Por favor, publica primero una encuesta de enlace.",
|
||||
"no_published_surveys": "No hay encuestas publicadas",
|
||||
"no_responses_found": "No se encontraron respuestas",
|
||||
"not_provided": "No proporcionado",
|
||||
"personal_link_generated": "Enlace personal generado correctamente",
|
||||
"personal_link_generated_but_clipboard_failed": "Enlace personal generado pero falló al copiar al portapapeles: {url}",
|
||||
"personal_survey_link": "Enlace personal de encuesta",
|
||||
"please_select_a_survey": "Por favor, selecciona una encuesta",
|
||||
"search_contact": "Buscar contacto",
|
||||
"select_a_survey": "Selecciona una encuesta",
|
||||
"select_attribute": "Seleccionar atributo",
|
||||
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
|
||||
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "Conexión de la aplicación",
|
||||
"app_connection_description": "Conecta tu aplicación o sitio web a Formbricks.",
|
||||
"cache_update_delay_description": "Cuando realizas actualizaciones en encuestas, contactos, acciones u otros datos, puede tardar hasta 5 minutos para que esos cambios aparezcan en tu aplicación local que ejecuta el SDK de Formbricks. Este retraso se debe a una limitación en nuestro sistema de caché actual. Estamos trabajando activamente en la reestructuración de la caché y lanzaremos una solución en Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Los cambios se reflejarán después de 5 minutos debido al almacenamiento en caché",
|
||||
"cache_update_delay_description": "Cuando realizas actualizaciones en encuestas, contactos, acciones u otros datos, puede tardar hasta 1 minuto para que esos cambios aparezcan en tu aplicación local que ejecuta el SDK de Formbricks.",
|
||||
"cache_update_delay_title": "Los cambios se reflejarán después de ~1 minuto debido al almacenamiento en caché",
|
||||
"environment_id": "Tu ID de entorno",
|
||||
"environment_id_description": "Este ID identifica de manera única este entorno de Formbricks.",
|
||||
"formbricks_sdk_connected": "El SDK de Formbricks está conectado",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "Añadir etiqueta",
|
||||
"count": "Recuento",
|
||||
"delete_tag_confirmation": "¿Estás seguro de que quieres eliminar esta etiqueta?",
|
||||
"empty_message": "Etiqueta un envío para encontrar tu lista de etiquetas aquí.",
|
||||
"manage_tags": "Gestionar etiquetas",
|
||||
"manage_tags_description": "Fusionar y eliminar etiquetas de respuesta.",
|
||||
"merge": "Fusionar",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Apellido",
|
||||
"not_completed": "No completado ⏳",
|
||||
"os": "Sistema operativo",
|
||||
"person_attributes": "Atributos de persona",
|
||||
"person_attributes": "Atributos de la persona en el momento del envío",
|
||||
"phone": "Teléfono",
|
||||
"respondent_skipped_questions": "El encuestado omitió estas preguntas.",
|
||||
"response_deleted_successfully": "Respuesta eliminada correctamente.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Filtro añadido para respuestas donde la respuesta a la pregunta {questionIdx} es {filterComboBoxValue} - {filterValue} ",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtro añadido para respuestas donde la respuesta a la pregunta {questionIdx} se ha omitido",
|
||||
"aggregated": "Agregado",
|
||||
"all_responses_csv": "Todas las respuestas (CSV)",
|
||||
"all_responses_excel": "Todas las respuestas (Excel)",
|
||||
"all_time": "Todo el tiempo",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "Respuestas filtradas (CSV)",
|
||||
"filtered_responses_excel": "Respuestas filtradas (Excel)",
|
||||
"generating_qr_code": "Generando código QR",
|
||||
"go_to_setup_checklist": "Ir a la lista de configuración 👉",
|
||||
"impressions": "Impresiones",
|
||||
"impressions_tooltip": "Número de veces que se ha visto la encuesta.",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "Incluye todo",
|
||||
"includes_either": "Incluye cualquiera",
|
||||
"install_widget": "Instalar widget de Formbricks",
|
||||
"individual": "Individual",
|
||||
"is_equal_to": "Es igual a",
|
||||
"is_less_than": "Es menor que",
|
||||
"last_30_days": "Últimos 30 días",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "No se han encontrado respuestas",
|
||||
"other_values_found": "Otros valores encontrados",
|
||||
"overall": "General",
|
||||
"promoters": "Promotores",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Las respuestas recogidas a través del código QR son anónimas.",
|
||||
"qr_code_download_failed": "La descarga del código QR ha fallado",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "El número de cuotas completadas por los encuestados.",
|
||||
"reset_survey": "Reiniciar encuesta",
|
||||
"reset_survey_warning": "Reiniciar una encuesta elimina todas las respuestas y visualizaciones asociadas a esta encuesta. Esto no se puede deshacer.",
|
||||
"satisfied": "Satisfecho",
|
||||
"selected_responses_csv": "Respuestas seleccionadas (CSV)",
|
||||
"selected_responses_excel": "Respuestas seleccionadas (Excel)",
|
||||
"setup_integrations": "Configurar integraciones",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
|
||||
"unknown_question_type": "Tipo de pregunta desconocido",
|
||||
"use_personal_links": "Usar enlaces personales",
|
||||
"waiting_for_response": "Esperando una respuesta 🧘♂️",
|
||||
"whats_next": "¿Qué sigue?",
|
||||
"your_survey_is_public": "Tu encuesta es pública",
|
||||
"youre_not_plugged_in_yet": "¡Aún no estás conectado!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Effacer les filtres",
|
||||
"clear_selection": "Effacer la sélection",
|
||||
"click": "Cliquez",
|
||||
"click_to_filter": "Cliquer pour filtrer",
|
||||
"clicks": "Clics",
|
||||
"close": "Fermer",
|
||||
"code": "Code",
|
||||
@@ -210,6 +211,7 @@
|
||||
"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_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_projects": "Échec du chargement des projets",
|
||||
"finish": "Terminer",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Nom complet",
|
||||
"gathering_responses": "Collecte des réponses",
|
||||
"general": "Général",
|
||||
"generate": "Générer",
|
||||
"go_back": "Retourner",
|
||||
"go_to_dashboard": "Aller au tableau de bord",
|
||||
"hidden": "Caché",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "Ajouter une classe ou un identifiant CSS",
|
||||
"add_regular_expression_here": "Ajouter une expression régulière",
|
||||
"add_url": "Ajouter une URL",
|
||||
"and": "ET",
|
||||
"click": "Cliquez",
|
||||
"contains": "Contient",
|
||||
"create_action": "Créer une action",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Sur certaines pages",
|
||||
"matches_regex": "Correspond à l'expression régulière",
|
||||
"on_all_pages": "Sur toutes les pages",
|
||||
"or": "OU",
|
||||
"page_filter": "Filtrage des pages",
|
||||
"page_view": "Vue de page",
|
||||
"select_match_type": "Sélectionner le type de match",
|
||||
@@ -594,9 +599,18 @@
|
||||
"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.}}",
|
||||
"generate_personal_link": "Générer un lien personnel",
|
||||
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
|
||||
"no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.",
|
||||
"no_published_surveys": "Aucune enquête publiée",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"not_provided": "Non fourni",
|
||||
"personal_link_generated": "Lien personnel généré avec succès",
|
||||
"personal_link_generated_but_clipboard_failed": "Lien personnel généré mais échec de la copie dans le presse-papiers : {url}",
|
||||
"personal_survey_link": "Lien vers le sondage personnel",
|
||||
"please_select_a_survey": "Veuillez sélectionner une enquête",
|
||||
"search_contact": "Rechercher un contact",
|
||||
"select_a_survey": "Sélectionner une enquête",
|
||||
"select_attribute": "Sélectionner un attribut",
|
||||
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
|
||||
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "Connexion d'une application",
|
||||
"app_connection_description": "Connectez votre application ou site web à 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",
|
||||
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, ces changements peuvent prendre jusqu'à 1 minute pour apparaître dans votre application locale exécutant le SDK Formbricks.",
|
||||
"cache_update_delay_title": "Les modifications seront visibles après environ 1 minute en raison de la mise en cache",
|
||||
"environment_id": "Identifiant de votre environnement",
|
||||
"environment_id_description": "Cet identifiant unique est attribué à votre environnement Formbricks.",
|
||||
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
|
||||
@@ -870,7 +884,6 @@
|
||||
"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.",
|
||||
"manage_tags": "Gérer les étiquettes",
|
||||
"manage_tags_description": "Vous pouvez fusionner et supprimer des balises de réponse.",
|
||||
"merge": "Fusionner",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Nom de famille",
|
||||
"not_completed": "Non terminé ⏳",
|
||||
"os": "Système d'exploitation",
|
||||
"person_attributes": "Attributs de la personne",
|
||||
"person_attributes": "Attributs de la personne au moment de la soumission",
|
||||
"phone": "Téléphone",
|
||||
"respondent_skipped_questions": "Le répondant a sauté ces questions.",
|
||||
"response_deleted_successfully": "Réponse supprimée avec succès.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est '{'filterComboBoxValue'}' - '{'filterValue'}' ",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est ignorée",
|
||||
"aggregated": "Agrégé",
|
||||
"all_responses_csv": "Tous les réponses (CSV)",
|
||||
"all_responses_excel": "Tous les réponses (Excel)",
|
||||
"all_time": "Tout le temps",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "Réponses filtrées (CSV)",
|
||||
"filtered_responses_excel": "Réponses filtrées (Excel)",
|
||||
"generating_qr_code": "Génération du code QR",
|
||||
"go_to_setup_checklist": "Allez à la liste de contrôle de configuration 👉",
|
||||
"impressions": "Impressions",
|
||||
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "Comprend tous",
|
||||
"includes_either": "Comprend soit",
|
||||
"install_widget": "Installer le widget Formbricks",
|
||||
"individual": "Individuel",
|
||||
"is_equal_to": "Est égal à",
|
||||
"is_less_than": "est inférieur à",
|
||||
"last_30_days": "30 derniers jours",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
"promoters": "Promoteurs",
|
||||
"qr_code": "Code QR",
|
||||
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
|
||||
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.",
|
||||
"reset_survey": "Réinitialiser l'enquête",
|
||||
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
|
||||
"satisfied": "Satisfait",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "Temps moyen pour compléter la question.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
"waiting_for_response": "En attente d'une réponse 🧘♂️",
|
||||
"whats_next": "Qu'est-ce qui vient ensuite ?",
|
||||
"your_survey_is_public": "Votre enquête est publique.",
|
||||
"youre_not_plugged_in_yet": "Vous n'êtes pas encore branché !"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "フィルターをクリア",
|
||||
"clear_selection": "選択をクリア",
|
||||
"click": "クリック",
|
||||
"click_to_filter": "クリックしてフィルター",
|
||||
"clicks": "クリック数",
|
||||
"close": "閉じる",
|
||||
"code": "コード",
|
||||
@@ -210,6 +211,7 @@
|
||||
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
|
||||
"error_rate_limit_title": "レート制限を超えました",
|
||||
"expand_rows": "行を展開",
|
||||
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
|
||||
"finish": "完了",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "氏名",
|
||||
"gathering_responses": "回答を収集しています",
|
||||
"general": "一般",
|
||||
"generate": "生成",
|
||||
"go_back": "戻る",
|
||||
"go_to_dashboard": "ダッシュボードへ移動",
|
||||
"hidden": "非表示",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "CSSクラスまたはIDを追加",
|
||||
"add_regular_expression_here": "ここに正規表現を追加",
|
||||
"add_url": "URLを追加",
|
||||
"and": "AND",
|
||||
"click": "クリック",
|
||||
"contains": "を含む",
|
||||
"create_action": "アクションを作成",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "特定のページに制限",
|
||||
"matches_regex": "正規表現に一致する",
|
||||
"on_all_pages": "すべてのページで",
|
||||
"or": "OR",
|
||||
"page_filter": "ページフィルター",
|
||||
"page_view": "ページビュー",
|
||||
"select_match_type": "一致タイプを選択",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
|
||||
"generate_personal_link": "個人リンクを生成",
|
||||
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
|
||||
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
|
||||
"no_published_surveys": "公開されたフォームはありません",
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"not_provided": "提供されていません",
|
||||
"personal_link_generated": "個人リンクが正常に生成されました",
|
||||
"personal_link_generated_but_clipboard_failed": "個人用リンクは生成されましたが、クリップボードへのコピーに失敗しました: {url}",
|
||||
"personal_survey_link": "個人調査リンク",
|
||||
"please_select_a_survey": "フォームを選択してください",
|
||||
"search_contact": "連絡先を検索",
|
||||
"select_a_survey": "フォームを選択",
|
||||
"select_attribute": "属性を選択",
|
||||
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
|
||||
"unlock_contacts_title": "上位プランで連絡先をアンロック",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "アプリ接続",
|
||||
"app_connection_description": "アプリやウェブサイトをFormbricksに接続します。",
|
||||
"cache_update_delay_description": "フォーム・連絡先・アクションなどを更新してから、Formbricks SDK を実行中のローカルアプリに反映されるまで最大5分かかる場合があります。これは現在のキャッシュ方式の制限によるものです。私たちはキャッシュを改修中で、Formbricks 4.0 で修正を提供予定です。",
|
||||
"cache_update_delay_title": "キャッシュのため変更の反映に最大5分かかります",
|
||||
"cache_update_delay_description": "アンケート、連絡先、アクション、またはその他のデータを更新した場合、Formbricks SDKを実行しているローカルアプリにそれらの変更が反映されるまでに最大1分かかることがあります。",
|
||||
"cache_update_delay_title": "キャッシュの影響により、変更が反映されるまでに約1分かかります",
|
||||
"environment_id": "あなたのEnvironmentId",
|
||||
"environment_id_description": "このIDはこのFormbricks環境を一意に識別します。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK は接続されています",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "タグを追加",
|
||||
"count": "件数",
|
||||
"delete_tag_confirmation": "このタグを削除してもよろしいですか?",
|
||||
"empty_message": "送信にタグ付けすると、ここにタグ一覧が表示されます。",
|
||||
"manage_tags": "タグを管理",
|
||||
"manage_tags_description": "回答タグを統合・削除します。",
|
||||
"merge": "統合",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完了 ⏳",
|
||||
"os": "OS",
|
||||
"person_attributes": "人物属性",
|
||||
"person_attributes": "回答時の個人属性",
|
||||
"phone": "電話",
|
||||
"respondent_skipped_questions": "回答者はこれらの質問をスキップしました。",
|
||||
"response_deleted_successfully": "回答を正常に削除しました。",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "質問 {questionIdx} の回答が {filterComboBoxValue} - {filterValue} である回答のフィルターを追加しました",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "質問 {questionIdx} の回答がスキップされた回答のフィルターを追加しました",
|
||||
"aggregated": "集計済み",
|
||||
"all_responses_csv": "すべての回答 (CSV)",
|
||||
"all_responses_excel": "すべての回答 (Excel)",
|
||||
"all_time": "全期間",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "フィルター済み回答 (CSV)",
|
||||
"filtered_responses_excel": "フィルター済み回答 (Excel)",
|
||||
"generating_qr_code": "QRコードを生成中",
|
||||
"go_to_setup_checklist": "セットアップチェックリストへ移動 👉",
|
||||
"impressions": "表示回数",
|
||||
"impressions_tooltip": "フォームが表示された回数。",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "すべてを含む",
|
||||
"includes_either": "どちらかを含む",
|
||||
"install_widget": "Formbricksウィジェットをインストール",
|
||||
"individual": "個人",
|
||||
"is_equal_to": "と等しい",
|
||||
"is_less_than": "より小さい",
|
||||
"last_30_days": "過去30日間",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"other_values_found": "他の値が見つかりました",
|
||||
"overall": "全体",
|
||||
"promoters": "推奨者",
|
||||
"qr_code": "QRコード",
|
||||
"qr_code_description": "QRコード経由で収集された回答は匿名です。",
|
||||
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
|
||||
"reset_survey": "フォームをリセット",
|
||||
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
|
||||
"satisfied": "満足",
|
||||
"selected_responses_csv": "選択した回答 (CSV)",
|
||||
"selected_responses_excel": "選択した回答 (Excel)",
|
||||
"setup_integrations": "連携を設定",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "フォームを完了するまでの平均時間。",
|
||||
"unknown_question_type": "不明な質問の種類",
|
||||
"use_personal_links": "個人リンクを使用",
|
||||
"waiting_for_response": "回答を待っています 🧘♂️",
|
||||
"whats_next": "次は何をしますか?",
|
||||
"your_survey_is_public": "あなたのフォームは公開されています",
|
||||
"youre_not_plugged_in_yet": "まだ接続されていません!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Wis filters",
|
||||
"clear_selection": "Duidelijke selectie",
|
||||
"click": "Klik",
|
||||
"click_to_filter": "Klik om te filteren",
|
||||
"clicks": "Klikken",
|
||||
"close": "Dichtbij",
|
||||
"code": "Code",
|
||||
@@ -210,6 +211,7 @@
|
||||
"error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.",
|
||||
"error_rate_limit_title": "Tarieflimiet overschreden",
|
||||
"expand_rows": "Vouw rijen uit",
|
||||
"failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt",
|
||||
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
||||
"failed_to_load_projects": "Laden van projecten mislukt",
|
||||
"finish": "Finish",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Volledige naam",
|
||||
"gathering_responses": "Reacties verzamelen",
|
||||
"general": "Algemeen",
|
||||
"generate": "Genereren",
|
||||
"go_back": "Ga terug",
|
||||
"go_to_dashboard": "Ga naar Dashboard",
|
||||
"hidden": "Verborgen",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "Voeg CSS-klasse of -ID toe",
|
||||
"add_regular_expression_here": "Voeg hier een reguliere expressie toe",
|
||||
"add_url": "URL toevoegen",
|
||||
"and": "EN",
|
||||
"click": "Klik",
|
||||
"contains": "Bevat",
|
||||
"create_action": "Actie creëren",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Beperk tot specifieke pagina's",
|
||||
"matches_regex": "Komt overeen met regex",
|
||||
"on_all_pages": "Op alle pagina's",
|
||||
"or": "OF",
|
||||
"page_filter": "Paginafilter",
|
||||
"page_view": "Paginaweergave",
|
||||
"select_match_type": "Selecteer het zoektype",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
|
||||
"delete_contact_confirmation": "Hierdoor worden alle enquêtereacties en contactkenmerken verwijderd die aan dit contact zijn gekoppeld. Elke targeting en personalisatie op basis van de gegevens van dit contact gaat verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dit verwijdert alle enquêteresultaten en contactattributen die aan dit contact zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van dit contact gaan verloren. Als dit contact reacties heeft die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.} other {Dit verwijdert alle enquêteresultaten en contactattributen die aan deze contacten zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van deze contacten gaan verloren. Als deze contacten reacties hebben die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.}}",
|
||||
"generate_personal_link": "Persoonlijke link genereren",
|
||||
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
|
||||
"no_published_link_surveys_available": "Geen gepubliceerde link-enquêtes beschikbaar. Publiceer eerst een link-enquête.",
|
||||
"no_published_surveys": "Geen gepubliceerde enquêtes",
|
||||
"no_responses_found": "Geen reacties gevonden",
|
||||
"not_provided": "Niet voorzien",
|
||||
"personal_link_generated": "Persoonlijke link succesvol gegenereerd",
|
||||
"personal_link_generated_but_clipboard_failed": "Persoonlijke link gegenereerd maar kopiëren naar klembord mislukt: {url}",
|
||||
"personal_survey_link": "Persoonlijke enquêtelink",
|
||||
"please_select_a_survey": "Selecteer een enquête",
|
||||
"search_contact": "Zoek contactpersoon",
|
||||
"select_a_survey": "Selecteer een enquête",
|
||||
"select_attribute": "Selecteer Kenmerk",
|
||||
"unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes",
|
||||
"unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "App-verbinding",
|
||||
"app_connection_description": "Verbind uw app of website met Formbricks.",
|
||||
"cache_update_delay_description": "Wanneer u updates aanbrengt in enquêtes, contacten, acties of andere gegevens, kan het tot vijf minuten duren voordat deze wijzigingen worden weergegeven in uw lokale app met de Formbricks SDK. Deze vertraging is te wijten aan een beperking in ons huidige cachingsysteem. We zijn de cache actief aan het herwerken en zullen een oplossing uitbrengen in Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Wijzigingen worden na 5 minuten doorgevoerd vanwege caching",
|
||||
"cache_update_delay_description": "Wanneer je updates maakt aan enquêtes, contacten, acties of andere gegevens, kan het tot 1 minuut duren voordat deze wijzigingen zichtbaar worden in je lokale app die de Formbricks SDK gebruikt.",
|
||||
"cache_update_delay_title": "Wijzigingen worden na ongeveer 1 minuut weergegeven vanwege caching",
|
||||
"environment_id": "Uw omgevings-ID",
|
||||
"environment_id_description": "Deze ID identificeert op unieke wijze deze Formbricks-omgeving.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK is verbonden",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "Label toevoegen",
|
||||
"count": "Graaf",
|
||||
"delete_tag_confirmation": "Weet u zeker dat u deze tag wilt verwijderen?",
|
||||
"empty_message": "Tag een inzending om hier uw lijst met tags te vinden.",
|
||||
"manage_tags": "Beheer tags",
|
||||
"manage_tags_description": "Reactietags samenvoegen en verwijderen.",
|
||||
"merge": "Samenvoegen",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Achternaam",
|
||||
"not_completed": "Niet voltooid ⏳",
|
||||
"os": "Besturingssysteem",
|
||||
"person_attributes": "Persoonsattributen",
|
||||
"person_attributes": "Persoonskenmerken op het moment van indiening",
|
||||
"phone": "Telefoon",
|
||||
"respondent_skipped_questions": "Respondent heeft deze vragen overgeslagen.",
|
||||
"response_deleted_successfully": "Reactie is succesvol verwijderd.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Filter toegevoegd voor antwoorden waarbij het antwoord op vraag {questionIdx} {filterComboBoxValue} - {filterValue} is",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filter toegevoegd voor antwoorden waarbij het antwoord op vraag {questionIdx} wordt overgeslagen",
|
||||
"aggregated": "Geaggregeerd",
|
||||
"all_responses_csv": "Alle reacties (CSV)",
|
||||
"all_responses_excel": "Alle reacties (Excel)",
|
||||
"all_time": "Altijd",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "Gefilterde reacties (CSV)",
|
||||
"filtered_responses_excel": "Gefilterde reacties (Excel)",
|
||||
"generating_qr_code": "QR-code genereren",
|
||||
"go_to_setup_checklist": "Ga naar Installatiechecklist 👉",
|
||||
"impressions": "Indrukken",
|
||||
"impressions_tooltip": "Aantal keren dat de enquête is bekeken.",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "Inclusief alles",
|
||||
"includes_either": "Inclusief beide",
|
||||
"install_widget": "Installeer Formbricks-widget",
|
||||
"individual": "Individueel",
|
||||
"is_equal_to": "Is gelijk aan",
|
||||
"is_less_than": "Is minder dan",
|
||||
"last_30_days": "Laatste 30 dagen",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "Geen reacties gevonden",
|
||||
"other_values_found": "Andere waarden gevonden",
|
||||
"overall": "Algemeen",
|
||||
"promoters": "Promoters",
|
||||
"qr_code": "QR-code",
|
||||
"qr_code_description": "Reacties verzameld via QR-code zijn anoniem.",
|
||||
"qr_code_download_failed": "Downloaden van QR-code mislukt",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "Het aantal quota dat door de respondenten is voltooid.",
|
||||
"reset_survey": "Enquête opnieuw instellen",
|
||||
"reset_survey_warning": "Als u een enquête opnieuw instelt, worden alle reacties en weergaven verwijderd die aan deze enquête zijn gekoppeld. Dit kan niet ongedaan worden gemaakt.",
|
||||
"satisfied": "Tevreden",
|
||||
"selected_responses_csv": "Geselecteerde reacties (CSV)",
|
||||
"selected_responses_excel": "Geselecteerde antwoorden (Excel)",
|
||||
"setup_integrations": "Integraties instellen",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
|
||||
"unknown_question_type": "Onbekend vraagtype",
|
||||
"use_personal_links": "Gebruik persoonlijke links",
|
||||
"waiting_for_response": "Wachten op een reactie 🧘♂️",
|
||||
"whats_next": "Wat is het volgende?",
|
||||
"your_survey_is_public": "Uw enquête is openbaar",
|
||||
"youre_not_plugged_in_yet": "Je bent nog niet aangesloten!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Limpar filtros",
|
||||
"clear_selection": "Limpar seleção",
|
||||
"click": "Clica",
|
||||
"click_to_filter": "Clique para filtrar",
|
||||
"clicks": "cliques",
|
||||
"close": "Fechar",
|
||||
"code": "Código",
|
||||
@@ -210,6 +211,7 @@
|
||||
"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_copy_to_clipboard": "Falha ao copiar para a área de transferência",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Terminar",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "Recolhendo respostas",
|
||||
"general": "Geral",
|
||||
"generate": "Gerar",
|
||||
"go_back": "Voltar",
|
||||
"go_to_dashboard": "Ir para o Painel",
|
||||
"hidden": "Escondido",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "Adicionar classe ou id CSS",
|
||||
"add_regular_expression_here": "Adicionar uma expressão regular aqui",
|
||||
"add_url": "Adicionar URL",
|
||||
"and": "E",
|
||||
"click": "Clica",
|
||||
"contains": "contém",
|
||||
"create_action": "criar ação",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Limitar a páginas específicas",
|
||||
"matches_regex": "Correspondência regex",
|
||||
"on_all_pages": "Em todas as páginas",
|
||||
"or": "OU",
|
||||
"page_filter": "filtro de página",
|
||||
"page_view": "Visualização de Página",
|
||||
"select_match_type": "Selecionar tipo de partida",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
|
||||
"generate_personal_link": "Gerar link pessoal",
|
||||
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
|
||||
"no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.",
|
||||
"no_published_surveys": "Sem pesquisas publicadas",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"not_provided": "Não fornecido",
|
||||
"personal_link_generated": "Link pessoal gerado com sucesso",
|
||||
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado, mas falha ao copiar para a área de transferência: {url}",
|
||||
"personal_survey_link": "Link da pesquisa pessoal",
|
||||
"please_select_a_survey": "Por favor, selecione uma pesquisa",
|
||||
"search_contact": "Buscar contato",
|
||||
"select_a_survey": "Selecione uma pesquisa",
|
||||
"select_attribute": "Selecionar Atributo",
|
||||
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
|
||||
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "Conexão do App",
|
||||
"app_connection_description": "Conecte seu app ou site ao Formbricks.",
|
||||
"cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 5 minutos para que essas mudanças apareçam no seu app local rodando o SDK do Formbricks. Esse atraso é devido a uma limitação no nosso sistema de cache atual. Estamos ativamente retrabalhando o cache e planejamos lançar uma correção no Formbricks 4.0.",
|
||||
"cache_update_delay_title": "As mudanças serão refletidas após 5 minutos devido ao cache",
|
||||
"cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 1 minuto para que essas alterações apareçam no seu aplicativo local executando o SDK do Formbricks.",
|
||||
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao cache",
|
||||
"environment_id": "Seu Id do Ambiente",
|
||||
"environment_id_description": "Este ID identifica exclusivamente este ambiente do Formbricks.",
|
||||
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "Adicionar Tag",
|
||||
"count": "Contar",
|
||||
"delete_tag_confirmation": "Tem certeza de que quer deletar essa tag?",
|
||||
"empty_message": "Marque uma submissão para encontrar sua lista de tags aqui.",
|
||||
"manage_tags": "Gerenciar Tags",
|
||||
"manage_tags_description": "Mesclar e remover tags de resposta.",
|
||||
"merge": "mesclar",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Sobrenome",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "sistema operacional",
|
||||
"person_attributes": "Atributos da pessoa",
|
||||
"person_attributes": "Atributos da pessoa no momento do envio",
|
||||
"phone": "Celular",
|
||||
"respondent_skipped_questions": "Respondente pulou essas perguntas.",
|
||||
"response_deleted_successfully": "Resposta deletada com sucesso.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} foi pulada",
|
||||
"aggregated": "Agregado",
|
||||
"all_responses_csv": "Todas as respostas (CSV)",
|
||||
"all_responses_excel": "Todas as respostas (Excel)",
|
||||
"all_time": "Todo o tempo",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "Respostas filtradas (CSV)",
|
||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||
"generating_qr_code": "Gerando código QR",
|
||||
"go_to_setup_checklist": "Vai para a Lista de Configuração 👉",
|
||||
"impressions": "Impressões",
|
||||
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "Inclui tudo",
|
||||
"includes_either": "Inclui ou",
|
||||
"install_widget": "Instalar Widget do Formbricks",
|
||||
"individual": "Individual",
|
||||
"is_equal_to": "É igual a",
|
||||
"is_less_than": "É menor que",
|
||||
"last_30_days": "Últimos 30 dias",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
"promoters": "Promotores",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
|
||||
"qr_code_download_failed": "falha no download do código QR",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "Número de cotas preenchidas pelos respondentes.",
|
||||
"reset_survey": "Redefinir pesquisa",
|
||||
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
|
||||
"satisfied": "Satisfeito",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "Tempo médio para completar a pergunta.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"use_personal_links": "Use links pessoais",
|
||||
"waiting_for_response": "Aguardando uma resposta 🧘♂️",
|
||||
"whats_next": "E agora?",
|
||||
"your_survey_is_public": "Sua pesquisa é pública",
|
||||
"youre_not_plugged_in_yet": "Você ainda não tá conectado!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Limpar filtros",
|
||||
"clear_selection": "Limpar seleção",
|
||||
"click": "Clique",
|
||||
"click_to_filter": "Clique para filtrar",
|
||||
"clicks": "Cliques",
|
||||
"close": "Fechar",
|
||||
"code": "Código",
|
||||
@@ -210,6 +211,7 @@
|
||||
"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_copy_to_clipboard": "Falha ao copiar para a área de transferência",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_projects": "Falha ao carregar projetos",
|
||||
"finish": "Concluir",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "A recolher respostas",
|
||||
"general": "Geral",
|
||||
"generate": "Gerar",
|
||||
"go_back": "Voltar",
|
||||
"go_to_dashboard": "Ir para o Painel",
|
||||
"hidden": "Oculto",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "Adicionar classe ou id CSS",
|
||||
"add_regular_expression_here": "Adicione uma expressão regular aqui",
|
||||
"add_url": "Adicionar URL",
|
||||
"and": "E",
|
||||
"click": "Clique",
|
||||
"contains": "Contém",
|
||||
"create_action": "Criar ação",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Limitar a páginas específicas",
|
||||
"matches_regex": "Coincide com regex",
|
||||
"on_all_pages": "Em todas as páginas",
|
||||
"or": "OU",
|
||||
"page_filter": "Filtro de página",
|
||||
"page_view": "Visualização de Página",
|
||||
"select_match_type": "Selecionar tipo de correspondência",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
|
||||
"generate_personal_link": "Gerar Link Pessoal",
|
||||
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
|
||||
"no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.",
|
||||
"no_published_surveys": "Sem inquéritos publicados",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"not_provided": "Não fornecido",
|
||||
"personal_link_generated": "Link pessoal gerado com sucesso",
|
||||
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado mas falha ao copiar para a área de transferência: {url}",
|
||||
"personal_survey_link": "Link do inquérito pessoal",
|
||||
"please_select_a_survey": "Por favor, selecione um inquérito",
|
||||
"search_contact": "Procurar contacto",
|
||||
"select_a_survey": "Selecione um inquérito",
|
||||
"select_attribute": "Selecionar Atributo",
|
||||
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
|
||||
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "Conexão de aplicação",
|
||||
"app_connection_description": "Ligue a sua aplicação ou website 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.",
|
||||
"cache_update_delay_description": "Quando faz atualizações a inquéritos, contactos, ações ou outros dados, pode demorar até 1 minuto para que essas alterações apareçam na sua aplicação local que executa o SDK Formbricks.",
|
||||
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao armazenamento em cache",
|
||||
"environment_id": "O seu identificador",
|
||||
"environment_id_description": "Este id identifica o seu espaço Formbricks.",
|
||||
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
|
||||
@@ -870,7 +884,6 @@
|
||||
"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",
|
||||
"manage_tags": "Gerir Etiquetas",
|
||||
"manage_tags_description": "Junte e remova etiquetas de resposta",
|
||||
"merge": "Fundir",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Apelido",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "SO",
|
||||
"person_attributes": "Atributos da pessoa",
|
||||
"person_attributes": "Atributos da pessoa no momento da submissão",
|
||||
"phone": "Telefone",
|
||||
"respondent_skipped_questions": "O respondente saltou estas perguntas.",
|
||||
"response_deleted_successfully": "Resposta eliminada com sucesso.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é ignorada",
|
||||
"aggregated": "Agregado",
|
||||
"all_responses_csv": "Todas as respostas (CSV)",
|
||||
"all_responses_excel": "Todas as respostas (Excel)",
|
||||
"all_time": "Todo o tempo",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "Respostas filtradas (CSV)",
|
||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||
"generating_qr_code": "A gerar código QR",
|
||||
"go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração 👉",
|
||||
"impressions": "Impressões",
|
||||
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "Inclui tudo",
|
||||
"includes_either": "Inclui qualquer um",
|
||||
"install_widget": "Instalar Widget Formbricks",
|
||||
"individual": "Individual",
|
||||
"is_equal_to": "É igual a",
|
||||
"is_less_than": "É menos que",
|
||||
"last_30_days": "Últimos 30 dias",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
"promoters": "Promotores",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
|
||||
"qr_code_download_failed": "Falha ao transferir o código QR",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "O número de quotas concluídas pelos respondentes.",
|
||||
"reset_survey": "Reiniciar inquérito",
|
||||
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
|
||||
"satisfied": "Satisfeito",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
"waiting_for_response": "A aguardar uma resposta 🧘♂️",
|
||||
"whats_next": "O que se segue?",
|
||||
"your_survey_is_public": "O seu inquérito é público",
|
||||
"youre_not_plugged_in_yet": "Ainda não está ligado!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "Curăță filtrele",
|
||||
"clear_selection": "Șterge selecția",
|
||||
"click": "Click",
|
||||
"click_to_filter": "Click pentru a filtra",
|
||||
"clicks": "Clickuri",
|
||||
"close": "Închide",
|
||||
"code": "Cod",
|
||||
@@ -210,6 +211,7 @@
|
||||
"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_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
|
||||
"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ă",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "Nume complet",
|
||||
"gathering_responses": "Culegere răspunsuri",
|
||||
"general": "General",
|
||||
"generate": "Generează",
|
||||
"go_back": "Înapoi",
|
||||
"go_to_dashboard": "Mergi la Tablou de Bord",
|
||||
"hidden": "Ascuns",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "Adăugați clasă CSS sau id",
|
||||
"add_regular_expression_here": "Adăugați o expresie regulată aici",
|
||||
"add_url": "Adaugă URL",
|
||||
"and": "ȘI",
|
||||
"click": "Click",
|
||||
"contains": "Conține",
|
||||
"create_action": "Creează acțiune",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "Limitează la pagini specifice",
|
||||
"matches_regex": "Se potrivește cu regex",
|
||||
"on_all_pages": "Pe toate paginile",
|
||||
"or": "SAU",
|
||||
"page_filter": "Filtru pagină",
|
||||
"page_view": "Vizualizare Pagina",
|
||||
"select_match_type": "Selectați tipul de potrivire",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
|
||||
"generate_personal_link": "Generează link personal",
|
||||
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
|
||||
"no_published_link_surveys_available": "Nu există sondaje publicate pentru linkuri disponibile. Vă rugăm să publicați mai întâi un sondaj pentru linkuri.",
|
||||
"no_published_surveys": "Nu există sondaje publicate",
|
||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||
"not_provided": "Nu a fost furnizat",
|
||||
"personal_link_generated": "Linkul personal a fost generat cu succes",
|
||||
"personal_link_generated_but_clipboard_failed": "Linkul personal a fost generat, dar nu s-a reușit copierea în clipboard: {url}",
|
||||
"personal_survey_link": "Link către sondajul personal",
|
||||
"please_select_a_survey": "Vă rugăm să selectați un sondaj",
|
||||
"search_contact": "Căutați contact",
|
||||
"select_a_survey": "Selectați un sondaj",
|
||||
"select_attribute": "Selectează atributul",
|
||||
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
|
||||
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "Conectare aplicație",
|
||||
"app_connection_description": "Conectează-ți aplicația sau site-ul la Formbricks.",
|
||||
"cache_update_delay_description": "Când faci actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 5 minute pentru ca aceste modificări să apară în aplicația locală care rulează SDK Formbricks. Această întârziere se datorează unei limitări în sistemul nostru actual de caching. Revedem activ cache-ul și vom lansa o soluție în Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Modificările vor fi reflectate după 5 minute datorită memorării în cache",
|
||||
"cache_update_delay_description": "Când efectuați actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 1 minut pentru ca aceste modificări să apară în aplicația locală care rulează SDK-ul Formbricks.",
|
||||
"cache_update_delay_title": "Modificările vor fi vizibile după ~1 minut din cauza memoriei cache",
|
||||
"environment_id": "ID-ul mediului tău",
|
||||
"environment_id_description": "Acest id identifică în mod unic acest mediu Formbricks.",
|
||||
"formbricks_sdk_connected": "SDK Formbricks este conectat",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "Adaugă Etichetă",
|
||||
"count": "Număr",
|
||||
"delete_tag_confirmation": "Sigur doriți să ștergeți această etichetă?",
|
||||
"empty_message": "Marcați o trimitere pentru a găsi lista de etichete aici.",
|
||||
"manage_tags": "Gestionați etichetele",
|
||||
"manage_tags_description": "Îmbinați și eliminați etichetele de răspuns.",
|
||||
"merge": "Îmbinare",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "Nume de familie",
|
||||
"not_completed": "Necompletat ⏳",
|
||||
"os": "SO",
|
||||
"person_attributes": "Atribute persoană",
|
||||
"person_attributes": "Atributele persoanei la momentul trimiterii",
|
||||
"phone": "Telefon",
|
||||
"respondent_skipped_questions": "Respondenții au sărit peste aceste întrebări.",
|
||||
"response_deleted_successfully": "Răspuns șters cu succes.",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este {filterComboBoxValue} - {filterValue}",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este omis",
|
||||
"aggregated": "Agregat",
|
||||
"all_responses_csv": "Toate răspunsurile (CSV)",
|
||||
"all_responses_excel": "Toate răspunsurile (Excel)",
|
||||
"all_time": "Pe parcursul întregii perioade",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "Răspunsuri filtrate (CSV)",
|
||||
"filtered_responses_excel": "Răspunsuri filtrate (Excel)",
|
||||
"generating_qr_code": "Se generează codul QR",
|
||||
"go_to_setup_checklist": "Mergi la lista de verificare a configurării 👉",
|
||||
"impressions": "Impresii",
|
||||
"impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "Include tot",
|
||||
"includes_either": "Include fie",
|
||||
"install_widget": "Instalați Widgetul Formbricks",
|
||||
"individual": "Individual",
|
||||
"is_equal_to": "Este egal cu",
|
||||
"is_less_than": "Este mai puțin de",
|
||||
"last_30_days": "Ultimele 30 de zile",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||
"other_values_found": "Alte valori găsite",
|
||||
"overall": "General",
|
||||
"promoters": "Promotori",
|
||||
"qr_code": "Cod QR",
|
||||
"qr_code_description": "Răspunsurile colectate prin cod QR sunt anonime.",
|
||||
"qr_code_download_failed": "Descărcarea codului QR a eșuat",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "Numărul de cote completate de respondenți.",
|
||||
"reset_survey": "Resetează chestionarul",
|
||||
"reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.",
|
||||
"satisfied": "Mulțumit",
|
||||
"selected_responses_csv": "Răspunsuri selectate (CSV)",
|
||||
"selected_responses_excel": "Răspunsuri selectate (Excel)",
|
||||
"setup_integrations": "Configurare integrare",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
|
||||
"unknown_question_type": "Tip de întrebare necunoscut",
|
||||
"use_personal_links": "Folosește linkuri personale",
|
||||
"waiting_for_response": "Așteptând un răspuns 🧘♂️",
|
||||
"whats_next": "Ce urmează?",
|
||||
"your_survey_is_public": "Sondajul tău este public",
|
||||
"youre_not_plugged_in_yet": "Nu sunteţi încă conectat!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "清除 过滤器",
|
||||
"clear_selection": "清除 选择",
|
||||
"click": "点击",
|
||||
"click_to_filter": "点击筛选",
|
||||
"clicks": "点击",
|
||||
"close": "关闭",
|
||||
"code": "代码",
|
||||
@@ -210,6 +211,7 @@
|
||||
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
|
||||
"error_rate_limit_title": "速率 限制 超过",
|
||||
"expand_rows": "展开 行",
|
||||
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_projects": "加载项目失败",
|
||||
"finish": "完成",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集反馈",
|
||||
"general": "通用",
|
||||
"generate": "生成",
|
||||
"go_back": "返回 ",
|
||||
"go_to_dashboard": "转到 Dashboard",
|
||||
"hidden": "隐藏",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "添加 CSS class 或 id",
|
||||
"add_regular_expression_here": "在 这里 添加 正则 表达式",
|
||||
"add_url": "添加 URL",
|
||||
"and": "与",
|
||||
"click": "点击",
|
||||
"contains": "包含",
|
||||
"create_action": "创建 操作",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "限制 特定 页面",
|
||||
"matches_regex": "匹配 正则表达式",
|
||||
"on_all_pages": "在 所有 页面",
|
||||
"or": "或",
|
||||
"page_filter": "页面 过滤器",
|
||||
"page_view": "页面 查看",
|
||||
"select_match_type": "选择 匹配 类型",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
|
||||
"generate_personal_link": "生成个人链接",
|
||||
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
|
||||
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",
|
||||
"no_published_surveys": "没有已发布的调查",
|
||||
"no_responses_found": "未找到 响应",
|
||||
"not_provided": "未提供",
|
||||
"personal_link_generated": "个人链接生成成功",
|
||||
"personal_link_generated_but_clipboard_failed": "个性化链接已生成,但复制到剪贴板失败:{url}",
|
||||
"personal_survey_link": "个人调查链接",
|
||||
"please_select_a_survey": "请选择一个调查",
|
||||
"search_contact": "搜索 联系人",
|
||||
"select_a_survey": "选择一个调查",
|
||||
"select_attribute": "选择 属性",
|
||||
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
|
||||
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "应用程序 连接",
|
||||
"app_connection_description": "将您的应用或网站连接到 Formbricks。",
|
||||
"cache_update_delay_description": "当 你 对 调查 、 联系人 、 操作 或 其他 数据 进行 更新 时 , 可能 需要 最多 5 分钟 更改 才能 显示 在 你 本地 运行 Formbricks SDK 的 应用程序 中 。 这个 延迟 是 由于 我们 当前 缓存 系统 的 限制 。 我们 正在 积极 重新设计 缓存 并 将 在 Formbricks 4.0 中 发布 修复 。",
|
||||
"cache_update_delay_title": "更改 将 在 5 分钟 后 由于 缓存 而 显示",
|
||||
"cache_update_delay_description": "当您更新问卷、联系人、操作或其他数据时,这些更改可能需要最多 1 分钟才能在运行 Formbricks SDK 的本地应用中显示。",
|
||||
"cache_update_delay_title": "由于缓存,变更将在约 1 分钟后生效",
|
||||
"environment_id": "你的 环境 ID",
|
||||
"environment_id_description": "这个 id 独特地 标识 这个 Formbricks 环境。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK 已连接",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "添加 标签",
|
||||
"count": "数量",
|
||||
"delete_tag_confirmation": "您 确定 要 删除 此 标签 吗?",
|
||||
"empty_message": "标记一个提交以在此处找到您的标签列表。",
|
||||
"manage_tags": "管理标签",
|
||||
"manage_tags_description": "合并 和 删除 response 标签。",
|
||||
"merge": "合并",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "操作系统",
|
||||
"person_attributes": "人员 属性",
|
||||
"person_attributes": "提交时的个人属性",
|
||||
"phone": "电话",
|
||||
"respondent_skipped_questions": "受访者跳过 这些问题。",
|
||||
"response_deleted_successfully": "响应 删除 成功",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "为 回答 问题 {questionIdx} 的 答复 增加 了 筛选器,筛选条件 是 {filterComboBoxValue} - {filterValue}",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "为 回答 问题 {questionIdx} 的 答复 增加 了 筛选器,筛选条件 是 略过",
|
||||
"aggregated": "汇总",
|
||||
"all_responses_csv": "所有 反馈 (CSV)",
|
||||
"all_responses_excel": "所有 反馈 (Excel)",
|
||||
"all_time": "所有 时间",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "过滤 反馈 (CSV)",
|
||||
"filtered_responses_excel": "过滤 反馈 (Excel)",
|
||||
"generating_qr_code": "正在生成二维码",
|
||||
"go_to_setup_checklist": "前往 设置 检查列表 👉",
|
||||
"impressions": "印象",
|
||||
"impressions_tooltip": "调查 被 查看 的 次数",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "包括所有 ",
|
||||
"includes_either": "包含 任意一个",
|
||||
"install_widget": "安装 Formbricks 小组件",
|
||||
"individual": "个人",
|
||||
"is_equal_to": "等于",
|
||||
"is_less_than": "少于",
|
||||
"last_30_days": "最近 30 天",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "未找到响应",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整体",
|
||||
"promoters": "推荐者",
|
||||
"qr_code": "二维码",
|
||||
"qr_code_description": "通过 QR 码 收集 的 响应 是 匿名 的。",
|
||||
"qr_code_download_failed": "二维码下载失败",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "受访者完成的配额数量。",
|
||||
"reset_survey": "重置 调查",
|
||||
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
|
||||
"satisfied": "满意",
|
||||
"selected_responses_csv": "选定 反馈 (CSV)",
|
||||
"selected_responses_excel": "选定 反馈 (Excel)",
|
||||
"setup_integrations": "设置 集成",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "完成 本 问题 的 平均 时间",
|
||||
"unknown_question_type": "未知 问题 类型",
|
||||
"use_personal_links": "使用 个人 链接",
|
||||
"waiting_for_response": "等待回复 🧘♂️",
|
||||
"whats_next": "接下来 是 什么?",
|
||||
"your_survey_is_public": "您的 调查 是 公共 的",
|
||||
"youre_not_plugged_in_yet": "您 还 没 有 连 接!"
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"clear_filters": "清除篩選器",
|
||||
"clear_selection": "清除選取",
|
||||
"click": "點擊",
|
||||
"click_to_filter": "點擊篩選",
|
||||
"clicks": "點擊數",
|
||||
"close": "關閉",
|
||||
"code": "程式碼",
|
||||
@@ -210,6 +211,7 @@
|
||||
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
||||
"error_rate_limit_title": "限流超過",
|
||||
"expand_rows": "展開列",
|
||||
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_projects": "無法載入專案",
|
||||
"finish": "完成",
|
||||
@@ -218,6 +220,7 @@
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集回應中",
|
||||
"general": "一般",
|
||||
"generate": "產生",
|
||||
"go_back": "返回",
|
||||
"go_to_dashboard": "前往儀表板",
|
||||
"hidden": "隱藏",
|
||||
@@ -524,6 +527,7 @@
|
||||
"add_css_class_or_id": "新增 CSS 類別或 ID",
|
||||
"add_regular_expression_here": "新增正則表達式在此",
|
||||
"add_url": "新增網址",
|
||||
"and": "且",
|
||||
"click": "點擊",
|
||||
"contains": "包含",
|
||||
"create_action": "建立操作",
|
||||
@@ -554,6 +558,7 @@
|
||||
"limit_to_specific_pages": "限制為特定頁面",
|
||||
"matches_regex": "符合 正則 表達式",
|
||||
"on_all_pages": "在所有頁面上",
|
||||
"or": "或",
|
||||
"page_filter": "頁面篩選器",
|
||||
"page_view": "頁面檢視",
|
||||
"select_match_type": "選取比對類型",
|
||||
@@ -594,9 +599,18 @@
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
|
||||
"generate_personal_link": "產生個人連結",
|
||||
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
|
||||
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
|
||||
"no_published_surveys": "沒有已發佈的問卷",
|
||||
"no_responses_found": "找不到回應",
|
||||
"not_provided": "未提供",
|
||||
"personal_link_generated": "個人連結已成功產生",
|
||||
"personal_link_generated_but_clipboard_failed": "已生成個人連結,但無法複製到剪貼簿:{url}",
|
||||
"personal_survey_link": "個人調查連結",
|
||||
"please_select_a_survey": "請選擇一個問卷",
|
||||
"search_contact": "搜尋聯絡人",
|
||||
"select_a_survey": "選擇問卷",
|
||||
"select_attribute": "選取屬性",
|
||||
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
|
||||
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
|
||||
@@ -776,8 +790,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "應用程式連線",
|
||||
"app_connection_description": "將您的應用程式或網站連接到 Formbricks。",
|
||||
"cache_update_delay_description": "當您對調查、聯絡人、操作或其他資料進行更新時,可能需要長達 5 分鐘這些變更才能顯示在執行 Formbricks SDK 的本地應用程式中。此延遲是因我們目前快取系統的限制。我們正積極重新設計快取,並將在 Formbricks 4.0 中發佈修補程式。",
|
||||
"cache_update_delay_title": "更改將於 5 分鐘後因快取而反映",
|
||||
"cache_update_delay_description": "當您更新問卷調查、聯絡人、操作或其他資料時,這些變更可能需要最多 1 分鐘的時間,才會顯示在執行 Formbricks SDK 的本地應用程式中。",
|
||||
"cache_update_delay_title": "由於快取,變更約需 1 分鐘後才會反映",
|
||||
"environment_id": "您的 EnvironmentId",
|
||||
"environment_id_description": "此 ID 可唯一識別此 Formbricks 環境。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK 已連線",
|
||||
@@ -870,7 +884,6 @@
|
||||
"add_tag": "新增標籤",
|
||||
"count": "計數",
|
||||
"delete_tag_confirmation": "您確定要刪除此標籤嗎?",
|
||||
"empty_message": "標記提交內容,在此處找到您的標籤清單。",
|
||||
"manage_tags": "管理標籤",
|
||||
"manage_tags_description": "合併和移除回應標籤。",
|
||||
"merge": "合併",
|
||||
@@ -1689,7 +1702,7 @@
|
||||
"last_name": "姓氏",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "作業系統",
|
||||
"person_attributes": "人員屬性",
|
||||
"person_attributes": "提交時的個人屬性",
|
||||
"phone": "電話",
|
||||
"respondent_skipped_questions": "回應者跳過這些問題。",
|
||||
"response_deleted_successfully": "回應已成功刪除。",
|
||||
@@ -1802,6 +1815,7 @@
|
||||
"summary": {
|
||||
"added_filter_for_responses_where_answer_to_question": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案為 '{'filterComboBoxValue'}' - '{'filterValue'}'",
|
||||
"added_filter_for_responses_where_answer_to_question_is_skipped": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案被跳過",
|
||||
"aggregated": "匯總",
|
||||
"all_responses_csv": "所有回應 (CSV)",
|
||||
"all_responses_excel": "所有回應 (Excel)",
|
||||
"all_time": "全部時間",
|
||||
@@ -1825,7 +1839,6 @@
|
||||
"filtered_responses_csv": "篩選回應 (CSV)",
|
||||
"filtered_responses_excel": "篩選回應 (Excel)",
|
||||
"generating_qr_code": "正在生成 QR code",
|
||||
"go_to_setup_checklist": "前往設定檢查清單 👉",
|
||||
"impressions": "曝光數",
|
||||
"impressions_tooltip": "問卷已檢視的次數。",
|
||||
"in_app": {
|
||||
@@ -1859,7 +1872,7 @@
|
||||
},
|
||||
"includes_all": "包含全部",
|
||||
"includes_either": "包含其中一個",
|
||||
"install_widget": "安裝 Formbricks 小工具",
|
||||
"individual": "個人",
|
||||
"is_equal_to": "等於",
|
||||
"is_less_than": "小於",
|
||||
"last_30_days": "過去 30 天",
|
||||
@@ -1872,6 +1885,7 @@
|
||||
"no_responses_found": "找不到回應",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"promoters": "推廣者",
|
||||
"qr_code": "QR 碼",
|
||||
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
|
||||
"qr_code_download_failed": "QR code 下載失敗",
|
||||
@@ -1881,6 +1895,7 @@
|
||||
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
|
||||
"reset_survey": "重設問卷",
|
||||
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
||||
"satisfied": "滿意",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"setup_integrations": "設定整合",
|
||||
@@ -1896,7 +1911,6 @@
|
||||
"ttc_tooltip": "完成 問題 的 平均 時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
"waiting_for_response": "正在等待回應 🧘♂️",
|
||||
"whats_next": "下一步是什麼?",
|
||||
"your_survey_is_public": "您的問卷是公開的",
|
||||
"youre_not_plugged_in_yet": "您尚未插入任何內容!"
|
||||
|
||||
@@ -66,13 +66,13 @@ const getSmiley = (
|
||||
|
||||
return (
|
||||
<table style={{ width: "48px", height: "48px" }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
{icon}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{" "}
|
||||
{/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */}
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
{icon}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { 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 { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Tag } from "@/modules/ui/components/tag";
|
||||
import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
|
||||
import { createTagAction, createTagToResponseAction, deleteTagOnResponseAction } from "../actions";
|
||||
import { SingleResponseCardMetadata } from "./SingleResponseCardMetadata";
|
||||
|
||||
interface ResponseTagsWrapperProps {
|
||||
tags: {
|
||||
@@ -24,6 +24,8 @@ interface ResponseTagsWrapperProps {
|
||||
environmentTags: TTag[];
|
||||
updateFetchedResponses: () => void;
|
||||
isReadOnly?: boolean;
|
||||
response: TResponse;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
@@ -33,9 +35,10 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
environmentTags,
|
||||
updateFetchedResponses,
|
||||
isReadOnly,
|
||||
response,
|
||||
locale,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [tagsState, setTagsState] = useState(tags);
|
||||
@@ -79,7 +82,6 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
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" />,
|
||||
});
|
||||
} else {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
|
||||
@@ -131,6 +133,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SingleResponseCardMetadata response={response} locale={locale} />
|
||||
{tagsState?.map((tag) => (
|
||||
<Tag
|
||||
key={tag.tagId}
|
||||
@@ -157,18 +160,6 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
/>
|
||||
)}
|
||||
</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,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { LanguagesIcon, TrashIcon } from "lucide-react";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -12,17 +10,12 @@ import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { isSubmissionTimeMoreThan5Minutes } from "../util";
|
||||
|
||||
interface TooltipRendererProps {
|
||||
shouldRender: boolean;
|
||||
tooltipContent: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface SingleResponseCardHeaderProps {
|
||||
pageType: "people" | "response";
|
||||
response: TResponse;
|
||||
@@ -54,140 +47,40 @@ export const SingleResponseCardHeader = ({
|
||||
? true
|
||||
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
|
||||
|
||||
const TooltipRenderer = ({ children, shouldRender, tooltipContent }: TooltipRendererProps) => {
|
||||
return shouldRender ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent avoidCollisions align="start" side="bottom" className="max-w-[75vw]">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTooltip = Boolean(
|
||||
(response.contactAttributes && Object.keys(response.contactAttributes).length > 0) ||
|
||||
(response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0)
|
||||
);
|
||||
|
||||
const tooltipContent = (
|
||||
<>
|
||||
{response.singleUseId && (
|
||||
<div>
|
||||
<p className="py-1 font-bold text-slate-700">
|
||||
{t("environments.surveys.responses.single_use_id")}:
|
||||
</p>
|
||||
<span>{response.singleUseId}</span>
|
||||
</div>
|
||||
)}
|
||||
{response.contactAttributes && Object.keys(response.contactAttributes).length > 0 && (
|
||||
<div>
|
||||
<p className="py-1 font-bold text-slate-700">
|
||||
{t("environments.surveys.responses.person_attributes")}:
|
||||
</p>
|
||||
{Object.keys(response.contactAttributes).map((key) => (
|
||||
<p
|
||||
key={key}
|
||||
className="truncate"
|
||||
title={`${key}: ${response.contactAttributes && response.contactAttributes[key]}`}>
|
||||
{key}:{" "}
|
||||
<span className="font-bold">
|
||||
{response.contactAttributes && response.contactAttributes[key]}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0 && (
|
||||
<div className="text-slate-600">
|
||||
{response.contactAttributes && Object.keys(response.contactAttributes).length > 0 && (
|
||||
<hr className="my-2 border-slate-200" />
|
||||
)}
|
||||
<p className="py-1 font-bold text-slate-700">{t("environments.surveys.responses.device_info")}:</p>
|
||||
{response.meta.userAgent?.browser && (
|
||||
<p className="truncate" title={`Browser: ${response.meta.userAgent.browser}`}>
|
||||
{t("environments.surveys.responses.browser")}: {response.meta.userAgent.browser}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.userAgent?.os && (
|
||||
<p className="truncate" title={`OS: ${response.meta.userAgent.os}`}>
|
||||
{t("environments.surveys.responses.os")}: {response.meta.userAgent.os}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.userAgent && (
|
||||
<p
|
||||
className="truncate"
|
||||
title={`Device: ${response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}`}>
|
||||
{t("environments.surveys.responses.device")}:{" "}
|
||||
{response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.url && (
|
||||
<p className="truncate" title={`URL: ${response.meta.url}`}>
|
||||
{t("common.url")}: {response.meta.url}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.action && (
|
||||
<p className="truncate" title={`Action: ${response.meta.action}`}>
|
||||
{t("common.action")}: {response.meta.action}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.source && (
|
||||
<p className="truncate" title={`Source: ${response.meta.source}`}>
|
||||
{t("environments.surveys.responses.source")}: {response.meta.source}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.country && (
|
||||
<p className="truncate" title={`Country: ${response.meta.country}`}>
|
||||
{t("environments.surveys.responses.country")}: {response.meta.country}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const deleteSubmissionToolTip = <>{t("environments.surveys.responses.this_response_is_in_progress")}</>;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
{pageType === "response" && (
|
||||
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
|
||||
<div className="group">
|
||||
{response.contact?.id ? (
|
||||
user ? (
|
||||
<Link
|
||||
className="flex items-center space-x-2"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
|
||||
{displayIdentifier}
|
||||
</h3>
|
||||
{response.contact.userId && <IdBadge id={response.contact.userId} />}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
|
||||
{displayIdentifier}
|
||||
</h3>
|
||||
{response.contact.userId && <IdBadge id={response.contact.userId} />}
|
||||
</div>
|
||||
)
|
||||
<>
|
||||
{response.contact?.id ? (
|
||||
user ? (
|
||||
<Link
|
||||
className="flex items-center space-x-2"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
|
||||
{displayIdentifier}
|
||||
</h3>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
<h3 className="ml-4 pb-1 font-semibold text-slate-600">{t("common.anonymous")}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
|
||||
{displayIdentifier}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
<h3 className="ml-4 pb-1 font-semibold text-slate-600">{t("common.anonymous")}</h3>
|
||||
</div>
|
||||
)}
|
||||
{response.contact?.userId && <IdBadge id={response.contact.userId} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pageType === "people" && (
|
||||
@@ -202,34 +95,34 @@ export const SingleResponseCardHeader = ({
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{response.language && response.language !== "default" && (
|
||||
<div className="flex space-x-2 rounded-full bg-slate-700 px-2 py-1 text-xs text-white">
|
||||
<div>{getLanguageLabel(response.language, locale)}</div>
|
||||
<LanguagesIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<time className="text-slate-500" dateTime={timeSince(response.createdAt.toISOString(), locale)}>
|
||||
{timeSince(response.createdAt.toISOString(), locale)}
|
||||
</time>
|
||||
{user &&
|
||||
!isReadOnly &&
|
||||
(canResponseBeDeleted ? (
|
||||
<TrashIcon
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="h-4 w-4 cursor-pointer text-slate-500 hover:text-red-700"
|
||||
aria-label="Delete response"
|
||||
/>
|
||||
aria-label="Delete response">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TrashIcon
|
||||
className="h-4 w-4 cursor-not-allowed text-slate-400"
|
||||
aria-label="Cannot delete response in progress"
|
||||
/>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
className="text-slate-400"
|
||||
aria-label="Cannot delete response in progress">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{deleteSubmissionToolTip}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { LanguagesIcon, LucideIcon, MonitorIcon, SmartphoneIcon, Tag } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface InfoIconButtonProps {
|
||||
icon: LucideIcon;
|
||||
tooltipContent: ReactNode;
|
||||
ariaLabel: string;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
const InfoIconButton = ({
|
||||
icon: Icon,
|
||||
tooltipContent,
|
||||
ariaLabel,
|
||||
maxWidth = "max-w-[75vw]",
|
||||
}: InfoIconButtonProps) => {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label={ariaLabel}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface SingleResponseCardMetadataProps {
|
||||
response: TResponse;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const SingleResponseCardMetadata = ({ response, locale }: SingleResponseCardMetadataProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasContactAttributes =
|
||||
response.contactAttributes && Object.keys(response.contactAttributes).length > 0;
|
||||
const hasUserAgent = response.meta.userAgent && Object.keys(response.meta.userAgent).length > 0;
|
||||
const hasLanguage = response.language && response.language !== "default";
|
||||
|
||||
if (!hasContactAttributes && !hasUserAgent && !hasLanguage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userAgentDeviceIcon = (() => {
|
||||
if (!hasUserAgent || !response.meta.userAgent?.device) return MonitorIcon;
|
||||
const device = response.meta.userAgent.device.toLowerCase();
|
||||
return device.includes("mobile") || device.includes("phone") ? SmartphoneIcon : MonitorIcon;
|
||||
})();
|
||||
|
||||
const contactAttributesTooltipContent = hasContactAttributes ? (
|
||||
<div>
|
||||
{response.singleUseId && (
|
||||
<div className="mb-2">
|
||||
<p className="py-1 font-semibold text-slate-700">
|
||||
{t("environments.surveys.responses.single_use_id")}
|
||||
</p>
|
||||
<span>{response.singleUseId}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="py-1 font-semibold text-slate-700">
|
||||
{t("environments.surveys.responses.person_attributes")}
|
||||
</p>
|
||||
{Object.keys(response.contactAttributes || {}).map((key) => (
|
||||
<p key={key} className="truncate" title={`${key}: ${response.contactAttributes?.[key]}`}>
|
||||
{key}: {response.contactAttributes?.[key]}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const userAgentTooltipContent = hasUserAgent ? (
|
||||
<div className="text-slate-600">
|
||||
<p className="py-1 font-semibold text-slate-700">{t("environments.surveys.responses.device_info")}</p>
|
||||
{response.meta.userAgent?.browser && (
|
||||
<p className="truncate" title={`Browser: ${response.meta.userAgent.browser}`}>
|
||||
{t("environments.surveys.responses.browser")}: {response.meta.userAgent.browser}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.userAgent?.os && (
|
||||
<p className="truncate" title={`OS: ${response.meta.userAgent.os}`}>
|
||||
{t("environments.surveys.responses.os")}: {response.meta.userAgent.os}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.userAgent && (
|
||||
<p
|
||||
className="truncate"
|
||||
title={`Device: ${response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}`}>
|
||||
{t("environments.surveys.responses.device")}:{" "}
|
||||
{response.meta.userAgent.device ? response.meta.userAgent.device : "PC / Generic device"}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.url && (
|
||||
<p className="break-all" title={`URL: ${response.meta.url}`}>
|
||||
{t("common.url")}: {response.meta.url}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.action && (
|
||||
<p className="truncate" title={`Action: ${response.meta.action}`}>
|
||||
{t("common.action")}: {response.meta.action}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.source && (
|
||||
<p className="truncate" title={`Source: ${response.meta.source}`}>
|
||||
{t("environments.surveys.responses.source")}: {response.meta.source}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.country && (
|
||||
<p className="truncate" title={`Country: ${response.meta.country}`}>
|
||||
{t("environments.surveys.responses.country")}: {response.meta.country}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const languageTooltipContent =
|
||||
hasLanguage && response.language ? (
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700">{t("common.language")}</p>
|
||||
<p>{getLanguageLabel(response.language, locale)}</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasContactAttributes && contactAttributesTooltipContent && (
|
||||
<InfoIconButton
|
||||
icon={Tag}
|
||||
tooltipContent={contactAttributesTooltipContent}
|
||||
ariaLabel={t("environments.surveys.responses.person_attributes")}
|
||||
/>
|
||||
)}
|
||||
{hasUserAgent && userAgentTooltipContent && (
|
||||
<InfoIconButton
|
||||
icon={userAgentDeviceIcon}
|
||||
tooltipContent={userAgentTooltipContent}
|
||||
ariaLabel={t("environments.surveys.responses.device_info")}
|
||||
maxWidth="max-w-md"
|
||||
/>
|
||||
)}
|
||||
{hasLanguage && languageTooltipContent && (
|
||||
<InfoIconButton
|
||||
icon={LanguagesIcon}
|
||||
tooltipContent={languageTooltipContent}
|
||||
ariaLabel={t("common.language")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -143,6 +143,8 @@ export const SingleResponseCard = ({
|
||||
environmentTags={environmentTags}
|
||||
updateFetchedResponses={updateFetchedResponses}
|
||||
isReadOnly={isReadOnly}
|
||||
response={response}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("pickCommonFilter", () => {
|
||||
order: "asc",
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-12-31"),
|
||||
filterDateField: "createdAt",
|
||||
} as TGetFilter;
|
||||
const result = pickCommonFilter(params);
|
||||
expect(result).toEqual(params);
|
||||
@@ -27,6 +28,7 @@ describe("pickCommonFilter", () => {
|
||||
order: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
filterDateField: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,5 +72,32 @@ describe("pickCommonFilter", () => {
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("applies filterDateField with updatedAt when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = {
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-12-31"),
|
||||
filterDateField: "updatedAt",
|
||||
} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
const updatedAt = result.where?.updatedAt as Prisma.DateTimeFilter | undefined;
|
||||
expect(updatedAt?.gte).toEqual(params.startDate);
|
||||
expect(updatedAt?.lte).toEqual(params.endDate);
|
||||
expect(result.where?.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
test("defaults to createdAt when filterDateField is not provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = {
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-12-31"),
|
||||
} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
const createdAt = result.where?.createdAt as Prisma.DateTimeFilter | undefined;
|
||||
expect(createdAt?.gte).toEqual(params.startDate);
|
||||
expect(createdAt?.lte).toEqual(params.endDate);
|
||||
expect(result.where?.updatedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
apps/web/modules/api/v2/management/lib/utils.test.ts
Normal file
43
apps/web/modules/api/v2/management/lib/utils.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCommonFilterQuery } from "./utils";
|
||||
|
||||
describe("buildCommonFilterQuery", () => {
|
||||
// Test for line 32: spread existing date filter when adding startDate
|
||||
it("should preserve existing date filter when adding startDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
lte: new Date("2024-12-31"),
|
||||
},
|
||||
},
|
||||
};
|
||||
const startDate = new Date("2024-01-01");
|
||||
|
||||
const result = buildCommonFilterQuery(query, { startDate });
|
||||
|
||||
expect(result.where?.createdAt).toEqual({
|
||||
lte: new Date("2024-12-31"),
|
||||
gte: startDate,
|
||||
});
|
||||
});
|
||||
|
||||
// Test for line 45: spread existing date filter when adding endDate
|
||||
it("should preserve existing date filter when adding endDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date("2024-01-01"),
|
||||
},
|
||||
},
|
||||
};
|
||||
const endDate = new Date("2024-12-31");
|
||||
|
||||
const result = buildCommonFilterQuery(query, { endDate });
|
||||
|
||||
expect(result.where?.createdAt).toEqual({
|
||||
gte: new Date("2024-01-01"),
|
||||
lte: endDate,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { Prisma } from "@prisma/client";
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
|
||||
export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params;
|
||||
return { limit, skip, sortBy, order, startDate, endDate };
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField } = params;
|
||||
return { limit, skip, sortBy, order, startDate, endDate, filterDateField };
|
||||
}
|
||||
|
||||
type HasFindMany =
|
||||
@@ -15,19 +15,21 @@ type HasFindMany =
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
|
||||
|
||||
let filteredQuery = {
|
||||
...query,
|
||||
};
|
||||
|
||||
const dateField = filterDateField;
|
||||
|
||||
if (startDate) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
[dateField]: {
|
||||
...(filteredQuery.where?.[dateField] as Prisma.DateTimeFilter),
|
||||
gte: startDate,
|
||||
},
|
||||
},
|
||||
@@ -39,8 +41,8 @@ export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params:
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
[dateField]: {
|
||||
...(filteredQuery.where?.[dateField] as Prisma.DateTimeFilter),
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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,
|
||||
@@ -51,8 +48,6 @@ export const createResponse = async (
|
||||
responseInput: TResponseInput,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("response created");
|
||||
|
||||
const {
|
||||
surveyId,
|
||||
displayId,
|
||||
@@ -126,7 +121,6 @@ export const createResponse = async (
|
||||
if (!billing.ok) {
|
||||
return err(billing.error as ApiErrorResponseV2);
|
||||
}
|
||||
const billingData = billing.data;
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
@@ -140,26 +134,7 @@ export const createResponse = async (
|
||||
return err(responsesCountResult.error as ApiErrorResponseV2);
|
||||
}
|
||||
|
||||
const responsesCount = responsesCountResult.data;
|
||||
const responsesLimit = billingData.limits?.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: billingData.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw it
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
// Limit check completed
|
||||
}
|
||||
|
||||
return ok(response);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationBilling,
|
||||
@@ -20,10 +19,6 @@ import {
|
||||
} from "@/modules/api/v2/management/responses/lib/organization";
|
||||
import { createResponse, getResponses } from "../response";
|
||||
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
getOrganizationBilling: vi.fn(),
|
||||
@@ -150,11 +145,8 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockImplementation(() => Promise.resolve(""));
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
@@ -191,10 +183,6 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(
|
||||
new Error("Error sending plan limits")
|
||||
);
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { WebhookSource } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { createWebhook, getWebhooks } from "../webhook";
|
||||
|
||||
@@ -16,10 +15,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telemetry", () => ({
|
||||
captureTelemetry: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getWebhooks", () => {
|
||||
const environmentId = "env1";
|
||||
const params = {
|
||||
@@ -86,7 +81,6 @@ describe("createWebhook", () => {
|
||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
||||
|
||||
const result = await createWebhook(inputWebhook);
|
||||
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
|
||||
expect(prisma.webhook.create).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -47,8 +46,6 @@ export const getWebhooks = async (
|
||||
};
|
||||
|
||||
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("webhook_created");
|
||||
|
||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ProjectTeam } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
|
||||
import {
|
||||
TGetProjectTeamsFilter,
|
||||
@@ -44,8 +43,6 @@ export const getProjectTeams = async (
|
||||
export const createProjectTeam = async (
|
||||
teamInput: TProjectTeamInput
|
||||
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("project team created");
|
||||
|
||||
const { teamId, projectId, permission } = teamInput;
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@ import "server-only";
|
||||
import { Team } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
|
||||
import {
|
||||
TGetTeamsFilter,
|
||||
@@ -15,8 +14,6 @@ export const createTeam = async (
|
||||
teamInput: TTeamInput,
|
||||
organizationId: string
|
||||
): Promise<Result<Team, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("team created");
|
||||
|
||||
const { name } = teamInput;
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TUser } from "@formbricks/database/zod/users";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
|
||||
import {
|
||||
TGetUsersFilter,
|
||||
@@ -73,8 +72,6 @@ export const createUser = async (
|
||||
userInput: TUserInput,
|
||||
organizationId
|
||||
): Promise<Result<TUser, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("user created");
|
||||
|
||||
const { name, email, role, teams, isActive } = userInput;
|
||||
|
||||
try {
|
||||
@@ -150,8 +147,6 @@ export const updateUser = async (
|
||||
userInput: TUserInputPatch,
|
||||
organizationId: string
|
||||
): Promise<Result<TUser, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("user updated");
|
||||
|
||||
const { name, email, role, teams, isActive } = userInput;
|
||||
let existingTeams: string[] = [];
|
||||
let newTeams;
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ZGetFilter = z.object({
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc").describe("Sort order"),
|
||||
startDate: z.coerce.date().optional().describe("Start date"),
|
||||
endDate: z.coerce.date().optional().describe("End date"),
|
||||
filterDateField: z.enum(["createdAt", "updatedAt"]).optional().describe("Date field to filter by"),
|
||||
});
|
||||
|
||||
export type TGetFilter = z.infer<typeof ZGetFilter>;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { createUser, updateUser } from "@/modules/auth/lib/user";
|
||||
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
|
||||
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
|
||||
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
|
||||
import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
@@ -46,21 +46,15 @@ const ZCreateUserAction = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
async function verifyTurnstileIfConfigured(
|
||||
turnstileToken: string | undefined,
|
||||
email: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
||||
if (!IS_TURNSTILE_CONFIGURED) return;
|
||||
|
||||
if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
|
||||
captureFailedSignup(email, name);
|
||||
throw new UnknownError("Server configuration error");
|
||||
}
|
||||
|
||||
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
|
||||
if (!isHuman) {
|
||||
captureFailedSignup(email, name);
|
||||
throw new UnknownError("reCAPTCHA verification failed");
|
||||
}
|
||||
}
|
||||
@@ -180,7 +174,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.signup);
|
||||
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
|
||||
await verifyTurnstileIfConfigured(parsedInput.turnstileToken);
|
||||
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const { user, userAlreadyExisted } = await createUserSafely(
|
||||
|
||||
@@ -13,7 +13,6 @@ import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
||||
import { captureFailedSignup } from "@/modules/auth/signup/lib/utils";
|
||||
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
@@ -236,7 +235,6 @@ export const SignupForm = ({
|
||||
onError={() => {
|
||||
setTurnstileToken(undefined);
|
||||
toast.error(t("auth.signup.captcha_failed"));
|
||||
captureFailedSignup(form.getValues("email"), form.getValues("name"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import posthog from "posthog-js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { captureFailedSignup, verifyTurnstileToken } from "./utils";
|
||||
import { verifyTurnstileToken } from "./utils";
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
@@ -62,18 +61,3 @@ describe("verifyTurnstileToken", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureFailedSignup", () => {
|
||||
test("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => {
|
||||
const captureSpy = vi.spyOn(posthog, "capture");
|
||||
const email = "test@example.com";
|
||||
const name = "Test User";
|
||||
|
||||
captureFailedSignup(email, name);
|
||||
|
||||
expect(captureSpy).toHaveBeenCalledWith("TELEMETRY_FAILED_SIGNUP", {
|
||||
email,
|
||||
name,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
@@ -29,10 +27,3 @@ export const verifyTurnstileToken = async (secretKey: string, token: string): Pr
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
export const captureFailedSignup = (email: string, name: string) => {
|
||||
posthog.capture("TELEMETRY_FAILED_SIGNUP", {
|
||||
email,
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
60
apps/web/modules/ee/contacts/[contactId]/actions.ts
Normal file
60
apps/web/modules/ee/contacts/[contactId]/actions.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
|
||||
const ZGeneratePersonalSurveyLinkAction = z.object({
|
||||
contactId: ZId,
|
||||
surveyId: ZId,
|
||||
expirationDays: z.number().optional(),
|
||||
});
|
||||
|
||||
export const generatePersonalSurveyLinkAction = authenticatedActionClient
|
||||
.schema(ZGeneratePersonalSurveyLinkAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getContactSurveyLink(
|
||||
parsedInput.contactId,
|
||||
parsedInput.surveyId,
|
||||
parsedInput.expirationDays
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error.type === "not_found") {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
if (result.error.type === "bad_request") {
|
||||
const errorMessage = result.error.details?.[0]?.issue || "Invalid request";
|
||||
throw new InvalidInputError(errorMessage);
|
||||
}
|
||||
const errorMessage = result.error.details?.[0]?.issue || "Failed to generate personal survey link";
|
||||
throw new InvalidInputError(errorMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
surveyUrl: result.data,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { LinkIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
|
||||
|
||||
interface ContactControlBarProps {
|
||||
environmentId: string;
|
||||
contactId: string;
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
}
|
||||
|
||||
export const ContactControlBar = ({
|
||||
environmentId,
|
||||
contactId,
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
publishedLinkSurveys,
|
||||
}: ContactControlBarProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
|
||||
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
|
||||
|
||||
const handleDeletePerson = async () => {
|
||||
setIsDeletingPerson(true);
|
||||
const deletePersonResponse = await deleteContactAction({ contactId });
|
||||
if (deletePersonResponse?.data) {
|
||||
router.refresh();
|
||||
router.push(`/environments/${environmentId}/contacts`);
|
||||
toast.success(t("environments.contacts.contact_deleted_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deletePersonResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsDeletingPerson(false);
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
if (isReadOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: LinkIcon,
|
||||
tooltip: t("environments.contacts.generate_personal_link"),
|
||||
onClick: () => {
|
||||
setIsGenerateLinkModalOpen(true);
|
||||
},
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: TrashIcon,
|
||||
tooltip: t("common.delete"),
|
||||
onClick: () => {
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconBar actions={iconActions} />
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat="person"
|
||||
onDelete={handleDeletePerson}
|
||||
isDeleting={isDeletingPerson}
|
||||
text={
|
||||
isQuotasAllowed
|
||||
? t("environments.contacts.delete_contact_confirmation_with_quotas", {
|
||||
value: 1,
|
||||
})
|
||||
: t("environments.contacts.delete_contact_confirmation")
|
||||
}
|
||||
/>
|
||||
<GeneratePersonalLinkModal
|
||||
open={isGenerateLinkModalOpen}
|
||||
setOpen={setIsGenerateLinkModalOpen}
|
||||
contactId={contactId}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, LinkIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { generatePersonalSurveyLinkAction } from "../actions";
|
||||
|
||||
interface GeneratePersonalLinkModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactId: string;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Failed to copy to clipboard:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const GeneratePersonalLinkModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
contactId,
|
||||
publishedLinkSurveys,
|
||||
}: GeneratePersonalLinkModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [generatedUrl, setGeneratedUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
} else {
|
||||
setSelectedSurveyId(undefined);
|
||||
setGeneratedUrl("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCopyUrl = useCallback(
|
||||
async (url: string) => {
|
||||
const success = await copyToClipboard(url);
|
||||
if (success) {
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
} else {
|
||||
toast.error(t("common.failed_to_copy_to_clipboard"));
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedSurveyId) {
|
||||
toast.error(t("environments.contacts.please_select_a_survey"));
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const response = await generatePersonalSurveyLinkAction({
|
||||
contactId,
|
||||
surveyId: selectedSurveyId,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
const surveyUrl = response?.data?.surveyUrl;
|
||||
if (!surveyUrl) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedUrl(surveyUrl);
|
||||
const success = await copyToClipboard(surveyUrl);
|
||||
|
||||
if (success) {
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
} else {
|
||||
toast.error(
|
||||
t("environments.contacts.personal_link_generated_but_clipboard_failed", {
|
||||
url: surveyUrl,
|
||||
}) || `${t("environments.contacts.personal_link_generated")}: ${surveyUrl}`,
|
||||
{ duration: 6000 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage || t("common.something_went_wrong_please_try_again"));
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const getSelectPlaceholder = () => {
|
||||
if (publishedLinkSurveys.length === 0) return t("environments.contacts.no_published_surveys");
|
||||
return t("environments.contacts.select_a_survey");
|
||||
};
|
||||
|
||||
const isDisabled = isLoading || publishedLinkSurveys.length === 0;
|
||||
const canGenerate = selectedSurveyId && !isDisabled;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<DialogTitle>{t("environments.contacts.generate_personal_link")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.contacts.generate_personal_link_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="m-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="survey-select">{t("common.select_survey")}</Label>
|
||||
<Select value={selectedSurveyId} onValueChange={setSelectedSurveyId} disabled={isDisabled}>
|
||||
<SelectTrigger id="survey-select">
|
||||
<SelectValue placeholder={getSelectPlaceholder()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{publishedLinkSurveys.map((survey) => (
|
||||
<SelectItem key={survey.id} value={survey.id}>
|
||||
{survey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{publishedLinkSurveys.length === 0 && (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.contacts.no_published_link_surveys_available")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generatedUrl && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generated-url">{t("environments.contacts.personal_survey_link")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="generated-url"
|
||||
value={generatedUrl}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => handleCopyUrl(generatedUrl)} className="gap-1">
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setOpen(false)} disabled={isLoading}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={!canGenerate}>
|
||||
{isLoading ? t("common.saving") : t("common.generate")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -12,7 +13,7 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ResponseTimelineProps {
|
||||
surveys: TSurvey[];
|
||||
@@ -33,6 +34,7 @@ export const ResponseFeed = ({
|
||||
locale,
|
||||
projectPermission,
|
||||
}: ResponseTimelineProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [fetchedResponses, setFetchedResponses] = useState(responses);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +52,7 @@ export const ResponseFeed = ({
|
||||
return (
|
||||
<>
|
||||
{fetchedResponses.length === 0 ? (
|
||||
<EmptySpaceFiller type="response" environment={environment} />
|
||||
<EmptyState text={t("environments.contacts.no_responses_found")} />
|
||||
) : (
|
||||
fetchedResponses.map((response) => (
|
||||
<ResponseSurveyCard
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { DeleteContactButton } from "@/modules/ee/contacts/[contactId]/components/delete-contact-button";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -19,10 +20,11 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, contactAttributes] = await Promise.all([
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
@@ -31,20 +33,21 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.billing.plan);
|
||||
|
||||
const getDeletePersonButton = () => {
|
||||
const getContactControlBar = () => {
|
||||
return (
|
||||
<DeleteContactButton
|
||||
<ContactControlBar
|
||||
environmentId={environment.id}
|
||||
contactId={params.contactId}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getDeletePersonButton()} />
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-4 gap-x-8">
|
||||
<AttributesSection contactId={params.contactId} />
|
||||
|
||||
@@ -300,7 +300,7 @@ export const ContactsTable = ({
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && (
|
||||
{data && hasMore && data.length > 0 && isDataLoaded && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button onClick={fetchNextPage}>{t("common.load_more")}</Button>
|
||||
</div>
|
||||
|
||||
@@ -56,9 +56,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
ITEMS_PER_PAGE: 2,
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
|
||||
IS_PRODUCTION: false,
|
||||
IS_POSTHOG_CONFIGURED: false,
|
||||
POSTHOG_API_HOST: "test-posthog-host",
|
||||
POSTHOG_API_KEY: "test-posthog-key",
|
||||
}));
|
||||
|
||||
const environmentId = "cm123456789012345678901237";
|
||||
|
||||
130
apps/web/modules/ee/contacts/lib/surveys.test.ts
Normal file
130
apps/web/modules/ee/contacts/lib/surveys.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getPublishedLinkSurveys } from "./surveys";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "cm123456789012345678901237";
|
||||
|
||||
describe("getPublishedLinkSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns published link surveys", async () => {
|
||||
const mockSurveys = [
|
||||
{ id: "survey1", name: "Customer Feedback Survey" },
|
||||
{ id: "survey2", name: "Product Survey" },
|
||||
{ id: "survey3", name: "NPS Survey" },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys);
|
||||
|
||||
const result = await getPublishedLinkSurveys(environmentId);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ id: "survey1", name: "Customer Feedback Survey" });
|
||||
expect(result[1]).toEqual({ id: "survey2", name: "Product Survey" });
|
||||
expect(result[2]).toEqual({ id: "survey3", name: "NPS Survey" });
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId, status: "inProgress", type: "link" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array if no published link surveys", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getPublishedLinkSurveys(environmentId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow(DatabaseError);
|
||||
await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow("DB error");
|
||||
});
|
||||
|
||||
test("throws original error on unknown error", async () => {
|
||||
const genericError = new Error("Unknown error");
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow(genericError);
|
||||
await expect(getPublishedLinkSurveys(environmentId)).rejects.toThrow("Unknown error");
|
||||
});
|
||||
|
||||
test("filters surveys by status inProgress", async () => {
|
||||
const mockSurveys = [{ id: "survey1", name: "Active Survey" }];
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys);
|
||||
|
||||
await getPublishedLinkSurveys(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: "inProgress",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("filters surveys by type link", async () => {
|
||||
const mockSurveys = [{ id: "survey1", name: "Link Survey" }];
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys);
|
||||
|
||||
await getPublishedLinkSurveys(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
type: "link",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("only selects id and name fields", async () => {
|
||||
const mockSurveys = [{ id: "survey1", name: "Test Survey" }];
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockSurveys);
|
||||
|
||||
const result = await getPublishedLinkSurveys(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Verify the result only contains id and name
|
||||
expect(Object.keys(result[0])).toEqual(["id", "name"]);
|
||||
});
|
||||
});
|
||||
32
apps/web/modules/ee/contacts/lib/surveys.ts
Normal file
32
apps/web/modules/ee/contacts/lib/surveys.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export interface PublishedLinkSurvey {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const getPublishedLinkSurveys = reactCache(
|
||||
async (environmentId: string): Promise<PublishedLinkSurvey[]> => {
|
||||
try {
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: { environmentId, status: "inProgress", type: "link" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
return surveys;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -33,6 +33,7 @@ export const SegmentTable = async ({
|
||||
<>
|
||||
{segments.map((segment) => (
|
||||
<SegmentTableDataRowContainer
|
||||
key={segment.id}
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
|
||||
@@ -4,7 +4,6 @@ import fetch from "node-fetch";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { env } from "@/lib/env";
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { collectTelemetryData } from "./telemetry";
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
@@ -246,29 +246,48 @@ const handleInitialFailure = async (currentTime: Date) => {
|
||||
|
||||
// API functions
|
||||
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
|
||||
// Skip license checks during build time
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let telemetryData;
|
||||
try {
|
||||
const now = new Date();
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
// first millisecond of next year => current year is fully included
|
||||
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
|
||||
telemetryData = await collectTelemetryData(env.ENTERPRISE_LICENSE_KEY || null);
|
||||
} catch (telemetryError) {
|
||||
logger.warn({ error: telemetryError }, "Telemetry collection failed, proceeding with minimal data");
|
||||
telemetryData = {
|
||||
licenseKey: env.ENTERPRISE_LICENSE_KEY || null,
|
||||
usage: null,
|
||||
};
|
||||
}
|
||||
|
||||
const responseCount = await prisma.response.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfYear,
|
||||
lt: startOfNextYear,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) {
|
||||
try {
|
||||
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
||||
|
||||
await fetch(CONFIG.API.ENDPOINT, {
|
||||
body: JSON.stringify(telemetryData),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
agent,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
logger.debug({ error }, "Failed to send telemetry (no license key)");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
|
||||
@@ -276,10 +295,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
||||
|
||||
const res = await fetch(CONFIG.API.ENDPOINT, {
|
||||
body: JSON.stringify({
|
||||
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount },
|
||||
}),
|
||||
body: JSON.stringify(telemetryData),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
agent,
|
||||
@@ -296,7 +312,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
||||
trackApiError(error);
|
||||
|
||||
// Retry on specific status codes
|
||||
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
|
||||
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
|
||||
return fetchLicenseFromServerInternal(retryCount + 1);
|
||||
@@ -341,6 +356,10 @@ export const getEnterpriseLicense = reactCache(
|
||||
validateConfig();
|
||||
|
||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
fetchLicenseFromServerInternal().catch((error) => {
|
||||
logger.debug({ error }, "Background telemetry send failed (no license key)");
|
||||
});
|
||||
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
|
||||
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal file
245
apps/web/modules/ee/license-check/lib/telemetry.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { collectTelemetryData } from "./telemetry";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: { count: vi.fn(), findFirst: vi.fn() },
|
||||
user: { count: vi.fn(), findFirst: vi.fn() },
|
||||
team: { count: vi.fn() },
|
||||
project: { count: vi.fn() },
|
||||
survey: { count: vi.fn(), findFirst: vi.fn() },
|
||||
contact: { count: vi.fn() },
|
||||
segment: { count: vi.fn() },
|
||||
display: { count: vi.fn() },
|
||||
response: { count: vi.fn() },
|
||||
surveyLanguage: { findFirst: vi.fn() },
|
||||
surveyAttributeFilter: { findFirst: vi.fn() },
|
||||
apiKey: { findFirst: vi.fn() },
|
||||
teamUser: { findFirst: vi.fn() },
|
||||
surveyQuota: { findFirst: vi.fn() },
|
||||
webhook: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_STORAGE_CONFIGURED: true,
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
GOOGLE_OAUTH_ENABLED: true,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
AIRTABLE_CLIENT_ID: "test-airtable-id",
|
||||
SLACK_CLIENT_ID: "test-slack-id",
|
||||
SLACK_CLIENT_SECRET: "test-slack-secret",
|
||||
NOTION_OAUTH_CLIENT_ID: "test-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "test-notion-secret",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "test-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-sheets-secret",
|
||||
}));
|
||||
|
||||
describe("Telemetry Collection", () => {
|
||||
const mockLicenseKey = "test-license-key-123";
|
||||
const mockOrganizationId = "org-123";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
id: mockOrganizationId,
|
||||
createdAt: new Date(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("collectTelemetryData", () => {
|
||||
test("should return null usage for cloud instances", async () => {
|
||||
// Mock IS_FORMBRICKS_CLOUD as true for this test
|
||||
const actualConstants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...(actualConstants as Record<string, unknown>),
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
}));
|
||||
|
||||
// Re-import to get the new mock
|
||||
const { collectTelemetryData: collectWithCloud } = await import("./telemetry");
|
||||
const result = await collectWithCloud(mockLicenseKey);
|
||||
|
||||
expect(result.licenseKey).toBe(mockLicenseKey);
|
||||
expect(result.usage).toBeNull();
|
||||
|
||||
// Reset mock
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should collect basic counts successfully", async () => {
|
||||
vi.mocked(prisma.organization.count).mockResolvedValue(1);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||
vi.mocked(prisma.team.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.project.count).mockResolvedValue(3);
|
||||
vi.mocked(prisma.survey.count).mockResolvedValue(10);
|
||||
vi.mocked(prisma.contact.count).mockResolvedValue(100);
|
||||
vi.mocked(prisma.segment.count).mockResolvedValue(5);
|
||||
vi.mocked(prisma.display.count).mockResolvedValue(500);
|
||||
vi.mocked(prisma.response.count).mockResolvedValue(1000);
|
||||
|
||||
const result = await collectTelemetryData(mockLicenseKey);
|
||||
|
||||
expect(result.usage).toBeTruthy();
|
||||
if (result.usage) {
|
||||
expect(result.usage.organizationCount).toBe(1);
|
||||
expect(result.usage.memberCount).toBe(5);
|
||||
expect(result.usage.teamCount).toBe(2);
|
||||
expect(result.usage.projectCount).toBe(3);
|
||||
expect(result.usage.surveyCount).toBe(10);
|
||||
expect(result.usage.contactCount).toBe(100);
|
||||
expect(result.usage.segmentCount).toBe(5);
|
||||
expect(result.usage.surveyDisplayCount).toBe(500);
|
||||
expect(result.usage.responseCountAllTime).toBe(1000);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle query timeouts gracefully", async () => {
|
||||
// Simulate slow query that times out (but resolve it eventually)
|
||||
let resolveOrgCount: (value: number) => void;
|
||||
const orgCountPromise = new Promise<number>((resolve) => {
|
||||
resolveOrgCount = resolve;
|
||||
});
|
||||
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
|
||||
|
||||
// Mock other queries to return quickly
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||
vi.mocked(prisma.team.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.project.count).mockResolvedValue(3);
|
||||
vi.mocked(prisma.survey.count).mockResolvedValue(10);
|
||||
vi.mocked(prisma.contact.count).mockResolvedValue(100);
|
||||
vi.mocked(prisma.segment.count).mockResolvedValue(5);
|
||||
vi.mocked(prisma.display.count).mockResolvedValue(500);
|
||||
vi.mocked(prisma.response.count).mockResolvedValue(1000);
|
||||
|
||||
// Mock batch 2 queries
|
||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue({ id: "survey-1" } as any);
|
||||
|
||||
// Start collection
|
||||
const resultPromise = collectTelemetryData(mockLicenseKey);
|
||||
|
||||
// Advance timers past the 2s query timeout
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
// Resolve the slow query after timeout
|
||||
resolveOrgCount!(1);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
// Should still return result, but with null values for timed-out queries
|
||||
expect(result.usage).toBeTruthy();
|
||||
expect(result.usage?.organizationCount).toBeNull();
|
||||
// Other queries should still work
|
||||
expect(result.usage?.memberCount).toBe(5);
|
||||
}, 15000);
|
||||
|
||||
test("should handle database errors gracefully", async () => {
|
||||
const dbError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.organization.count).mockRejectedValue(dbError);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||
|
||||
const result = await collectTelemetryData(mockLicenseKey);
|
||||
|
||||
// Should continue despite errors
|
||||
expect(result.usage).toBeTruthy();
|
||||
expect(result.usage?.organizationCount).toBeNull();
|
||||
expect(result.usage?.memberCount).toBe(5);
|
||||
});
|
||||
|
||||
test("should detect feature usage correctly", async () => {
|
||||
// Mock feature detection queries
|
||||
vi.mocked(prisma.surveyLanguage.findFirst).mockResolvedValue({ languageId: "en" } as any);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({
|
||||
id: "user-2",
|
||||
twoFactorEnabled: true,
|
||||
} as any);
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue({ id: "key-1" } as any);
|
||||
|
||||
// Mock all count queries to return 0 to avoid complexity
|
||||
vi.mocked(prisma.organization.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.team.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.project.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.survey.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.contact.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.segment.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.display.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.response.count).mockResolvedValue(0);
|
||||
|
||||
const result = await collectTelemetryData(mockLicenseKey);
|
||||
|
||||
expect(result.usage?.featureUsage).toBeTruthy();
|
||||
if (result.usage?.featureUsage) {
|
||||
expect(result.usage.featureUsage.multiLanguageSurveys).toBe(true);
|
||||
expect(result.usage.featureUsage.twoFA).toBe(true);
|
||||
expect(result.usage.featureUsage.apiKeys).toBe(true);
|
||||
expect(result.usage.featureUsage.sso).toBe(true); // From constants
|
||||
expect(result.usage.featureUsage.fileUpload).toBe(true); // From constants
|
||||
}
|
||||
});
|
||||
|
||||
test("should generate instance ID when no organization exists", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await collectTelemetryData(mockLicenseKey);
|
||||
|
||||
expect(result.usage).toBeTruthy();
|
||||
expect(result.usage?.instanceId).toBeTruthy();
|
||||
expect(typeof result.usage?.instanceId).toBe("string");
|
||||
});
|
||||
|
||||
test("should handle total timeout gracefully", async () => {
|
||||
let resolveOrgFind: (value: any) => void;
|
||||
const orgFindPromise = new Promise<any>((resolve) => {
|
||||
resolveOrgFind = resolve;
|
||||
});
|
||||
vi.mocked(prisma.organization.findFirst).mockImplementation(() => orgFindPromise as any);
|
||||
|
||||
let resolveOrgCount: (value: number) => void;
|
||||
const orgCountPromise = new Promise<number>((resolve) => {
|
||||
resolveOrgCount = resolve;
|
||||
});
|
||||
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
|
||||
|
||||
// Start collection
|
||||
const resultPromise = collectTelemetryData(mockLicenseKey);
|
||||
|
||||
// Advance timers past the 15s total timeout
|
||||
await vi.advanceTimersByTimeAsync(16000);
|
||||
|
||||
resolveOrgFind!({ id: mockOrganizationId, createdAt: new Date() });
|
||||
resolveOrgCount!(1);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
// Should return usage object (may be empty or partial)
|
||||
expect(result.usage).toBeTruthy();
|
||||
}, 20000);
|
||||
});
|
||||
});
|
||||
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal file
630
apps/web/modules/ee/license-check/lib/telemetry.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import "server-only";
|
||||
import crypto from "node:crypto";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
AIRTABLE_CLIENT_ID,
|
||||
AUDIT_LOG_ENABLED,
|
||||
AZURE_OAUTH_ENABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_RECAPTCHA_CONFIGURED,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
NOTION_OAUTH_CLIENT_SECRET,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
SAML_OAUTH_ENABLED,
|
||||
SLACK_CLIENT_ID,
|
||||
SLACK_CLIENT_SECRET,
|
||||
} from "@/lib/constants";
|
||||
|
||||
const CONFIG = {
|
||||
QUERY_TIMEOUT_MS: 2000,
|
||||
BATCH_TIMEOUT_MS: 5000,
|
||||
TOTAL_TIMEOUT_MS: 15000,
|
||||
} as const;
|
||||
|
||||
export type TelemetryUsage = {
|
||||
instanceId: string;
|
||||
organizationCount: number | null;
|
||||
memberCount: number | null;
|
||||
teamCount: number | null;
|
||||
projectCount: number | null;
|
||||
surveyCount: number | null;
|
||||
activeSurveyCount: number | null;
|
||||
completedSurveyCount: number | null;
|
||||
responseCountAllTime: number | null;
|
||||
responseCountLast30d: number | null;
|
||||
surveyDisplayCount: number | null;
|
||||
contactCount: number | null;
|
||||
segmentCount: number | null;
|
||||
featureUsage: {
|
||||
multiLanguageSurveys: boolean | null;
|
||||
advancedTargeting: boolean | null;
|
||||
sso: boolean | null;
|
||||
saml: boolean | null;
|
||||
twoFA: boolean | null;
|
||||
apiKeys: boolean | null;
|
||||
teamRoles: boolean | null;
|
||||
auditLogs: boolean | null;
|
||||
whitelabel: boolean | null;
|
||||
removeBranding: boolean | null;
|
||||
fileUpload: boolean | null;
|
||||
spamProtection: boolean | null;
|
||||
quotas: boolean | null;
|
||||
};
|
||||
activeIntegrations: {
|
||||
airtable: boolean | null;
|
||||
slack: boolean | null;
|
||||
notion: boolean | null;
|
||||
googleSheets: boolean | null;
|
||||
zapier: boolean | null;
|
||||
make: boolean | null;
|
||||
n8n: boolean | null;
|
||||
webhook: boolean | null;
|
||||
};
|
||||
temporal: {
|
||||
instanceCreatedAt: string | null;
|
||||
newestSurveyDate: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type TelemetryData = {
|
||||
licenseKey: string | null;
|
||||
usage: TelemetryUsage | null;
|
||||
};
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T | null>((resolve) => {
|
||||
setTimeout(() => {
|
||||
logger.warn({ timeoutMs }, "Query timeout exceeded");
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const safeQuery = async <T>(
|
||||
queryFn: () => Promise<T>,
|
||||
queryName: string,
|
||||
batchNumber: number
|
||||
): Promise<T | null> => {
|
||||
try {
|
||||
const result = await withTimeout(queryFn(), CONFIG.QUERY_TIMEOUT_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
queryName,
|
||||
batchNumber,
|
||||
},
|
||||
`Telemetry query failed: ${queryName}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getInstanceId = async (): Promise<string> => {
|
||||
try {
|
||||
const firstOrg = await withTimeout(
|
||||
prisma.organization.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
}),
|
||||
CONFIG.QUERY_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!firstOrg) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return crypto.createHash("sha256").update(firstOrg.id).digest("hex").substring(0, 32);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get instance ID, using random UUID");
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
};
|
||||
|
||||
const collectBatch1 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||
const queries = [
|
||||
{
|
||||
name: "organizationCount",
|
||||
fn: () => prisma.organization.count(),
|
||||
},
|
||||
{
|
||||
name: "memberCount",
|
||||
fn: () => prisma.user.count(),
|
||||
},
|
||||
{
|
||||
name: "teamCount",
|
||||
fn: () => prisma.team.count(),
|
||||
},
|
||||
{
|
||||
name: "projectCount",
|
||||
fn: () => prisma.project.count(),
|
||||
},
|
||||
{
|
||||
name: "surveyCount",
|
||||
fn: () => prisma.survey.count(),
|
||||
},
|
||||
{
|
||||
name: "contactCount",
|
||||
fn: () => prisma.contact.count(),
|
||||
},
|
||||
{
|
||||
name: "segmentCount",
|
||||
fn: () => prisma.segment.count(),
|
||||
},
|
||||
{
|
||||
name: "surveyDisplayCount",
|
||||
fn: () => prisma.display.count(),
|
||||
},
|
||||
{
|
||||
name: "responseCountAllTime",
|
||||
fn: () => prisma.response.count(),
|
||||
},
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 1)));
|
||||
|
||||
const batchResult: Partial<TelemetryUsage> = {};
|
||||
for (const [index, result] of results.entries()) {
|
||||
const key = queries[index].name as keyof TelemetryUsage;
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
(batchResult as Record<string, unknown>)[key] = result.value;
|
||||
} else {
|
||||
(batchResult as Record<string, unknown>)[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return batchResult;
|
||||
};
|
||||
|
||||
const collectBatch2 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const queries = [
|
||||
{
|
||||
name: "activeSurveyCount",
|
||||
fn: () => prisma.survey.count({ where: { status: "inProgress" } }),
|
||||
},
|
||||
{
|
||||
name: "completedSurveyCount",
|
||||
fn: () => prisma.survey.count({ where: { status: "completed" } }),
|
||||
},
|
||||
{
|
||||
name: "responseCountLast30d",
|
||||
fn: () =>
|
||||
prisma.response.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 2)));
|
||||
|
||||
const batchResult: Partial<TelemetryUsage> = {};
|
||||
for (const [index, result] of results.entries()) {
|
||||
const key = queries[index].name as keyof TelemetryUsage;
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
(batchResult as Record<string, unknown>)[key] = result.value;
|
||||
} else {
|
||||
(batchResult as Record<string, unknown>)[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return batchResult;
|
||||
};
|
||||
|
||||
const collectBatch3 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||
const queries = [
|
||||
{
|
||||
name: "multiLanguageSurveys",
|
||||
fn: async () => {
|
||||
const result = await prisma.surveyLanguage.findFirst({ select: { languageId: true } });
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "advancedTargeting",
|
||||
fn: async () => {
|
||||
const [hasFilters, hasSegments] = await Promise.all([
|
||||
prisma.surveyAttributeFilter.findFirst({ select: { id: true } }),
|
||||
prisma.survey.findFirst({ where: { segmentId: { not: null } } }),
|
||||
]);
|
||||
return hasFilters !== null || hasSegments !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "twoFA",
|
||||
fn: async () => {
|
||||
const result = await prisma.user.findFirst({
|
||||
where: { twoFactorEnabled: true },
|
||||
select: { id: true },
|
||||
});
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "apiKeys",
|
||||
fn: async () => {
|
||||
const result = await prisma.apiKey.findFirst({ select: { id: true } });
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "teamRoles",
|
||||
fn: async () => {
|
||||
const result = await prisma.teamUser.findFirst({ select: { teamId: true } });
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitelabel",
|
||||
fn: async () => {
|
||||
const organizations = await prisma.organization.findMany({
|
||||
select: { whitelabel: true },
|
||||
take: 100,
|
||||
});
|
||||
return organizations.some((org) => {
|
||||
const whitelabel = org.whitelabel as Record<string, unknown> | null;
|
||||
return whitelabel !== null && typeof whitelabel === "object" && Object.keys(whitelabel).length > 0;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removeBranding",
|
||||
fn: async () => {
|
||||
const organizations = await prisma.organization.findMany({
|
||||
select: { billing: true },
|
||||
take: 100,
|
||||
});
|
||||
return organizations.some((org) => {
|
||||
const billing = org.billing as { plan?: string; removeBranding?: boolean } | null;
|
||||
return billing?.removeBranding === true;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "quotas",
|
||||
fn: async () => {
|
||||
const result = await prisma.surveyQuota.findFirst({ select: { id: true } });
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 3)));
|
||||
|
||||
const batchResult: Partial<TelemetryUsage> = {
|
||||
featureUsage: {
|
||||
multiLanguageSurveys: null,
|
||||
advancedTargeting: null,
|
||||
sso: null,
|
||||
saml: null,
|
||||
twoFA: null,
|
||||
apiKeys: null,
|
||||
teamRoles: null,
|
||||
auditLogs: null,
|
||||
whitelabel: null,
|
||||
removeBranding: null,
|
||||
fileUpload: null,
|
||||
spamProtection: null,
|
||||
quotas: null,
|
||||
},
|
||||
};
|
||||
|
||||
const featureMap: Record<string, keyof TelemetryUsage["featureUsage"]> = {
|
||||
multiLanguageSurveys: "multiLanguageSurveys",
|
||||
advancedTargeting: "advancedTargeting",
|
||||
twoFA: "twoFA",
|
||||
apiKeys: "apiKeys",
|
||||
teamRoles: "teamRoles",
|
||||
whitelabel: "whitelabel",
|
||||
removeBranding: "removeBranding",
|
||||
quotas: "quotas",
|
||||
};
|
||||
|
||||
for (const [index, result] of results.entries()) {
|
||||
const queryName = queries[index].name;
|
||||
const featureKey = featureMap[queryName];
|
||||
if (featureKey && batchResult.featureUsage) {
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
batchResult.featureUsage[featureKey] = result.value;
|
||||
} else {
|
||||
batchResult.featureUsage[featureKey] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batchResult.featureUsage) {
|
||||
batchResult.featureUsage.sso =
|
||||
GOOGLE_OAUTH_ENABLED ||
|
||||
GITHUB_OAUTH_ENABLED ||
|
||||
AZURE_OAUTH_ENABLED ||
|
||||
OIDC_OAUTH_ENABLED ||
|
||||
SAML_OAUTH_ENABLED;
|
||||
batchResult.featureUsage.saml = SAML_OAUTH_ENABLED;
|
||||
batchResult.featureUsage.auditLogs = AUDIT_LOG_ENABLED;
|
||||
batchResult.featureUsage.fileUpload = IS_STORAGE_CONFIGURED;
|
||||
batchResult.featureUsage.spamProtection = IS_RECAPTCHA_CONFIGURED;
|
||||
}
|
||||
|
||||
return batchResult;
|
||||
};
|
||||
|
||||
const collectBatch4 = async (): Promise<Partial<TelemetryUsage>> => {
|
||||
const booleanQueries = [
|
||||
{
|
||||
name: "zapier",
|
||||
fn: async (): Promise<boolean> => {
|
||||
const result = await prisma.webhook.findFirst({
|
||||
where: { source: "zapier" },
|
||||
select: { id: true },
|
||||
});
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "make",
|
||||
fn: async (): Promise<boolean> => {
|
||||
const result = await prisma.webhook.findFirst({
|
||||
where: { source: "make" },
|
||||
select: { id: true },
|
||||
});
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "n8n",
|
||||
fn: async (): Promise<boolean> => {
|
||||
const result = await prisma.webhook.findFirst({
|
||||
where: { source: "n8n" },
|
||||
select: { id: true },
|
||||
});
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webhook",
|
||||
fn: async (): Promise<boolean> => {
|
||||
const result = await prisma.webhook.findFirst({
|
||||
where: { source: "user" },
|
||||
select: { id: true },
|
||||
});
|
||||
return result !== null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const stringQueries = [
|
||||
{
|
||||
name: "instanceCreatedAt",
|
||||
fn: async (): Promise<string | null> => {
|
||||
const result = await prisma.user.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
return result?.createdAt.toISOString() ?? null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "newestSurveyDate",
|
||||
fn: async (): Promise<string | null> => {
|
||||
const result = await prisma.survey.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
return result?.createdAt.toISOString() ?? null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const booleanResults = await Promise.allSettled(
|
||||
booleanQueries.map((query) => safeQuery(query.fn, query.name, 4))
|
||||
);
|
||||
const stringResults = await Promise.allSettled(
|
||||
stringQueries.map((query) => safeQuery(query.fn, query.name, 4))
|
||||
);
|
||||
|
||||
const batchResult: Partial<TelemetryUsage> = {
|
||||
activeIntegrations: {
|
||||
airtable: null,
|
||||
slack: null,
|
||||
notion: null,
|
||||
googleSheets: null,
|
||||
zapier: null,
|
||||
make: null,
|
||||
n8n: null,
|
||||
webhook: null,
|
||||
},
|
||||
temporal: {
|
||||
instanceCreatedAt: null,
|
||||
newestSurveyDate: null,
|
||||
},
|
||||
};
|
||||
|
||||
const integrationMap: Record<string, keyof TelemetryUsage["activeIntegrations"]> = {
|
||||
zapier: "zapier",
|
||||
make: "make",
|
||||
n8n: "n8n",
|
||||
webhook: "webhook",
|
||||
};
|
||||
|
||||
for (const [index, result] of booleanResults.entries()) {
|
||||
const queryName = booleanQueries[index].name;
|
||||
const integrationKey = integrationMap[queryName];
|
||||
if (integrationKey && batchResult.activeIntegrations) {
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
batchResult.activeIntegrations[integrationKey] = result.value;
|
||||
} else {
|
||||
batchResult.activeIntegrations[integrationKey] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, result] of stringResults.entries()) {
|
||||
const queryName = stringQueries[index].name;
|
||||
if (batchResult.temporal && (queryName === "instanceCreatedAt" || queryName === "newestSurveyDate")) {
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
batchResult.temporal[queryName] = result.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batchResult.activeIntegrations) {
|
||||
batchResult.activeIntegrations.airtable = !!AIRTABLE_CLIENT_ID;
|
||||
batchResult.activeIntegrations.slack = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
|
||||
batchResult.activeIntegrations.notion = !!(NOTION_OAUTH_CLIENT_ID && NOTION_OAUTH_CLIENT_SECRET);
|
||||
batchResult.activeIntegrations.googleSheets = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
return batchResult;
|
||||
};
|
||||
|
||||
export const collectTelemetryData = async (licenseKey: string | null): Promise<TelemetryData> => {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return {
|
||||
licenseKey,
|
||||
usage: null,
|
||||
};
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const instanceId = await getInstanceId();
|
||||
|
||||
const batchPromises = [
|
||||
Promise.race([
|
||||
collectBatch1(),
|
||||
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||
setTimeout(() => {
|
||||
logger.warn("Batch 1 timeout");
|
||||
resolve({});
|
||||
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||
}),
|
||||
]),
|
||||
Promise.race([
|
||||
collectBatch2(),
|
||||
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||
setTimeout(() => {
|
||||
logger.warn("Batch 2 timeout");
|
||||
resolve({});
|
||||
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||
}),
|
||||
]),
|
||||
Promise.race([
|
||||
collectBatch3(),
|
||||
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||
setTimeout(() => {
|
||||
logger.warn("Batch 3 timeout");
|
||||
resolve({});
|
||||
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||
}),
|
||||
]),
|
||||
Promise.race([
|
||||
collectBatch4(),
|
||||
new Promise<Partial<TelemetryUsage>>((resolve) => {
|
||||
setTimeout(() => {
|
||||
logger.warn("Batch 4 timeout");
|
||||
resolve({});
|
||||
}, CONFIG.BATCH_TIMEOUT_MS);
|
||||
}),
|
||||
]),
|
||||
];
|
||||
|
||||
const batchResults = await Promise.race([
|
||||
Promise.all(batchPromises),
|
||||
new Promise<Partial<TelemetryUsage>[]>((resolve) => {
|
||||
setTimeout(() => {
|
||||
logger.warn("Total telemetry collection timeout");
|
||||
resolve([{}, {}, {}, {}]);
|
||||
}, CONFIG.TOTAL_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
|
||||
const usage: TelemetryUsage = {
|
||||
instanceId,
|
||||
organizationCount: null,
|
||||
memberCount: null,
|
||||
teamCount: null,
|
||||
projectCount: null,
|
||||
surveyCount: null,
|
||||
activeSurveyCount: null,
|
||||
completedSurveyCount: null,
|
||||
responseCountAllTime: null,
|
||||
responseCountLast30d: null,
|
||||
surveyDisplayCount: null,
|
||||
contactCount: null,
|
||||
segmentCount: null,
|
||||
featureUsage: {
|
||||
multiLanguageSurveys: null,
|
||||
advancedTargeting: null,
|
||||
sso: null,
|
||||
saml: null,
|
||||
twoFA: null,
|
||||
apiKeys: null,
|
||||
teamRoles: null,
|
||||
auditLogs: null,
|
||||
whitelabel: null,
|
||||
removeBranding: null,
|
||||
fileUpload: null,
|
||||
spamProtection: null,
|
||||
quotas: null,
|
||||
},
|
||||
activeIntegrations: {
|
||||
airtable: null,
|
||||
slack: null,
|
||||
notion: null,
|
||||
googleSheets: null,
|
||||
zapier: null,
|
||||
make: null,
|
||||
n8n: null,
|
||||
webhook: null,
|
||||
},
|
||||
temporal: {
|
||||
instanceCreatedAt: null,
|
||||
newestSurveyDate: null,
|
||||
},
|
||||
};
|
||||
|
||||
for (const batchResult of batchResults) {
|
||||
Object.assign(usage, batchResult);
|
||||
if (batchResult.featureUsage) {
|
||||
Object.assign(usage.featureUsage, batchResult.featureUsage);
|
||||
}
|
||||
if (batchResult.activeIntegrations) {
|
||||
Object.assign(usage.activeIntegrations, batchResult.activeIntegrations);
|
||||
}
|
||||
if (batchResult.temporal) {
|
||||
Object.assign(usage.temporal, batchResult.temporal);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info({ duration, instanceId }, "Telemetry collection completed");
|
||||
|
||||
return {
|
||||
licenseKey,
|
||||
usage,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, duration: Date.now() - startTime }, "Telemetry collection failed completely");
|
||||
return {
|
||||
licenseKey,
|
||||
usage: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { WebhookModal } from "@/modules/integrations/webhooks/components/webhook-detail-modal";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface WebhookTableProps {
|
||||
environment: TEnvironment;
|
||||
@@ -46,12 +46,7 @@ export const WebhookTable = ({
|
||||
return (
|
||||
<>
|
||||
{webhooks.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.webhooks.empty_webhook_message")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.webhooks.empty_webhook_message")} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
{TableHeading}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TTag, TTagsCount } from "@formbricks/types/tags";
|
||||
import { SingleTag } from "@/modules/projects/settings/tags/components/single-tag";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface EditTagsWrapperProps {
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
environmentTagsCount: TTagsCount;
|
||||
isReadOnly: boolean;
|
||||
@@ -16,7 +14,12 @@ interface EditTagsWrapperProps {
|
||||
|
||||
export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { environment, environmentTags, environmentTagsCount, isReadOnly } = props;
|
||||
const { environmentTags, environmentTagsCount, isReadOnly } = props;
|
||||
|
||||
if (!environmentTags?.length) {
|
||||
return <EmptyState text={t("environments.project.tags.no_tag_found")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
|
||||
@@ -27,11 +30,7 @@ export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!environmentTags?.length ? (
|
||||
<EmptySpaceFiller environment={environment} type="tag" noWidgetRequired />
|
||||
) : null}
|
||||
|
||||
{environmentTags?.map((tag) => (
|
||||
{environmentTags.map((tag) => (
|
||||
<SingleTag
|
||||
key={tag.id}
|
||||
tagId={tag.id}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const TagsPage = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [tags, environmentTagsCount] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
@@ -28,7 +28,6 @@ export const TagsPage = async (props) => {
|
||||
title={t("environments.project.tags.manage_tags")}
|
||||
description={t("environments.project.tags.manage_tags_description")}>
|
||||
<EditTagsWrapper
|
||||
environment={environment}
|
||||
environmentTags={tags}
|
||||
environmentTagsCount={environmentTagsCount}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -9,16 +9,11 @@ import {
|
||||
getOrganizationByEnvironmentId,
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { selectSurvey } from "@/modules/survey/lib/survey";
|
||||
import { createSurvey, handleTriggerUpdates } from "./survey";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
capturePosthogEnvironmentEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
checkForInvalidImagesInQuestions: vi.fn(),
|
||||
}));
|
||||
@@ -121,11 +116,6 @@ describe("survey module", () => {
|
||||
"user-123",
|
||||
"org-123"
|
||||
);
|
||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(
|
||||
environmentId,
|
||||
"survey created",
|
||||
expect.objectContaining({ surveyId: "survey-123" })
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("survey-123");
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getOrganizationByEnvironmentId,
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
@@ -122,11 +121,6 @@ export const createSurvey = async (
|
||||
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
||||
surveyId: survey.id,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return transformedSurvey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user