mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 19:30:41 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 506323aed4 |
@@ -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; // 1 minute (seconds for client)
|
||||
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
||||
|
||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
||||
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||||
|
||||
// HTTP cache headers (seconds)
|
||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
||||
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
||||
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||
```
|
||||
|
||||
|
||||
@@ -52,15 +52,22 @@ export const EnvironmentContextWrapper = ({
|
||||
organization,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
const environmentContextValue = useMemo(() => {
|
||||
if (!environment?.id || !project?.id || !organization?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project, organization]
|
||||
);
|
||||
};
|
||||
}, [environment, project, organization]);
|
||||
|
||||
if (!environmentContextValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -20,8 +21,18 @@ const EnvLayout = async (props: {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
// Handle AuthorizationError gracefully during rapid navigation
|
||||
let layoutData;
|
||||
try {
|
||||
layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
} catch (error) {
|
||||
// If user doesn't have access, show not found instead of crashing
|
||||
if (error instanceof AuthorizationError) {
|
||||
return notFound();
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
export default function EnvironmentLoading() {
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen items-center justify-center">
|
||||
<LoadingSpinner className="h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
@@ -15,6 +16,7 @@ interface AirtableWrapperProps {
|
||||
airtableArray: TIntegrationItem[];
|
||||
airtableIntegration?: TIntegrationAirtable;
|
||||
surveys: TSurvey[];
|
||||
environment: TEnvironment;
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
@@ -25,6 +27,7 @@ export const AirtableWrapper = ({
|
||||
airtableArray,
|
||||
airtableIntegration,
|
||||
surveys,
|
||||
environment,
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
@@ -45,6 +48,7 @@ export const AirtableWrapper = ({
|
||||
<ManageIntegration
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
airtableIntegration={airtableIntegration}
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
|
||||
+10
-3
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -14,11 +15,12 @@ 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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
environment: TEnvironment;
|
||||
environmentId: string;
|
||||
setIsConnected: (data: boolean) => void;
|
||||
surveys: TSurvey[];
|
||||
@@ -27,7 +29,7 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -130,7 +132,12 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const Page = async (props) => {
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ export const GoogleSheetWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
+10
-2
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -14,9 +15,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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||
setIsConnected: (v: boolean) => void;
|
||||
@@ -25,6 +27,7 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
googleSheetIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -87,7 +90,12 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
+1
-2
@@ -13,7 +13,6 @@ import {
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import {
|
||||
ERRORS,
|
||||
@@ -123,7 +122,7 @@ export const AddIntegrationModal = ({
|
||||
const questions = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getTextContent(getLocalizedValue(q.headline, "default")),
|
||||
name: getLocalizedValue(q.headline, "default"),
|
||||
type: q.type,
|
||||
}))
|
||||
: [];
|
||||
|
||||
+10
-2
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -11,10 +12,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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
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>>;
|
||||
@@ -26,6 +28,7 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
notionIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -98,7 +101,12 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
+1
@@ -64,6 +64,7 @@ export const NotionWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
notionIntegration={notionIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
+10
-2
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -11,9 +12,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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
slackIntegration: TIntegrationSlack;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -27,6 +29,7 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
slackIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -103,7 +106,12 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
+1
@@ -78,6 +78,7 @@ export const SlackWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
slackIntegration={slackIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{appLanguages.map((lang) => (
|
||||
|
||||
+13
-26
@@ -26,7 +26,6 @@ interface ResponsePageProps {
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
initialResponses?: TResponseWithQuotas[];
|
||||
}
|
||||
|
||||
export const ResponsePage = ({
|
||||
@@ -40,12 +39,11 @@ export const ResponsePage = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
initialResponses = [],
|
||||
}: ResponsePageProps) => {
|
||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
@@ -58,7 +56,6 @@ export const ResponsePage = ({
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
if (page === null) return;
|
||||
const newPage = page + 1;
|
||||
|
||||
let newResponses: TResponseWithQuotas[] = [];
|
||||
@@ -97,14 +94,9 @@ export const ResponsePage = ({
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFilteredResponses = async () => {
|
||||
const fetchInitialResponses = async () => {
|
||||
try {
|
||||
// skip call for initial mount
|
||||
if (page === null) {
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
setIsFetchingFirstPage(true);
|
||||
setFetchingFirstPage(true);
|
||||
let responses: TResponseWithQuotas[] = [];
|
||||
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
@@ -118,24 +110,19 @@ export const ResponsePage = ({
|
||||
|
||||
if (responses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setHasMore(true);
|
||||
}
|
||||
setResponses(responses);
|
||||
} finally {
|
||||
setIsFetchingFirstPage(false);
|
||||
setFetchingFirstPage(false);
|
||||
}
|
||||
};
|
||||
fetchInitialResponses();
|
||||
}, [surveyId, filters, responsesPerPage]);
|
||||
|
||||
// Only fetch if filters are applied (not on initial mount with no filters)
|
||||
const hasFilters =
|
||||
(selectedFilter && Object.keys(selectedFilter).length > 0) ||
|
||||
(dateRange && (dateRange.from || dateRange.to));
|
||||
|
||||
if (hasFilters) {
|
||||
fetchFilteredResponses();
|
||||
}
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+21
-18
@@ -2,8 +2,9 @@ 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, getResponses } from "@/lib/response/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -13,6 +14,7 @@ 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";
|
||||
@@ -21,43 +23,44 @@ const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
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(),
|
||||
]);
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_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 organizationBilling = await getOrganizationBilling(organization.id);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
if (!organizationId) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||
|
||||
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
||||
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
|
||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -71,6 +74,7 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
@@ -90,7 +94,6 @@ const Page = async (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
initialResponses={initialResponses}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
+29
-119
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
@@ -11,12 +9,8 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
} 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 { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface NPSSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryNps;
|
||||
@@ -30,20 +24,8 @@ 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<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const applyFilter = (group: string) => {
|
||||
const filters = {
|
||||
promoters: {
|
||||
@@ -79,110 +61,38 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<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 "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<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")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<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
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary[group]?.count}{" "}
|
||||
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<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
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<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 (
|
||||
<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>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary[group]?.count}{" "}
|
||||
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
+41
-170
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
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 { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface RatingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRating;
|
||||
@@ -34,8 +29,6 @@ interface RatingSummaryProps {
|
||||
|
||||
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = questionSummary.question.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
@@ -49,174 +42,52 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<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")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<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")}
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 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>
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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;
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.rating}
|
||||
className="flex flex-col items-center justify-center py-2"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight:
|
||||
index < questionSummary.choices.length - 1
|
||||
? "1px solid rgb(226, 232, 240)"
|
||||
: "none",
|
||||
}}>
|
||||
<div className="mb-1 flex items-center justify-center">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={false}
|
||||
variant="aggregated"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-600">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<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) => (
|
||||
<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>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||
<div key="dismissed">
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
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}`} />;
|
||||
};
|
||||
+11
-11
@@ -3,14 +3,9 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
@@ -34,7 +29,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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
import { AddressSummary } from "./AddressSummary";
|
||||
|
||||
@@ -59,7 +54,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||
const value = {
|
||||
id: questionId,
|
||||
label: getTextContent(getLocalizedValue(label, "default")),
|
||||
label: getLocalizedValue(label, "default"),
|
||||
questionType: questionType,
|
||||
type: OptionsType.QUESTIONS,
|
||||
};
|
||||
@@ -108,7 +103,12 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
) : summary.length === 0 ? (
|
||||
<SkeletonLoader type="summary" />
|
||||
) : responseCount === 0 ? (
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||
/>
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
|
||||
+3
-1
@@ -29,6 +29,7 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -47,6 +48,7 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
@@ -167,7 +169,7 @@ export const SurveyAnalysisCTA = ({
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly,
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
|
||||
-684
@@ -2334,147 +2334,6 @@ describe("NPS question type tests", () => {
|
||||
// Score should be -100 since all valid responses are detractors
|
||||
expect(summary[0].score).toBe(-100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => {
|
||||
const question = {
|
||||
id: "nps-q1",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend us?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
expect(summary[0].choices).toBeDefined();
|
||||
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
|
||||
|
||||
// Verify specific scores
|
||||
const score0 = summary[0].choices.find((c: any) => c.rating === 0);
|
||||
expect(score0.count).toBe(1);
|
||||
expect(score0.percentage).toBe(20); // 1/5 * 100
|
||||
|
||||
const score5 = summary[0].choices.find((c: any) => c.rating === 5);
|
||||
expect(score5.count).toBe(1);
|
||||
expect(score5.percentage).toBe(20);
|
||||
|
||||
const score7 = summary[0].choices.find((c: any) => c.rating === 7);
|
||||
expect(score7.count).toBe(1);
|
||||
expect(score7.percentage).toBe(20);
|
||||
|
||||
const score9 = summary[0].choices.find((c: any) => c.rating === 9);
|
||||
expect(score9.count).toBe(1);
|
||||
expect(score9.percentage).toBe(20);
|
||||
|
||||
const score10 = summary[0].choices.find((c: any) => c.rating === 10);
|
||||
expect(score10.count).toBe(1);
|
||||
expect(score10.percentage).toBe(20);
|
||||
|
||||
// Verify scores with no responses have 0 count
|
||||
const score1 = summary[0].choices.find((c: any) => c.rating === 1);
|
||||
expect(score1.count).toBe(0);
|
||||
expect(score1.percentage).toBe(0);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => {
|
||||
const question = {
|
||||
id: "nps-q1",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend us?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses: any[] = [];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
expect(summary[0].choices).toBeDefined();
|
||||
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
|
||||
|
||||
// All scores should have 0 count and percentage
|
||||
summary[0].choices.forEach((choice: any) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rating question type tests", () => {
|
||||
@@ -2698,549 +2557,6 @@ describe("Rating question type tests", () => {
|
||||
// Verify dismissed is 0
|
||||
expect(summary[0].dismissed.count).toBe(0);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 3,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 3: satisfied = score 3
|
||||
// 2 out of 3 responses are satisfied (score 3)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67); // Math.round((2/3) * 100)
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 4,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 4: satisfied = scores 3-4
|
||||
// 2 out of 3 responses are satisfied (scores 3 and 4)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 5: satisfied = scores 4-5
|
||||
// 2 out of 3 responses are satisfied (scores 4 and 5)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 6,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 6: satisfied = scores 5-6
|
||||
// 2 out of 3 responses are satisfied (scores 5 and 6)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 7,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 7: satisfied = scores 6-7
|
||||
// 2 out of 3 responses are satisfied (scores 6 and 7)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 10,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 10: satisfied = scores 8-10
|
||||
// 3 out of 4 responses are satisfied (scores 8, 9, 10)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(3);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(75);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 5: satisfied = scores 4-5
|
||||
// All 2 responses are satisfied
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} 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,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 5: satisfied = scores 4-5
|
||||
// None of the responses are satisfied (all are 1, 2, or 3)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(0);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses: any[] = [];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
expect(summary[0].csat.satisfiedCount).toBe(0);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PictureSelection question type tests", () => {
|
||||
|
||||
+1
-38
@@ -532,31 +532,13 @@ export const getQuestionSummary = async (
|
||||
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
rating: Number.parseInt(label),
|
||||
rating: parseInt(label),
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate CSAT based on range
|
||||
let satisfiedCount = 0;
|
||||
if (range === 3) {
|
||||
satisfiedCount = choiceCountMap[3] || 0;
|
||||
} else if (range === 4) {
|
||||
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
|
||||
} else if (range === 5) {
|
||||
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
|
||||
} else if (range === 6) {
|
||||
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
|
||||
} else if (range === 7) {
|
||||
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
|
||||
} else if (range === 10) {
|
||||
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
|
||||
}
|
||||
const satisfiedPercentage =
|
||||
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
@@ -566,10 +548,6 @@ export const getQuestionSummary = async (
|
||||
dismissed: {
|
||||
count: dismissed,
|
||||
},
|
||||
csat: {
|
||||
satisfiedCount,
|
||||
satisfiedPercentage,
|
||||
},
|
||||
});
|
||||
|
||||
values = [];
|
||||
@@ -585,17 +563,10 @@ export const getQuestionSummary = async (
|
||||
score: 0,
|
||||
};
|
||||
|
||||
// Track individual score counts (0-10)
|
||||
const scoreCountMap: Record<number, number> = {};
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
scoreCountMap[i] = 0;
|
||||
}
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (typeof value === "number") {
|
||||
data.total++;
|
||||
scoreCountMap[value]++;
|
||||
if (value >= 9) {
|
||||
data.promoters++;
|
||||
} else if (value >= 7) {
|
||||
@@ -614,13 +585,6 @@ export const getQuestionSummary = async (
|
||||
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
||||
: 0;
|
||||
|
||||
// Build choices array with individual score breakdown
|
||||
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
|
||||
rating: Number.parseInt(rating),
|
||||
count,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
|
||||
}));
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
@@ -643,7 +607,6 @@ export const getQuestionSummary = async (
|
||||
count: data.dismissed,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||
},
|
||||
choices,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ 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
-3
@@ -217,13 +217,11 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
setFilterValue(selectedFilter);
|
||||
}, [selectedFilter]);
|
||||
|
||||
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
@@ -29,38 +28,15 @@ export const GET = withV1ApiWrapper({
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// Basic type check for environmentId
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
if (typeof params.environmentId !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("Environment ID is required", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = params.environmentId.trim();
|
||||
|
||||
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
|
||||
// This catches all invalid formats including:
|
||||
// - null/undefined passed as string "null" or "undefined"
|
||||
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
|
||||
// - Empty or whitespace-only IDs
|
||||
// - Any other invalid CUID v1 format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
if (!cuidValidation.success) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
url: req.url,
|
||||
validationError: cuidValidation.error.errors[0]?.message,
|
||||
},
|
||||
"Invalid CUID v1 format detected"
|
||||
);
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(environmentId);
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
return {
|
||||
@@ -70,12 +46,12 @@ export const GET = withV1ApiWrapper({
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// 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"
|
||||
// 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"
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
@@ -51,7 +51,7 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
@@ -43,7 +43,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
|
||||
@@ -1504,7 +1504,7 @@ const docsFeedback = (t: TFunction): TTemplate => {
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.docs_feedback_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "url",
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
|
||||
+1
-13
@@ -7,19 +7,7 @@
|
||||
},
|
||||
"locale": {
|
||||
"source": "en-US",
|
||||
"targets": [
|
||||
"de-DE",
|
||||
"fr-FR",
|
||||
"ja-JP",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"cs-CZ"
|
||||
]
|
||||
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW", "nl-NL"]
|
||||
},
|
||||
"version": 1.8
|
||||
}
|
||||
|
||||
+8
-23
@@ -126,7 +126,6 @@ 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
|
||||
@@ -184,7 +183,6 @@ 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
|
||||
@@ -193,7 +191,6 @@ 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
|
||||
@@ -401,7 +398,6 @@ checksums:
|
||||
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
||||
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
||||
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
||||
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||
@@ -495,7 +491,6 @@ 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
|
||||
@@ -526,7 +521,6 @@ 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
|
||||
@@ -563,18 +557,9 @@ 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
|
||||
@@ -737,8 +722,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: 3368e4a8090b7684117a16c94f0c409c
|
||||
environments/project/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
|
||||
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
|
||||
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
|
||||
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
|
||||
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
|
||||
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
@@ -823,6 +808,7 @@ 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
|
||||
@@ -1252,7 +1238,7 @@ checksums:
|
||||
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
|
||||
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
|
||||
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
|
||||
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
|
||||
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
|
||||
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
|
||||
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
||||
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479
|
||||
@@ -1611,7 +1597,7 @@ checksums:
|
||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||
environments/surveys/responses/person_attributes: 07ae67ae73d7a2a7c67008694a83f0a3
|
||||
environments/surveys/responses/person_attributes: 8f7f8a9040ce8efb3cb54ce33b590866
|
||||
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
|
||||
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
|
||||
@@ -1706,7 +1692,6 @@ 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
|
||||
@@ -1730,6 +1715,7 @@ 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
|
||||
@@ -1761,7 +1747,7 @@ checksums:
|
||||
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
|
||||
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
|
||||
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
|
||||
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
|
||||
environments/surveys/summary/install_widget: 55d403de32e3d0da7513ab199f1d1934
|
||||
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
|
||||
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
|
||||
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
|
||||
@@ -1774,7 +1760,6 @@ 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
|
||||
@@ -1784,7 +1769,6 @@ 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
|
||||
@@ -1800,6 +1784,7 @@ 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
|
||||
|
||||
@@ -175,8 +175,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"cs-CZ",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
@@ -138,8 +138,6 @@ export const appLanguages = [
|
||||
"ja-JP": "英語(米国)",
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
"cs-CZ": "Angličtina (USA)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -155,8 +153,6 @@ export const appLanguages = [
|
||||
"ja-JP": "ドイツ語",
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
"cs-CZ": "Němčina",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -172,8 +168,6 @@ export const appLanguages = [
|
||||
"ja-JP": "ポルトガル語(ブラジル)",
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
"cs-CZ": "Portugalština (Brazílie)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -189,8 +183,6 @@ export const appLanguages = [
|
||||
"ja-JP": "フランス語",
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
"cs-CZ": "Francouzština",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -206,8 +198,6 @@ export const appLanguages = [
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
"cs-CZ": "Čínština (tradiční)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -223,8 +213,6 @@ export const appLanguages = [
|
||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
"cs-CZ": "Portugalština (Portugalsko)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -240,8 +228,6 @@ export const appLanguages = [
|
||||
"ja-JP": "ルーマニア語",
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
"cs-CZ": "Rumunština",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -257,8 +243,6 @@ export const appLanguages = [
|
||||
"ja-JP": "日本語",
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
"cs-CZ": "Japonština",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -274,8 +258,6 @@ export const appLanguages = [
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
"cs-CZ": "Čínština (zjednodušená)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -291,42 +273,6 @@ export const appLanguages = [
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
"cs-CZ": "Holandština",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"de-DE": "Spanisch",
|
||||
"pt-BR": "Espanhol",
|
||||
"fr-FR": "Espagnol",
|
||||
"zh-Hant-TW": "西班牙語",
|
||||
"pt-PT": "Espanhol",
|
||||
"ro-RO": "Spaniol",
|
||||
"ja-JP": "スペイン語",
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
"cs-CZ": "Španělština",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "cs-CZ",
|
||||
label: {
|
||||
"en-US": "Czech",
|
||||
"de-DE": "Tschechisch",
|
||||
"pt-BR": "Tcheco",
|
||||
"fr-FR": "Tchèque",
|
||||
"zh-Hant-TW": "捷克語",
|
||||
"pt-PT": "Checo",
|
||||
"ro-RO": "Cehă",
|
||||
"ja-JP": "チェコ語",
|
||||
"zh-Hans-CN": "捷克语",
|
||||
"nl-NL": "Tsjechisch",
|
||||
"es-ES": "Checo",
|
||||
"cs-CZ": "Čeština",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { cs, de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -103,10 +103,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return ja;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "cs-CZ":
|
||||
return cs;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,7 +153,6 @@
|
||||
"clear_filters": "Filter löschen",
|
||||
"clear_selection": "Auswahl aufheben",
|
||||
"click": "Klick",
|
||||
"click_to_filter": "Klicken zum Filtern",
|
||||
"clicks": "Klicks",
|
||||
"close": "Schließen",
|
||||
"code": "Code",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"failed_to_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",
|
||||
@@ -220,7 +218,6 @@
|
||||
"full_name": "Name",
|
||||
"gathering_responses": "Antworten sammeln",
|
||||
"general": "Allgemein",
|
||||
"generate": "Generieren",
|
||||
"go_back": "Geh zurück",
|
||||
"go_to_dashboard": "Zum Dashboard gehen",
|
||||
"hidden": "Versteckt",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "Benutzer-ID",
|
||||
"user_not_found": "Benutzer nicht gefunden",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variablen-IDs",
|
||||
"variables": "Variablen",
|
||||
"verified_email": "Verifizierte E-Mail",
|
||||
"video": "Video",
|
||||
@@ -527,7 +523,6 @@
|
||||
"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",
|
||||
@@ -558,7 +553,6 @@
|
||||
"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",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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",
|
||||
@@ -790,8 +775,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "App-Verbindung",
|
||||
"app_connection_description": "Verbinde deine App oder Website mit Formbricks.",
|
||||
"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",
|
||||
"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",
|
||||
"environment_id": "Deine Umgebungs-ID",
|
||||
"environment_id_description": "Diese ID identifiziert eindeutig diese Formbricks Umgebung.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
|
||||
@@ -884,6 +869,7 @@
|
||||
"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",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "Bearbeitungslink",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Befragten erlauben, die Sprache jederzeit zu wechseln. Benötigt mind. 2 aktive Sprachen.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
|
||||
"enable_spam_protection": "Spamschutz",
|
||||
"end_screen_card": "Abschluss-Karte",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "Nachname",
|
||||
"not_completed": "Nicht abgeschlossen ⏳",
|
||||
"os": "Betriebssystem",
|
||||
"person_attributes": "Personenattribute zum Zeitpunkt der Einreichung",
|
||||
"person_attributes": "Personenattribute",
|
||||
"phone": "Telefon",
|
||||
"respondent_skipped_questions": "Der Befragte hat diese Fragen übersprungen.",
|
||||
"response_deleted_successfully": "Antwort erfolgreich gelöscht.",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"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": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "Beinhaltet alles",
|
||||
"includes_either": "Beinhaltet entweder",
|
||||
"individual": "Individuell",
|
||||
"install_widget": "Formbricks Widget installieren",
|
||||
"is_equal_to": "Ist gleich",
|
||||
"is_less_than": "ist weniger als",
|
||||
"last_30_days": "Letzte 30 Tage",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"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",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"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",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"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,7 +153,6 @@
|
||||
"clear_filters": "Clear filters",
|
||||
"clear_selection": "Clear selection",
|
||||
"click": "Click",
|
||||
"click_to_filter": "Click to filter",
|
||||
"clicks": "Clicks",
|
||||
"close": "Close",
|
||||
"code": "Code",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
|
||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||
"expand_rows": "Expand rows",
|
||||
"failed_to_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",
|
||||
@@ -220,7 +218,6 @@
|
||||
"full_name": "Full name",
|
||||
"gathering_responses": "Gathering responses",
|
||||
"general": "General",
|
||||
"generate": "Generate",
|
||||
"go_back": "Go Back",
|
||||
"go_to_dashboard": "Go to Dashboard",
|
||||
"hidden": "Hidden",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "User ID",
|
||||
"user_not_found": "User not found",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variable IDs",
|
||||
"variables": "Variables",
|
||||
"verified_email": "Verified Email",
|
||||
"video": "Video",
|
||||
@@ -527,7 +523,6 @@
|
||||
"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",
|
||||
@@ -558,7 +553,6 @@
|
||||
"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",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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",
|
||||
@@ -790,8 +775,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 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",
|
||||
"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",
|
||||
"environment_id": "Your Environment ID",
|
||||
"environment_id_description": "This id uniquely identifies this Formbricks environment.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK is connected",
|
||||
@@ -884,6 +869,7 @@
|
||||
"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",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "Edit link",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
|
||||
"enable_spam_protection": "Spam protection",
|
||||
"end_screen_card": "End screen card",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "Last Name",
|
||||
"not_completed": "Not Completed ⏳",
|
||||
"os": "OS",
|
||||
"person_attributes": "Person attributes at time of submission",
|
||||
"person_attributes": "Person attributes",
|
||||
"phone": "Phone",
|
||||
"respondent_skipped_questions": "Respondent skipped these questions.",
|
||||
"response_deleted_successfully": "Response deleted successfully.",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"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",
|
||||
"impressions": "Impressions",
|
||||
"impressions_tooltip": "Number of times the survey has been viewed.",
|
||||
"in_app": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"no_responses_found": "No responses found",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"promoters": "Promoters",
|
||||
"qr_code": "QR code",
|
||||
"qr_code_description": "Responses collected via QR code are anonymous.",
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
|
||||
"reset_survey": "Reset survey",
|
||||
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
|
||||
"satisfied": "Satisfied",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"setup_integrations": "Setup integrations",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"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!"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,7 +153,6 @@
|
||||
"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",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
|
||||
"error_rate_limit_title": "Limite de Taux Dépassée",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"failed_to_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",
|
||||
@@ -220,7 +218,6 @@
|
||||
"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é",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "Identifiant d'utilisateur",
|
||||
"user_not_found": "Utilisateur non trouvé",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Identifiants variables",
|
||||
"variables": "Variables",
|
||||
"verified_email": "Email vérifié",
|
||||
"video": "Vidéo",
|
||||
@@ -527,7 +523,6 @@
|
||||
"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",
|
||||
@@ -558,7 +553,6 @@
|
||||
"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",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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.",
|
||||
@@ -790,8 +775,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, 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",
|
||||
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, cela peut prendre jusqu'à 5 minutes pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks. Ce délai est dû à une limitation de notre système de mise en cache actuel. Nous retravaillons activement le cache et publierons une correction dans Formbricks 4.0.",
|
||||
"cache_update_delay_title": "Les modifications seront reflétées après 5 minutes en raison de la mise en cache",
|
||||
"environment_id": "Identifiant de votre environnement",
|
||||
"environment_id_description": "Cet identifiant unique est attribué à votre environnement Formbricks.",
|
||||
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
|
||||
@@ -884,6 +869,7 @@
|
||||
"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",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "Modifier le lien",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux répondants de changer de langue à tout moment. Nécessite au moins 2 langues actives.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
|
||||
"enable_spam_protection": "Protection contre le spam",
|
||||
"end_screen_card": "Carte de fin d'écran",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "Nom de famille",
|
||||
"not_completed": "Non terminé ⏳",
|
||||
"os": "Système d'exploitation",
|
||||
"person_attributes": "Attributs de la personne au moment de la soumission",
|
||||
"person_attributes": "Attributs de la personne",
|
||||
"phone": "Téléphone",
|
||||
"respondent_skipped_questions": "Le répondant a sauté ces questions.",
|
||||
"response_deleted_successfully": "Réponse supprimée avec succès.",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"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": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "Comprend tous",
|
||||
"includes_either": "Comprend soit",
|
||||
"individual": "Individuel",
|
||||
"install_widget": "Installer le widget Formbricks",
|
||||
"is_equal_to": "Est égal à",
|
||||
"is_less_than": "est inférieur à",
|
||||
"last_30_days": "30 derniers jours",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"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",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"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",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"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,7 +153,6 @@
|
||||
"clear_filters": "フィルターをクリア",
|
||||
"clear_selection": "選択をクリア",
|
||||
"click": "クリック",
|
||||
"click_to_filter": "クリックしてフィルター",
|
||||
"clicks": "クリック数",
|
||||
"close": "閉じる",
|
||||
"code": "コード",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
|
||||
"error_rate_limit_title": "レート制限を超えました",
|
||||
"expand_rows": "行を展開",
|
||||
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
|
||||
"finish": "完了",
|
||||
@@ -220,7 +218,6 @@
|
||||
"full_name": "氏名",
|
||||
"gathering_responses": "回答を収集しています",
|
||||
"general": "一般",
|
||||
"generate": "生成",
|
||||
"go_back": "戻る",
|
||||
"go_to_dashboard": "ダッシュボードへ移動",
|
||||
"hidden": "非表示",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "ユーザーID",
|
||||
"user_not_found": "ユーザーが見つかりません",
|
||||
"variable": "変数",
|
||||
"variable_ids": "変数ID",
|
||||
"variables": "変数",
|
||||
"verified_email": "認証済みメールアドレス",
|
||||
"video": "動画",
|
||||
@@ -527,7 +523,6 @@
|
||||
"add_css_class_or_id": "CSSクラスまたはIDを追加",
|
||||
"add_regular_expression_here": "ここに正規表現を追加",
|
||||
"add_url": "URLを追加",
|
||||
"and": "AND",
|
||||
"click": "クリック",
|
||||
"contains": "を含む",
|
||||
"create_action": "アクションを作成",
|
||||
@@ -558,7 +553,6 @@
|
||||
"limit_to_specific_pages": "特定のページに制限",
|
||||
"matches_regex": "正規表現に一致する",
|
||||
"on_all_pages": "すべてのページで",
|
||||
"or": "OR",
|
||||
"page_filter": "ページフィルター",
|
||||
"page_view": "ページビュー",
|
||||
"select_match_type": "一致タイプを選択",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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": "上位プランで連絡先をアンロック",
|
||||
@@ -790,8 +775,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "アプリ接続",
|
||||
"app_connection_description": "アプリやウェブサイトをFormbricksに接続します。",
|
||||
"cache_update_delay_description": "アンケート、連絡先、アクション、またはその他のデータを更新した場合、Formbricks SDKを実行しているローカルアプリにそれらの変更が反映されるまでに最大1分かかることがあります。",
|
||||
"cache_update_delay_title": "キャッシュの影響により、変更が反映されるまでに約1分かかります",
|
||||
"cache_update_delay_description": "フォーム・連絡先・アクションなどを更新してから、Formbricks SDK を実行中のローカルアプリに反映されるまで最大5分かかる場合があります。これは現在のキャッシュ方式の制限によるものです。私たちはキャッシュを改修中で、Formbricks 4.0 で修正を提供予定です。",
|
||||
"cache_update_delay_title": "キャッシュのため変更の反映に最大5分かかります",
|
||||
"environment_id": "あなたのEnvironmentId",
|
||||
"environment_id_description": "このIDはこのFormbricks環境を一意に識別します。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK は接続されています",
|
||||
@@ -884,6 +869,7 @@
|
||||
"add_tag": "タグを追加",
|
||||
"count": "件数",
|
||||
"delete_tag_confirmation": "このタグを削除してもよろしいですか?",
|
||||
"empty_message": "送信にタグ付けすると、ここにタグ一覧が表示されます。",
|
||||
"manage_tags": "タグを管理",
|
||||
"manage_tags_description": "回答タグを統合・削除します。",
|
||||
"merge": "統合",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "編集 リンク",
|
||||
"edit_recall": "リコールを編集",
|
||||
"edit_translations": "{lang} 翻訳を編集",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がいつでも言語を切り替えられるようにします。最低2つのアクティブな言語が必要です。",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "スパム対策はreCAPTCHA v3を使用してスパム回答をフィルタリングします。",
|
||||
"enable_spam_protection": "スパム対策",
|
||||
"end_screen_card": "終了画面カード",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完了 ⏳",
|
||||
"os": "OS",
|
||||
"person_attributes": "回答時の個人属性",
|
||||
"person_attributes": "人物属性",
|
||||
"phone": "電話",
|
||||
"respondent_skipped_questions": "回答者はこれらの質問をスキップしました。",
|
||||
"response_deleted_successfully": "回答を正常に削除しました。",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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": "全期間",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"filtered_responses_csv": "フィルター済み回答 (CSV)",
|
||||
"filtered_responses_excel": "フィルター済み回答 (Excel)",
|
||||
"generating_qr_code": "QRコードを生成中",
|
||||
"go_to_setup_checklist": "セットアップチェックリストへ移動 👉",
|
||||
"impressions": "表示回数",
|
||||
"impressions_tooltip": "フォームが表示された回数。",
|
||||
"in_app": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "すべてを含む",
|
||||
"includes_either": "どちらかを含む",
|
||||
"individual": "個人",
|
||||
"install_widget": "Formbricksウィジェットをインストール",
|
||||
"is_equal_to": "と等しい",
|
||||
"is_less_than": "より小さい",
|
||||
"last_30_days": "過去30日間",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"other_values_found": "他の値が見つかりました",
|
||||
"overall": "全体",
|
||||
"promoters": "推奨者",
|
||||
"qr_code": "QRコード",
|
||||
"qr_code_description": "QRコード経由で収集された回答は匿名です。",
|
||||
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
|
||||
"reset_survey": "フォームをリセット",
|
||||
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
|
||||
"satisfied": "満足",
|
||||
"selected_responses_csv": "選択した回答 (CSV)",
|
||||
"selected_responses_excel": "選択した回答 (Excel)",
|
||||
"setup_integrations": "連携を設定",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"ttc_tooltip": "フォームを完了するまでの平均時間。",
|
||||
"unknown_question_type": "不明な質問の種類",
|
||||
"use_personal_links": "個人リンクを使用",
|
||||
"waiting_for_response": "回答を待っています 🧘♂️",
|
||||
"whats_next": "次は何をしますか?",
|
||||
"your_survey_is_public": "あなたのフォームは公開されています",
|
||||
"youre_not_plugged_in_yet": "まだ接続されていません!"
|
||||
|
||||
@@ -153,7 +153,6 @@
|
||||
"clear_filters": "Wis filters",
|
||||
"clear_selection": "Duidelijke selectie",
|
||||
"click": "Klik",
|
||||
"click_to_filter": "Klik om te filteren",
|
||||
"clicks": "Klikken",
|
||||
"close": "Dichtbij",
|
||||
"code": "Code",
|
||||
@@ -211,7 +210,6 @@
|
||||
"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",
|
||||
@@ -220,7 +218,6 @@
|
||||
"full_name": "Volledige naam",
|
||||
"gathering_responses": "Reacties verzamelen",
|
||||
"general": "Algemeen",
|
||||
"generate": "Genereren",
|
||||
"go_back": "Ga terug",
|
||||
"go_to_dashboard": "Ga naar Dashboard",
|
||||
"hidden": "Verborgen",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "Gebruikers-ID",
|
||||
"user_not_found": "Gebruiker niet gevonden",
|
||||
"variable": "Variabel",
|
||||
"variable_ids": "Variabele ID's",
|
||||
"variables": "Variabelen",
|
||||
"verified_email": "Geverifieerde e-mail",
|
||||
"video": "Video",
|
||||
@@ -527,7 +523,6 @@
|
||||
"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",
|
||||
@@ -558,7 +553,6 @@
|
||||
"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",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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",
|
||||
@@ -790,8 +775,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "App-verbinding",
|
||||
"app_connection_description": "Verbind uw app of website met Formbricks.",
|
||||
"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",
|
||||
"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",
|
||||
"environment_id": "Uw omgevings-ID",
|
||||
"environment_id_description": "Deze ID identificeert op unieke wijze deze Formbricks-omgeving.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK is verbonden",
|
||||
@@ -884,6 +869,7 @@
|
||||
"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",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "Link bewerken",
|
||||
"edit_recall": "Bewerken Terugroepen",
|
||||
"edit_translations": "Bewerk {lang} vertalingen",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Sta respondenten toe om op elk moment van taal te wisselen. Vereist min. 2 actieve talen.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Geef deelnemers de mogelijkheid om op elk moment tijdens de enquête van enquêtetaal te wisselen.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spambeveiliging maakt gebruik van reCAPTCHA v3 om de spamreacties eruit te filteren.",
|
||||
"enable_spam_protection": "Spambescherming",
|
||||
"end_screen_card": "Eindschermkaart",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "Achternaam",
|
||||
"not_completed": "Niet voltooid ⏳",
|
||||
"os": "Besturingssysteem",
|
||||
"person_attributes": "Persoonskenmerken op het moment van indiening",
|
||||
"person_attributes": "Persoonsattributen",
|
||||
"phone": "Telefoon",
|
||||
"respondent_skipped_questions": "Respondent heeft deze vragen overgeslagen.",
|
||||
"response_deleted_successfully": "Reactie is succesvol verwijderd.",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"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": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "Inclusief alles",
|
||||
"includes_either": "Inclusief beide",
|
||||
"individual": "Individueel",
|
||||
"install_widget": "Installeer Formbricks-widget",
|
||||
"is_equal_to": "Is gelijk aan",
|
||||
"is_less_than": "Is minder dan",
|
||||
"last_30_days": "Laatste 30 dagen",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"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",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"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",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"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,7 +153,6 @@
|
||||
"clear_filters": "Limpar filtros",
|
||||
"clear_selection": "Limpar seleção",
|
||||
"click": "Clica",
|
||||
"click_to_filter": "Clique para filtrar",
|
||||
"clicks": "cliques",
|
||||
"close": "Fechar",
|
||||
"code": "Código",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_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",
|
||||
@@ -220,7 +218,6 @@
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "Recolhendo respostas",
|
||||
"general": "Geral",
|
||||
"generate": "Gerar",
|
||||
"go_back": "Voltar",
|
||||
"go_to_dashboard": "Ir para o Painel",
|
||||
"hidden": "Escondido",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "ID do usuário",
|
||||
"user_not_found": "Usuário não encontrado",
|
||||
"variable": "variável",
|
||||
"variable_ids": "IDs de variáveis",
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email Verificado",
|
||||
"video": "vídeo",
|
||||
@@ -527,7 +523,6 @@
|
||||
"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",
|
||||
@@ -558,7 +553,6 @@
|
||||
"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",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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",
|
||||
@@ -790,8 +775,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é 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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -884,6 +869,7 @@
|
||||
"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",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os respondentes alterem o idioma a qualquer momento. Necessita de no mínimo 2 idiomas ativos.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
"end_screen_card": "cartão de tela final",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "Sobrenome",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "sistema operacional",
|
||||
"person_attributes": "Atributos da pessoa no momento do envio",
|
||||
"person_attributes": "Atributos da pessoa",
|
||||
"phone": "Celular",
|
||||
"respondent_skipped_questions": "Respondente pulou essas perguntas.",
|
||||
"response_deleted_successfully": "Resposta deletada com sucesso.",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"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": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "Inclui tudo",
|
||||
"includes_either": "Inclui ou",
|
||||
"individual": "Individual",
|
||||
"install_widget": "Instalar Widget do Formbricks",
|
||||
"is_equal_to": "É igual a",
|
||||
"is_less_than": "É menor que",
|
||||
"last_30_days": "Últimos 30 dias",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"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",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"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",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"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,7 +153,6 @@
|
||||
"clear_filters": "Limpar filtros",
|
||||
"clear_selection": "Limpar seleção",
|
||||
"click": "Clique",
|
||||
"click_to_filter": "Clique para filtrar",
|
||||
"clicks": "Cliques",
|
||||
"close": "Fechar",
|
||||
"code": "Código",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
|
||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"failed_to_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",
|
||||
@@ -220,7 +218,6 @@
|
||||
"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",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "ID do Utilizador",
|
||||
"user_not_found": "Utilizador não encontrado",
|
||||
"variable": "Variável",
|
||||
"variable_ids": "IDs de variáveis",
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email verificado",
|
||||
"video": "Vídeo",
|
||||
@@ -527,7 +523,6 @@
|
||||
"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",
|
||||
@@ -558,7 +553,6 @@
|
||||
"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",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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",
|
||||
@@ -790,8 +775,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 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",
|
||||
"cache_update_delay_description": "Quando fizer atualizações para inquéritos, contactos, ações ou outros dados, pode demorar até 5 minutos para que essas alterações apareçam na sua aplicação local a correr o SDK do Formbricks. Este atraso deve-se a uma limitação no nosso atual sistema de cache. Estamos a trabalhar ativamente na reformulação da cache e lançaremos uma correção no Formbricks 4.0.",
|
||||
"cache_update_delay_title": "As alterações serão refletidas após 5 minutos devido ao armazenamento em cache.",
|
||||
"environment_id": "O seu identificador",
|
||||
"environment_id_description": "Este id identifica o seu espaço Formbricks.",
|
||||
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
|
||||
@@ -884,6 +869,7 @@
|
||||
"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",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os inquiridos mudem de idioma a qualquer momento. Necessita de pelo menos 2 idiomas ativos.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
"end_screen_card": "Cartão de ecrã final",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "Apelido",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "SO",
|
||||
"person_attributes": "Atributos da pessoa no momento da submissão",
|
||||
"person_attributes": "Atributos da pessoa",
|
||||
"phone": "Telefone",
|
||||
"respondent_skipped_questions": "O respondente saltou estas perguntas.",
|
||||
"response_deleted_successfully": "Resposta eliminada com sucesso.",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"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": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "Inclui tudo",
|
||||
"includes_either": "Inclui qualquer um",
|
||||
"individual": "Individual",
|
||||
"install_widget": "Instalar Widget Formbricks",
|
||||
"is_equal_to": "É igual a",
|
||||
"is_less_than": "É menos que",
|
||||
"last_30_days": "Últimos 30 dias",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"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",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"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",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"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,7 +153,6 @@
|
||||
"clear_filters": "Curăță filtrele",
|
||||
"clear_selection": "Șterge selecția",
|
||||
"click": "Click",
|
||||
"click_to_filter": "Click pentru a filtra",
|
||||
"clicks": "Clickuri",
|
||||
"close": "Închide",
|
||||
"code": "Cod",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
|
||||
"error_rate_limit_title": "Limită de cereri depășită",
|
||||
"expand_rows": "Extinde rândurile",
|
||||
"failed_to_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ă",
|
||||
@@ -220,7 +218,6 @@
|
||||
"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",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "ID Utilizator",
|
||||
"user_not_found": "Utilizatorul nu a fost găsit",
|
||||
"variable": "Variabilă",
|
||||
"variable_ids": "ID-uri variabile",
|
||||
"variables": "Variante",
|
||||
"verified_email": "Email verificat",
|
||||
"video": "Video",
|
||||
@@ -527,7 +523,6 @@
|
||||
"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",
|
||||
@@ -558,7 +553,6 @@
|
||||
"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",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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.",
|
||||
@@ -790,8 +775,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 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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -884,6 +869,7 @@
|
||||
"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",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "Editare legătură",
|
||||
"edit_recall": "Editează Referințele",
|
||||
"edit_translations": "Editează traducerile {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite respondenților să schimbe limba în orice moment. Necesită minimum 2 limbi active.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.",
|
||||
"enable_spam_protection": "Protecția împotriva spamului",
|
||||
"end_screen_card": "Ecran final card",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "Nume de familie",
|
||||
"not_completed": "Necompletat ⏳",
|
||||
"os": "SO",
|
||||
"person_attributes": "Atributele persoanei la momentul trimiterii",
|
||||
"person_attributes": "Atribute persoană",
|
||||
"phone": "Telefon",
|
||||
"respondent_skipped_questions": "Respondenții au sărit peste aceste întrebări.",
|
||||
"response_deleted_successfully": "Răspuns șters cu succes.",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"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": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "Include tot",
|
||||
"includes_either": "Include fie",
|
||||
"individual": "Individual",
|
||||
"install_widget": "Instalați Widgetul Formbricks",
|
||||
"is_equal_to": "Este egal cu",
|
||||
"is_less_than": "Este mai puțin de",
|
||||
"last_30_days": "Ultimele 30 de zile",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"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",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"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",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"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,7 +153,6 @@
|
||||
"clear_filters": "清除 过滤器",
|
||||
"clear_selection": "清除 选择",
|
||||
"click": "点击",
|
||||
"click_to_filter": "点击筛选",
|
||||
"clicks": "点击",
|
||||
"close": "关闭",
|
||||
"code": "代码",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
|
||||
"error_rate_limit_title": "速率 限制 超过",
|
||||
"expand_rows": "展开 行",
|
||||
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_projects": "加载项目失败",
|
||||
"finish": "完成",
|
||||
@@ -220,7 +218,6 @@
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集反馈",
|
||||
"general": "通用",
|
||||
"generate": "生成",
|
||||
"go_back": "返回 ",
|
||||
"go_to_dashboard": "转到 Dashboard",
|
||||
"hidden": "隐藏",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "用户 ID",
|
||||
"user_not_found": "用户 不存在",
|
||||
"variable": "变量",
|
||||
"variable_ids": "变量 ID",
|
||||
"variables": "变量",
|
||||
"verified_email": "已验证 电子邮件",
|
||||
"video": "视频",
|
||||
@@ -527,7 +523,6 @@
|
||||
"add_css_class_or_id": "添加 CSS class 或 id",
|
||||
"add_regular_expression_here": "在 这里 添加 正则 表达式",
|
||||
"add_url": "添加 URL",
|
||||
"and": "与",
|
||||
"click": "点击",
|
||||
"contains": "包含",
|
||||
"create_action": "创建 操作",
|
||||
@@ -558,7 +553,6 @@
|
||||
"limit_to_specific_pages": "限制 特定 页面",
|
||||
"matches_regex": "匹配 正则表达式",
|
||||
"on_all_pages": "在 所有 页面",
|
||||
"or": "或",
|
||||
"page_filter": "页面 过滤器",
|
||||
"page_view": "页面 查看",
|
||||
"select_match_type": "选择 匹配 类型",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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": "通过 更 高级 划解锁 联系人",
|
||||
@@ -790,8 +775,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "应用程序 连接",
|
||||
"app_connection_description": "将您的应用或网站连接到 Formbricks。",
|
||||
"cache_update_delay_description": "当您更新问卷、联系人、操作或其他数据时,这些更改可能需要最多 1 分钟才能在运行 Formbricks SDK 的本地应用中显示。",
|
||||
"cache_update_delay_title": "由于缓存,变更将在约 1 分钟后生效",
|
||||
"cache_update_delay_description": "当 你 对 调查 、 联系人 、 操作 或 其他 数据 进行 更新 时 , 可能 需要 最多 5 分钟 更改 才能 显示 在 你 本地 运行 Formbricks SDK 的 应用程序 中 。 这个 延迟 是 由于 我们 当前 缓存 系统 的 限制 。 我们 正在 积极 重新设计 缓存 并 将 在 Formbricks 4.0 中 发布 修复 。",
|
||||
"cache_update_delay_title": "更改 将 在 5 分钟 后 由于 缓存 而 显示",
|
||||
"environment_id": "你的 环境 ID",
|
||||
"environment_id_description": "这个 id 独特地 标识 这个 Formbricks 环境。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK 已连接",
|
||||
@@ -884,6 +869,7 @@
|
||||
"add_tag": "添加 标签",
|
||||
"count": "数量",
|
||||
"delete_tag_confirmation": "您 确定 要 删除 此 标签 吗?",
|
||||
"empty_message": "标记一个提交以在此处找到您的标签列表。",
|
||||
"manage_tags": "管理标签",
|
||||
"manage_tags_description": "合并 和 删除 response 标签。",
|
||||
"merge": "合并",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "编辑 链接",
|
||||
"edit_recall": "编辑 调用",
|
||||
"edit_translations": "编辑 {lang} 翻译",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允许受访者在调查过程中随时切换语言。需要至少启用两种语言。",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾 邮件 保护 使用 reCAPTCHA v3 来 过滤 掉 垃圾 响应 。",
|
||||
"enable_spam_protection": "垃圾 邮件 保护",
|
||||
"end_screen_card": "结束 屏幕 卡片",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "操作系统",
|
||||
"person_attributes": "提交时的个人属性",
|
||||
"person_attributes": "人员 属性",
|
||||
"phone": "电话",
|
||||
"respondent_skipped_questions": "受访者跳过 这些问题。",
|
||||
"response_deleted_successfully": "响应 删除 成功",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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": "所有 时间",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"filtered_responses_csv": "过滤 反馈 (CSV)",
|
||||
"filtered_responses_excel": "过滤 反馈 (Excel)",
|
||||
"generating_qr_code": "正在生成二维码",
|
||||
"go_to_setup_checklist": "前往 设置 检查列表 👉",
|
||||
"impressions": "印象",
|
||||
"impressions_tooltip": "调查 被 查看 的 次数",
|
||||
"in_app": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "包括所有 ",
|
||||
"includes_either": "包含 任意一个",
|
||||
"individual": "个人",
|
||||
"install_widget": "安装 Formbricks 小组件",
|
||||
"is_equal_to": "等于",
|
||||
"is_less_than": "少于",
|
||||
"last_30_days": "最近 30 天",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"no_responses_found": "未找到响应",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整体",
|
||||
"promoters": "推荐者",
|
||||
"qr_code": "二维码",
|
||||
"qr_code_description": "通过 QR 码 收集 的 响应 是 匿名 的。",
|
||||
"qr_code_download_failed": "二维码下载失败",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"quotas_completed_tooltip": "受访者完成的配额数量。",
|
||||
"reset_survey": "重置 调查",
|
||||
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
|
||||
"satisfied": "满意",
|
||||
"selected_responses_csv": "选定 反馈 (CSV)",
|
||||
"selected_responses_excel": "选定 反馈 (Excel)",
|
||||
"setup_integrations": "设置 集成",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"ttc_tooltip": "完成 本 问题 的 平均 时间",
|
||||
"unknown_question_type": "未知 问题 类型",
|
||||
"use_personal_links": "使用 个人 链接",
|
||||
"waiting_for_response": "等待回复 🧘♂️",
|
||||
"whats_next": "接下来 是 什么?",
|
||||
"your_survey_is_public": "您的 调查 是 公共 的",
|
||||
"youre_not_plugged_in_yet": "您 还 没 有 连 接!"
|
||||
|
||||
@@ -153,7 +153,6 @@
|
||||
"clear_filters": "清除篩選器",
|
||||
"clear_selection": "清除選取",
|
||||
"click": "點擊",
|
||||
"click_to_filter": "點擊篩選",
|
||||
"clicks": "點擊數",
|
||||
"close": "關閉",
|
||||
"code": "程式碼",
|
||||
@@ -211,7 +210,6 @@
|
||||
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
||||
"error_rate_limit_title": "限流超過",
|
||||
"expand_rows": "展開列",
|
||||
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_projects": "無法載入專案",
|
||||
"finish": "完成",
|
||||
@@ -220,7 +218,6 @@
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集回應中",
|
||||
"general": "一般",
|
||||
"generate": "產生",
|
||||
"go_back": "返回",
|
||||
"go_to_dashboard": "前往儀表板",
|
||||
"hidden": "隱藏",
|
||||
@@ -428,7 +425,6 @@
|
||||
"user_id": "使用者 ID",
|
||||
"user_not_found": "找不到使用者",
|
||||
"variable": "變數",
|
||||
"variable_ids": "變數 ID",
|
||||
"variables": "變數",
|
||||
"verified_email": "已驗證的電子郵件",
|
||||
"video": "影片",
|
||||
@@ -527,7 +523,6 @@
|
||||
"add_css_class_or_id": "新增 CSS 類別或 ID",
|
||||
"add_regular_expression_here": "新增正則表達式在此",
|
||||
"add_url": "新增網址",
|
||||
"and": "且",
|
||||
"click": "點擊",
|
||||
"contains": "包含",
|
||||
"create_action": "建立操作",
|
||||
@@ -558,7 +553,6 @@
|
||||
"limit_to_specific_pages": "限制為特定頁面",
|
||||
"matches_regex": "符合 正則 表達式",
|
||||
"on_all_pages": "在所有頁面上",
|
||||
"or": "或",
|
||||
"page_filter": "頁面篩選器",
|
||||
"page_view": "頁面檢視",
|
||||
"select_match_type": "選取比對類型",
|
||||
@@ -599,18 +593,9 @@
|
||||
"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": "使用更高等級的方案解鎖聯絡人",
|
||||
@@ -790,8 +775,8 @@
|
||||
"app-connection": {
|
||||
"app_connection": "應用程式連線",
|
||||
"app_connection_description": "將您的應用程式或網站連接到 Formbricks。",
|
||||
"cache_update_delay_description": "當您更新問卷調查、聯絡人、操作或其他資料時,這些變更可能需要最多 1 分鐘的時間,才會顯示在執行 Formbricks SDK 的本地應用程式中。",
|
||||
"cache_update_delay_title": "由於快取,變更約需 1 分鐘後才會反映",
|
||||
"cache_update_delay_description": "當您對調查、聯絡人、操作或其他資料進行更新時,可能需要長達 5 分鐘這些變更才能顯示在執行 Formbricks SDK 的本地應用程式中。此延遲是因我們目前快取系統的限制。我們正積極重新設計快取,並將在 Formbricks 4.0 中發佈修補程式。",
|
||||
"cache_update_delay_title": "更改將於 5 分鐘後因快取而反映",
|
||||
"environment_id": "您的 EnvironmentId",
|
||||
"environment_id_description": "此 ID 可唯一識別此 Formbricks 環境。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK 已連線",
|
||||
@@ -884,6 +869,7 @@
|
||||
"add_tag": "新增標籤",
|
||||
"count": "計數",
|
||||
"delete_tag_confirmation": "您確定要刪除此標籤嗎?",
|
||||
"empty_message": "標記提交內容,在此處找到您的標籤清單。",
|
||||
"manage_tags": "管理標籤",
|
||||
"manage_tags_description": "合併和移除回應標籤。",
|
||||
"merge": "合併",
|
||||
@@ -1337,7 +1323,7 @@
|
||||
"edit_link": "編輯 連結",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許受訪者隨時切換語言。需要至少啟用兩種語言。",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
|
||||
"enable_spam_protection": "垃圾郵件保護",
|
||||
"end_screen_card": "結束畫面卡片",
|
||||
@@ -1702,7 +1688,7 @@
|
||||
"last_name": "姓氏",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "作業系統",
|
||||
"person_attributes": "提交時的個人屬性",
|
||||
"person_attributes": "人員屬性",
|
||||
"phone": "電話",
|
||||
"respondent_skipped_questions": "回應者跳過這些問題。",
|
||||
"response_deleted_successfully": "回應已成功刪除。",
|
||||
@@ -1815,7 +1801,6 @@
|
||||
"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": "全部時間",
|
||||
@@ -1839,6 +1824,7 @@
|
||||
"filtered_responses_csv": "篩選回應 (CSV)",
|
||||
"filtered_responses_excel": "篩選回應 (Excel)",
|
||||
"generating_qr_code": "正在生成 QR code",
|
||||
"go_to_setup_checklist": "前往設定檢查清單 👉",
|
||||
"impressions": "曝光數",
|
||||
"impressions_tooltip": "問卷已檢視的次數。",
|
||||
"in_app": {
|
||||
@@ -1872,7 +1858,7 @@
|
||||
},
|
||||
"includes_all": "包含全部",
|
||||
"includes_either": "包含其中一個",
|
||||
"individual": "個人",
|
||||
"install_widget": "安裝 Formbricks 小工具",
|
||||
"is_equal_to": "等於",
|
||||
"is_less_than": "小於",
|
||||
"last_30_days": "過去 30 天",
|
||||
@@ -1885,7 +1871,6 @@
|
||||
"no_responses_found": "找不到回應",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"promoters": "推廣者",
|
||||
"qr_code": "QR 碼",
|
||||
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
|
||||
"qr_code_download_failed": "QR code 下載失敗",
|
||||
@@ -1895,7 +1880,6 @@
|
||||
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
|
||||
"reset_survey": "重設問卷",
|
||||
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
||||
"satisfied": "滿意",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"setup_integrations": "設定整合",
|
||||
@@ -1911,6 +1895,7 @@
|
||||
"ttc_tooltip": "完成 問題 的 平均 時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
"waiting_for_response": "正在等待回應 🧘♂️",
|
||||
"whats_next": "下一步是什麼?",
|
||||
"your_survey_is_public": "您的問卷是公開的",
|
||||
"youre_not_plugged_in_yet": "您尚未插入任何內容!"
|
||||
|
||||
+17
-8
@@ -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,8 +24,6 @@ interface ResponseTagsWrapperProps {
|
||||
environmentTags: TTag[];
|
||||
updateFetchedResponses: () => void;
|
||||
isReadOnly?: boolean;
|
||||
response: TResponse;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
@@ -35,10 +33,9 @@ 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);
|
||||
@@ -82,6 +79,7 @@ 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"));
|
||||
@@ -133,7 +131,6 @@ 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}
|
||||
@@ -160,6 +157,18 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
+151
-44
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { LanguagesIcon, 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";
|
||||
@@ -10,12 +12,17 @@ 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;
|
||||
@@ -47,40 +54,140 @@ 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-2">
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
{pageType === "response" && (
|
||||
<>
|
||||
{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>
|
||||
<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>
|
||||
)
|
||||
) : (
|
||||
<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 className="flex items-center">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
<h3 className="ml-4 pb-1 font-semibold text-slate-600">{t("common.anonymous")}</h3>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<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} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
|
||||
{pageType === "people" && (
|
||||
@@ -95,34 +202,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-2 text-sm">
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<time className="text-slate-500" dateTime={timeSince(response.createdAt.toISOString(), locale)}>
|
||||
{timeSince(response.createdAt.toISOString(), locale)}
|
||||
</time>
|
||||
{user &&
|
||||
!isReadOnly &&
|
||||
(canResponseBeDeleted ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<TrashIcon
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
aria-label="Delete response">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
className="h-4 w-4 cursor-pointer text-slate-500 hover:text-red-700"
|
||||
aria-label="Delete response"
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<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>
|
||||
<TrashIcon
|
||||
className="h-4 w-4 cursor-not-allowed text-slate-400"
|
||||
aria-label="Cannot delete response in progress"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{deleteSubmissionToolTip}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
-163
@@ -1,163 +0,0 @@
|
||||
"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,8 +143,6 @@ export const SingleResponseCard = ({
|
||||
environmentTags={environmentTags}
|
||||
updateFetchedResponses={updateFetchedResponses}
|
||||
isReadOnly={isReadOnly}
|
||||
response={response}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
|
||||
@@ -12,7 +12,6 @@ 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);
|
||||
@@ -28,7 +27,6 @@ describe("pickCommonFilter", () => {
|
||||
order: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
filterDateField: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,32 +70,5 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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, filterDateField } = params;
|
||||
return { limit, skip, sortBy, order, startDate, endDate, filterDateField };
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params;
|
||||
return { limit, skip, sortBy, order, startDate, endDate };
|
||||
}
|
||||
|
||||
type HasFindMany =
|
||||
@@ -15,21 +15,19 @@ type HasFindMany =
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
|
||||
let filteredQuery = {
|
||||
...query,
|
||||
};
|
||||
|
||||
const dateField = filterDateField;
|
||||
|
||||
if (startDate) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
[dateField]: {
|
||||
...(filteredQuery.where?.[dateField] as Prisma.DateTimeFilter),
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
gte: startDate,
|
||||
},
|
||||
},
|
||||
@@ -41,8 +39,8 @@ export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params:
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
[dateField]: {
|
||||
...(filteredQuery.where?.[dateField] as Prisma.DateTimeFilter),
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ 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>;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
"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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
"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,7 +1,6 @@
|
||||
"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";
|
||||
@@ -13,7 +12,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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
|
||||
interface ResponseTimelineProps {
|
||||
surveys: TSurvey[];
|
||||
@@ -34,7 +33,6 @@ export const ResponseFeed = ({
|
||||
locale,
|
||||
projectPermission,
|
||||
}: ResponseTimelineProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [fetchedResponses, setFetchedResponses] = useState(responses);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +50,7 @@ export const ResponseFeed = ({
|
||||
return (
|
||||
<>
|
||||
{fetchedResponses.length === 0 ? (
|
||||
<EmptyState text={t("environments.contacts.no_responses_found")} />
|
||||
<EmptySpaceFiller type="response" environment={environment} />
|
||||
) : (
|
||||
fetchedResponses.map((response) => (
|
||||
<ResponseSurveyCard
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { DeleteContactButton } from "@/modules/ee/contacts/[contactId]/components/delete-contact-button";
|
||||
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";
|
||||
@@ -20,11 +19,10 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([
|
||||
const [environmentTags, contact, contactAttributes] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
@@ -33,21 +31,20 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.billing.plan);
|
||||
|
||||
const getContactControlBar = () => {
|
||||
const getDeletePersonButton = () => {
|
||||
return (
|
||||
<ContactControlBar
|
||||
<DeleteContactButton
|
||||
environmentId={environment.id}
|
||||
contactId={params.contactId}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getDeletePersonButton()} />
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-4 gap-x-8">
|
||||
<AttributesSection contactId={params.contactId} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
@@ -30,36 +29,15 @@ export const POST = withV1ApiWrapper({
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// Basic type check for environmentId
|
||||
if (typeof params.environmentId !== "string") {
|
||||
const { environmentId } = params;
|
||||
|
||||
// Simple validation (faster than Zod for high-frequency endpoint)
|
||||
if (!environmentId || typeof environmentId !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("Environment ID is required", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = params.environmentId.trim();
|
||||
|
||||
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
|
||||
// This catches all invalid formats including:
|
||||
// - null/undefined passed as string "null" or "undefined"
|
||||
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
|
||||
// - Empty or whitespace-only IDs
|
||||
// - Any other invalid CUID v1 format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
if (!cuidValidation.success) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
url: req.url,
|
||||
validationError: cuidValidation.error.errors[0]?.message,
|
||||
},
|
||||
"Invalid CUID v1 format detected"
|
||||
);
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const jsonInput = await req.json();
|
||||
|
||||
// Basic input validation without Zod overhead
|
||||
|
||||
@@ -300,7 +300,7 @@ export const ContactsTable = ({
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && isDataLoaded && (
|
||||
{data && hasMore && data.length > 0 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button onClick={fetchNextPage}>{t("common.load_more")}</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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,7 +33,6 @@ export const SegmentTable = async ({
|
||||
<>
|
||||
{segments.map((segment) => (
|
||||
<SegmentTableDataRowContainer
|
||||
key={segment.id}
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { TSurvey, TSurveyLanguage, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
@@ -177,8 +177,6 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const enabledLanguages = getEnabledLanguages(localSurvey.languages);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -302,7 +300,6 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
<AdvancedOptionToggle
|
||||
customContainerClass="px-0 pt-0"
|
||||
htmlId="languageSwitch"
|
||||
disabled={enabledLanguages.length <= 1}
|
||||
isChecked={!!localSurvey.showLanguageSwitch}
|
||||
onToggle={handleLanguageSwitchToggle}
|
||||
title={t("environments.surveys.edit.show_language_switch")}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { HandshakeIcon, Undo2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyEndings } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import {
|
||||
Select,
|
||||
@@ -42,7 +41,7 @@ export const EndingCardSelector = ({ endings, value, onChange }: EndingCardSelec
|
||||
{/* Custom endings */}
|
||||
{endingCards.map((ending) => (
|
||||
<SelectItem key={ending.id} value={ending.id}>
|
||||
{getTextContent(getLocalizedValue(ending.headline, "default"))}
|
||||
{getLocalizedValue(ending.headline, "default")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
@@ -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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
|
||||
interface WebhookTableProps {
|
||||
environment: TEnvironment;
|
||||
@@ -46,7 +46,12 @@ export const WebhookTable = ({
|
||||
return (
|
||||
<>
|
||||
{webhooks.length === 0 ? (
|
||||
<EmptyState text={t("environments.integrations.webhooks.empty_webhook_message")} />
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.webhooks.empty_webhook_message")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
{TableHeading}
|
||||
|
||||
@@ -26,7 +26,7 @@ const baseProject = {
|
||||
darkOverlay: false,
|
||||
environments: [
|
||||
{
|
||||
id: "cmi2sra0j000004l73fvh7lhe",
|
||||
id: "prodenv",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production" as TEnvironment["type"],
|
||||
@@ -34,7 +34,7 @@ const baseProject = {
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
{
|
||||
id: "cmi2srt9q000104l7127e67v7",
|
||||
id: "devenv",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development" as TEnvironment["type"],
|
||||
@@ -155,7 +155,7 @@ describe("project lib", () => {
|
||||
vi.mocked(deleteFilesByEnvironmentId).mockResolvedValue({ ok: true, data: undefined });
|
||||
const result = await deleteProject("p1");
|
||||
expect(result).toEqual(baseProject);
|
||||
expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("cmi2sra0j000004l73fvh7lhe");
|
||||
expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
|
||||
});
|
||||
|
||||
test("logs error if file deletion fails", async () => {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
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 { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
|
||||
interface EditTagsWrapperProps {
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
environmentTagsCount: TTagsCount;
|
||||
isReadOnly: boolean;
|
||||
@@ -14,12 +16,7 @@ interface EditTagsWrapperProps {
|
||||
|
||||
export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { environmentTags, environmentTagsCount, isReadOnly } = props;
|
||||
|
||||
if (!environmentTags?.length) {
|
||||
return <EmptyState text={t("environments.project.tags.no_tag_found")} />;
|
||||
}
|
||||
|
||||
const { environment, environmentTags, environmentTagsCount, isReadOnly } = props;
|
||||
return (
|
||||
<div className="">
|
||||
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
|
||||
@@ -30,7 +27,11 @@ export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{environmentTags.map((tag) => (
|
||||
{!environmentTags?.length ? (
|
||||
<EmptySpaceFiller environment={environment} type="tag" noWidgetRequired />
|
||||
) : null}
|
||||
|
||||
{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 } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [tags, environmentTagsCount] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
@@ -28,6 +28,7 @@ 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}
|
||||
|
||||
@@ -40,9 +40,7 @@ export const AdvancedSettings = ({
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
|
||||
{showOptionIds && (
|
||||
<OptionIds type="question" question={question} selectedLanguageCode={selectedLanguageCode} />
|
||||
)}
|
||||
{showOptionIds && <OptionIds question={question} selectedLanguageCode={selectedLanguageCode} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
@@ -49,7 +48,7 @@ export function LogicEditor({
|
||||
const ques = localSurvey.questions[i];
|
||||
options.push({
|
||||
icon: QUESTIONS_ICON_MAP[ques.type],
|
||||
label: getTextContent(getLocalizedValue(ques.headline, "default")),
|
||||
label: getLocalizedValue(ques.headline, "default"),
|
||||
value: ques.id,
|
||||
});
|
||||
}
|
||||
@@ -58,8 +57,7 @@ export function LogicEditor({
|
||||
options.push({
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
|
||||
t("environments.surveys.edit.end_screen_card")
|
||||
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
|
||||
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
||||
value: ending.id,
|
||||
});
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface OptionIdsQuestionProps {
|
||||
type: "question";
|
||||
interface OptionIdsProps {
|
||||
question: TSurveyQuestion;
|
||||
selectedLanguageCode: string;
|
||||
}
|
||||
|
||||
interface OptionIdsVariablesProps {
|
||||
type: "variables";
|
||||
variables: TSurveyVariable[];
|
||||
}
|
||||
|
||||
type OptionIdsProps = OptionIdsQuestionProps | OptionIdsVariablesProps;
|
||||
|
||||
export const OptionIds = (props: OptionIdsProps) => {
|
||||
export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderChoiceIds = (question: TSurveyQuestion, selectedLanguageCode: string) => {
|
||||
const renderChoiceIds = () => {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
@@ -67,31 +59,10 @@ export const OptionIds = (props: OptionIdsProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderVariableIds = (variables: TSurveyVariable[]) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{variables.map((variable) => (
|
||||
<div key={variable.id}>
|
||||
<IdBadge id={variable.id} label={variable.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (props.type === "variables") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-gray-700">{t("common.variable_ids")}</Label>
|
||||
<div className="w-full">{renderVariableIds(props.variables)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-gray-700">{t("common.option_ids")}</Label>
|
||||
<div className="w-full">{renderChoiceIds(props.question, props.selectedLanguageCode)}</div>
|
||||
<div className="w-full">{renderChoiceIds()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -195,7 +195,7 @@ export const QuestionCard = ({
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
isInvalid && "bg-red-400 hover:bg-red-600",
|
||||
"flex flex-col items-center justify-between"
|
||||
)}>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const QuestionOptionChoice = ({
|
||||
const normalChoice = question.choices?.filter((c) => c.id !== "other" && c.id !== "none") || [];
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-2" ref={setNodeRef} style={style}>
|
||||
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
|
||||
{/* drag handle */}
|
||||
<div className={cn(isSpecialChoice && "invisible")} {...listeners} {...attributes}>
|
||||
<GripVerticalIcon className="h-4 w-4 cursor-move text-slate-400" />
|
||||
@@ -167,7 +167,7 @@ export const QuestionOptionChoice = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(normalChoice.length > 1 || isSpecialChoice) && (
|
||||
{(normalChoice.length > 2 || isSpecialChoice) && (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.delete_choice")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -47,8 +48,10 @@ export const QuestionsDroppable = ({
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionsDraggableProps) => {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5">
|
||||
<div className="group mb-5 flex w-full flex-col gap-5" ref={parent}>
|
||||
<SortableContext items={localSurvey.questions} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.questions.map((question, questionIdx) => (
|
||||
<QuestionCard
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
closestCorners,
|
||||
useSensor,
|
||||
@@ -14,7 +12,7 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Language, Project } from "@prisma/client";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import React, { SetStateAction, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
@@ -40,7 +38,6 @@ import { AddQuestionButton } from "@/modules/survey/editor/components/add-questi
|
||||
import { EditEndingCard } from "@/modules/survey/editor/components/edit-ending-card";
|
||||
import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome-card";
|
||||
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
|
||||
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
|
||||
import { QuestionsDroppable } from "@/modules/survey/editor/components/questions-droppable";
|
||||
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
|
||||
import { findQuestionUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
|
||||
@@ -422,8 +419,6 @@ export const QuestionsView = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeQuestionId, setActiveQuestionId]);
|
||||
|
||||
const [activeQuestionDragId, setActiveQuestionDragId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
@@ -432,31 +427,18 @@ export const QuestionsView = ({
|
||||
})
|
||||
);
|
||||
|
||||
const onQuestionCardDragStart = (event: DragStartEvent) => {
|
||||
setActiveQuestionDragId(event.active.id as string);
|
||||
};
|
||||
|
||||
const onQuestionCardDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveQuestionDragId(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const sourceIndex = newQuestions.findIndex((question) => question.id === active.id);
|
||||
const destinationIndex = newQuestions.findIndex((question) => question.id === over.id);
|
||||
const destinationIndex = newQuestions.findIndex((question) => question.id === over?.id);
|
||||
const [reorderedQuestion] = newQuestions.splice(sourceIndex, 1);
|
||||
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
|
||||
const updatedSurvey = { ...localSurvey, questions: newQuestions };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const onQuestionCardDragCancel = () => {
|
||||
setActiveQuestionDragId(null);
|
||||
};
|
||||
|
||||
const onEndingCardDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const newEndings = Array.from(localSurvey.endings);
|
||||
@@ -492,9 +474,7 @@ export const QuestionsView = ({
|
||||
<DndContext
|
||||
id="questions"
|
||||
sensors={sensors}
|
||||
onDragStart={onQuestionCardDragStart}
|
||||
onDragEnd={onQuestionCardDragEnd}
|
||||
onDragCancel={onQuestionCardDragCancel}
|
||||
collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
localSurvey={localSurvey}
|
||||
@@ -517,44 +497,6 @@ export const QuestionsView = ({
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{activeQuestionDragId
|
||||
? (() => {
|
||||
const draggedQuestion = localSurvey.questions.find((q) => q.id === activeQuestionDragId);
|
||||
const draggedQuestionIdx = localSurvey.questions.findIndex(
|
||||
(q) => q.id === activeQuestionDragId
|
||||
);
|
||||
return draggedQuestion ? (
|
||||
<div className="rotate-2 opacity-90">
|
||||
<QuestionCard
|
||||
localSurvey={localSurvey}
|
||||
project={project}
|
||||
question={draggedQuestion}
|
||||
questionIdx={draggedQuestionIdx}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
deleteQuestion={deleteQuestion}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={draggedQuestionIdx === localSurvey.questions.length - 1}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(draggedQuestion.id) : false}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
locale={locale}
|
||||
responseCount={responseCount}
|
||||
onAlertTrigger={() => setIsCautionDialogOpen(true)}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
})()
|
||||
: null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} project={project} isCxMode={isCxMode} />
|
||||
|
||||
@@ -114,12 +114,7 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
};
|
||||
|
||||
const handleOverwriteDaysChange = (event) => {
|
||||
let value = Number(event.target.value);
|
||||
if (Number.isNaN(value) || value < 1) {
|
||||
value = 1;
|
||||
} else if (value > 365) {
|
||||
value = 365;
|
||||
}
|
||||
const value = Number(event.target.value);
|
||||
setInputDays(value);
|
||||
|
||||
const updatedSurvey = { ...localSurvey, recontactDays: value };
|
||||
@@ -127,10 +122,7 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
};
|
||||
|
||||
const handleDisplayLimitChange = (event) => {
|
||||
let value = Number(event.target.value);
|
||||
if (Number.isNaN(value) || value < 1) {
|
||||
value = 1;
|
||||
}
|
||||
const value = Number(event.target.value);
|
||||
setDisplayLimit(value);
|
||||
|
||||
const updatedSurvey = { ...localSurvey, displayLimit: value } satisfies TSurvey;
|
||||
@@ -218,7 +210,6 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
id="overwriteDays"
|
||||
value={inputDays}
|
||||
onChange={handleOverwriteDaysChange}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { OptionIds } from "@/modules/survey/editor/components/option-ids";
|
||||
import { SurveyVariablesCardItem } from "@/modules/survey/editor/components/survey-variables-card-item";
|
||||
|
||||
interface SurveyVariablesCardProps {
|
||||
@@ -92,12 +91,6 @@ export const SurveyVariablesCard = ({
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
quotas={quotas}
|
||||
/>
|
||||
|
||||
{localSurvey.variables.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<OptionIds type="variables" variables={localSurvey.variables} />
|
||||
</div>
|
||||
)}
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
|
||||
@@ -594,7 +594,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = openTextQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -691,7 +691,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -765,7 +765,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -845,7 +845,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
|
||||
@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -141,9 +140,9 @@ export const FollowUpModal = ({
|
||||
|
||||
return [
|
||||
...openTextAndContactQuestions.map((question) => ({
|
||||
label: getTextContent(
|
||||
recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[selectedLanguageCode]
|
||||
),
|
||||
label: recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
],
|
||||
id: question.id,
|
||||
type:
|
||||
question.type === TSurveyQuestionTypeEnum.OpenText
|
||||
@@ -518,9 +517,7 @@ export const FollowUpModal = ({
|
||||
const getEndingLabel = (): string => {
|
||||
if (ending.type === "endScreen") {
|
||||
return (
|
||||
getTextContent(
|
||||
getLocalizedValue(ending.headline, selectedLanguageCode)
|
||||
) || "Ending"
|
||||
getLocalizedValue(ending.headline, selectedLanguageCode) || "Ending"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
@@ -30,6 +30,10 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
|
||||
}
|
||||
}, [survey, isReadOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSingleUseId();
|
||||
}, [refreshSingleUseId]);
|
||||
|
||||
return {
|
||||
singleUseId: isReadOnly ? undefined : singleUseId,
|
||||
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,
|
||||
|
||||
@@ -45,11 +45,11 @@ export const selectSurvey = {
|
||||
language: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
code: true,
|
||||
projectId: true,
|
||||
alias: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -72,15 +72,7 @@ export const selectSurvey = {
|
||||
},
|
||||
},
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
isPrivate: true,
|
||||
filters: true,
|
||||
include: {
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
+117
-66
@@ -1,110 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Project } from "@prisma/client";
|
||||
import { Project, Response } from "@prisma/client";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TResponseData, TResponseHiddenFieldValue } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
import { SurveyLinkUsed } from "@/modules/survey/link/components/survey-link-used";
|
||||
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
interface SurveyClientWrapperProps {
|
||||
survey: TSurvey;
|
||||
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
|
||||
styling: TProjectStyling | TSurveyStyling;
|
||||
publicDomain: string;
|
||||
responseCount?: number;
|
||||
languageCode: string;
|
||||
isEmbed: boolean;
|
||||
singleUseId?: string;
|
||||
singleUseResponseId?: string;
|
||||
contactId?: string;
|
||||
recaptchaSiteKey?: string;
|
||||
isSpamProtectionEnabled: boolean;
|
||||
isPreview: boolean;
|
||||
verifiedEmail?: string;
|
||||
IMPRINT_URL?: string;
|
||||
PRIVACY_URL?: string;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
}
|
||||
|
||||
// Module-level functions to allow SurveyInline to control survey state
|
||||
let setQuestionId = (_: string) => {};
|
||||
let setResponseData = (_: TResponseData) => {};
|
||||
|
||||
export const SurveyClientWrapper = ({
|
||||
interface LinkSurveyProps {
|
||||
survey: TSurvey;
|
||||
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
|
||||
emailVerificationStatus?: string;
|
||||
singleUseId?: string;
|
||||
singleUseResponse?: Pick<Response, "id" | "finished">;
|
||||
publicDomain: string;
|
||||
responseCount?: number;
|
||||
verifiedEmail?: string;
|
||||
languageCode: string;
|
||||
isEmbed: boolean;
|
||||
IMPRINT_URL?: string;
|
||||
PRIVACY_URL?: string;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
locale: string;
|
||||
isPreview: boolean;
|
||||
contactId?: string;
|
||||
recaptchaSiteKey?: string;
|
||||
isSpamProtectionEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const LinkSurvey = ({
|
||||
survey,
|
||||
project,
|
||||
styling,
|
||||
emailVerificationStatus,
|
||||
singleUseId,
|
||||
singleUseResponse,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
verifiedEmail,
|
||||
languageCode,
|
||||
isEmbed,
|
||||
singleUseId,
|
||||
singleUseResponseId,
|
||||
contactId,
|
||||
recaptchaSiteKey,
|
||||
isSpamProtectionEnabled,
|
||||
isPreview,
|
||||
verifiedEmail,
|
||||
IMPRINT_URL,
|
||||
PRIVACY_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
}: SurveyClientWrapperProps) => {
|
||||
locale,
|
||||
isPreview,
|
||||
contactId,
|
||||
recaptchaSiteKey,
|
||||
isSpamProtectionEnabled = false,
|
||||
}: LinkSurveyProps) => {
|
||||
const responseId = singleUseResponse?.id;
|
||||
const searchParams = useSearchParams();
|
||||
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
|
||||
const suId = searchParams.get("suId");
|
||||
|
||||
const startAt = searchParams.get("startAt");
|
||||
|
||||
// Extract survey properties outside useMemo to create stable references
|
||||
const welcomeCardEnabled = survey.welcomeCard.enabled;
|
||||
const surveyQuestions = survey.questions;
|
||||
|
||||
// Validate startAt parameter against survey questions
|
||||
const isStartAtValid = useMemo(() => {
|
||||
if (!startAt) return false;
|
||||
if (welcomeCardEnabled && startAt === "start") return true;
|
||||
const isValid = surveyQuestions.some((q) => q.id === startAt);
|
||||
if (survey.welcomeCard.enabled && startAt === "start") return true;
|
||||
|
||||
// Clean up invalid startAt from URL to prevent confusion
|
||||
if (!isValid && globalThis.window !== undefined) {
|
||||
const url = new URL(globalThis.location.href);
|
||||
const isValid = survey.questions.some((question) => question.id === startAt);
|
||||
|
||||
// To remove startAt query param from URL if it is not valid:
|
||||
if (!isValid && typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("startAt");
|
||||
globalThis.history.replaceState({}, "", url.toString());
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}, [welcomeCardEnabled, surveyQuestions, startAt]);
|
||||
}, [survey, startAt]);
|
||||
|
||||
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
|
||||
const [autoFocus, setAutoFocus] = useState(false);
|
||||
|
||||
// Enable autofocus only when not in iframe
|
||||
useEffect(() => {
|
||||
if (globalThis.self === globalThis.top) {
|
||||
setAutoFocus(true);
|
||||
const [autoFocus, setAutoFocus] = useState(false);
|
||||
const hasFinishedSingleUseResponse = useMemo(() => {
|
||||
if (singleUseResponse?.finished) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
|
||||
}, []);
|
||||
|
||||
// Extract hidden fields from URL parameters
|
||||
const hiddenFieldsRecord = useMemo(() => {
|
||||
const fieldsRecord: Record<string, string> = {};
|
||||
for (const field of survey.hiddenFields.fieldIds || []) {
|
||||
const answer = searchParams.get(field);
|
||||
if (answer) fieldsRecord[field] = answer;
|
||||
// Not in an iframe, enable autofocus on input fields.
|
||||
useEffect(() => {
|
||||
if (window.self === window.top) {
|
||||
setAutoFocus(true);
|
||||
}
|
||||
return fieldsRecord;
|
||||
}, [searchParams, JSON.stringify(survey.hiddenFields.fieldIds || [])]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
|
||||
}, []);
|
||||
|
||||
const hiddenFieldsRecord = useMemo<TResponseHiddenFieldValue>(() => {
|
||||
const fieldsRecord: TResponseHiddenFieldValue = {};
|
||||
|
||||
survey.hiddenFields.fieldIds?.forEach((field) => {
|
||||
const answer = searchParams.get(field);
|
||||
if (answer) {
|
||||
fieldsRecord[field] = answer;
|
||||
}
|
||||
});
|
||||
|
||||
return fieldsRecord;
|
||||
}, [searchParams, survey.hiddenFields.fieldIds]);
|
||||
|
||||
// Include verified email in hidden fields if available
|
||||
const getVerifiedEmail = useMemo<Record<string, string> | null>(() => {
|
||||
if (survey.isVerifyEmailEnabled && verifiedEmail) {
|
||||
return { verifiedEmail: verifiedEmail };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}, [survey.isVerifyEmailEnabled, verifiedEmail]);
|
||||
|
||||
if (hasFinishedSingleUseResponse) {
|
||||
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
|
||||
}
|
||||
|
||||
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
|
||||
if (emailVerificationStatus === "fishy") {
|
||||
return (
|
||||
<VerifyEmail
|
||||
survey={survey}
|
||||
isErrorComponent={true}
|
||||
languageCode={languageCode}
|
||||
styling={project.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
//emailVerificationStatus === "not-verified"
|
||||
return (
|
||||
<VerifyEmail
|
||||
singleUseId={suId ?? ""}
|
||||
survey={survey}
|
||||
languageCode={languageCode}
|
||||
styling={project.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const determineStyling = () => {
|
||||
// Check if style overwrite is disabled at the project level
|
||||
if (!project.styling.allowStyleOverwrite) {
|
||||
return project.styling;
|
||||
}
|
||||
|
||||
// Return survey styling if survey overwrites are enabled, otherwise return project styling
|
||||
return survey.styling?.overwriteThemeStyling ? survey.styling : project.styling;
|
||||
};
|
||||
|
||||
const handleResetSurvey = () => {
|
||||
setQuestionId(survey.welcomeCard.enabled ? "start" : survey.questions[0].id);
|
||||
setResponseData({});
|
||||
@@ -117,8 +167,8 @@ export const SurveyClientWrapper = ({
|
||||
isWelcomeCardEnabled={survey.welcomeCard.enabled}
|
||||
isPreview={isPreview}
|
||||
surveyType={survey.type}
|
||||
determineStyling={() => styling}
|
||||
handleResetSurvey={handleResetSurvey}
|
||||
determineStyling={determineStyling}
|
||||
isEmbed={isEmbed}
|
||||
publicDomain={publicDomain}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
@@ -130,10 +180,11 @@ export const SurveyClientWrapper = ({
|
||||
environmentId={survey.environmentId}
|
||||
isPreviewMode={isPreview}
|
||||
survey={survey}
|
||||
styling={styling}
|
||||
styling={determineStyling()}
|
||||
languageCode={languageCode}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
shouldResetQuestionId={false}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- need it as focus behaviour is different in normal surveys and survey preview
|
||||
autoFocus={autoFocus}
|
||||
prefillResponseData={prefillValue}
|
||||
skipPrefilled={skipPrefilled}
|
||||
@@ -151,7 +202,7 @@ export const SurveyClientWrapper = ({
|
||||
...getVerifiedEmail,
|
||||
}}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponseId={singleUseResponseId}
|
||||
singleUseResponseId={responseId}
|
||||
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
|
||||
contactId={contactId}
|
||||
recaptchaSiteKey={recaptchaSiteKey}
|
||||
@@ -3,17 +3,17 @@
|
||||
import { Project, Response } from "@prisma/client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { validateSurveyPinAction } from "@/modules/survey/link/actions";
|
||||
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
|
||||
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
|
||||
import { OTPInput } from "@/modules/ui/components/otp-input";
|
||||
|
||||
interface PinScreenProps {
|
||||
surveyId: string;
|
||||
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
|
||||
emailVerificationStatus?: string;
|
||||
singleUseId?: string;
|
||||
singleUseResponse?: Pick<Response, "id" | "finished">;
|
||||
publicDomain: string;
|
||||
@@ -23,12 +23,11 @@ interface PinScreenProps {
|
||||
verifiedEmail?: string;
|
||||
languageCode: string;
|
||||
isEmbed: boolean;
|
||||
locale: string;
|
||||
isPreview: boolean;
|
||||
contactId?: string;
|
||||
recaptchaSiteKey?: string;
|
||||
isSpamProtectionEnabled?: boolean;
|
||||
responseCount?: number;
|
||||
styling: TProjectStyling | TSurveyStyling;
|
||||
}
|
||||
|
||||
export const PinScreen = (props: PinScreenProps) => {
|
||||
@@ -36,6 +35,7 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
surveyId,
|
||||
project,
|
||||
publicDomain,
|
||||
emailVerificationStatus,
|
||||
singleUseId,
|
||||
singleUseResponse,
|
||||
IMPRINT_URL,
|
||||
@@ -44,12 +44,11 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
verifiedEmail,
|
||||
languageCode,
|
||||
isEmbed,
|
||||
locale,
|
||||
isPreview,
|
||||
contactId,
|
||||
recaptchaSiteKey,
|
||||
isSpamProtectionEnabled = false,
|
||||
responseCount,
|
||||
styling,
|
||||
} = props;
|
||||
|
||||
const [localPinEntry, setLocalPinEntry] = useState<string>("");
|
||||
@@ -117,24 +116,24 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SurveyClientWrapper
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
project={project}
|
||||
styling={styling}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponse={singleUseResponse}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
verifiedEmail={verifiedEmail}
|
||||
languageCode={languageCode}
|
||||
isEmbed={isEmbed}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponseId={singleUseResponse?.id}
|
||||
contactId={contactId}
|
||||
recaptchaSiteKey={recaptchaSiteKey}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
isPreview={isPreview}
|
||||
verifiedEmail={verifiedEmail}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
locale={locale}
|
||||
isPreview={isPreview}
|
||||
contactId={contactId}
|
||||
recaptchaSiteKey={recaptchaSiteKey}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-5
@@ -1,21 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Project } from "@prisma/client";
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveySingleUse } from "@formbricks/types/surveys/types";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import footerLogo from "../lib/footerlogo.svg";
|
||||
|
||||
interface SurveyCompletedMessageProps {
|
||||
interface SurveyLinkUsedProps {
|
||||
singleUseMessage: TSurveySingleUse | null;
|
||||
project?: Pick<Project, "linkSurveyBranding">;
|
||||
}
|
||||
|
||||
export const SurveyCompletedMessage = async ({ singleUseMessage, project }: SurveyCompletedMessageProps) => {
|
||||
const t = await getTranslate();
|
||||
export const SurveyLinkUsed = ({ singleUseMessage, project }: SurveyLinkUsedProps) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultHeading = t("s.survey_already_answered_heading");
|
||||
const defaultSubheading = t("s.survey_already_answered_subheading");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
|
||||
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">
|
||||
@@ -1,8 +1,6 @@
|
||||
import { type Response } from "@prisma/client";
|
||||
import { notFound } from "next/navigation";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
IMPRINT_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
@@ -11,13 +9,16 @@ import {
|
||||
RECAPTCHA_SITE_KEY,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
|
||||
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
|
||||
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
|
||||
import { SurveyCompletedMessage } from "@/modules/survey/link/components/survey-completed-message";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
|
||||
import { TEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
|
||||
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
|
||||
interface SurveyRendererProps {
|
||||
survey: TSurvey;
|
||||
@@ -26,31 +27,13 @@ interface SurveyRendererProps {
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
suId?: string;
|
||||
};
|
||||
singleUseId?: string;
|
||||
singleUseResponse?: Pick<Response, "id" | "finished">;
|
||||
singleUseResponse?: Pick<Response, "id" | "finished"> | undefined;
|
||||
contactId?: string;
|
||||
isPreview: boolean;
|
||||
// New props - pre-fetched in parent
|
||||
environmentContext: TEnvironmentContextForLinkSurvey;
|
||||
locale: TUserLocale;
|
||||
isMultiLanguageAllowed: boolean;
|
||||
responseCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders link survey with pre-fetched data from parent.
|
||||
*
|
||||
* This function receives all necessary data as props to avoid additional
|
||||
* database queries. The parent (page.tsx) fetches data in parallel stages
|
||||
* to minimize latency for users geographically distant from servers.
|
||||
*
|
||||
* @param environmentContext - Pre-fetched project and organization data
|
||||
* @param locale - User's locale from Accept-Language header
|
||||
* @param isMultiLanguageAllowed - Calculated from organization billing plan
|
||||
* @param responseCount - Conditionally fetched if showResponseCount is enabled
|
||||
*/
|
||||
export const renderSurvey = async ({
|
||||
survey,
|
||||
searchParams,
|
||||
@@ -58,11 +41,8 @@ export const renderSurvey = async ({
|
||||
singleUseResponse,
|
||||
contactId,
|
||||
isPreview,
|
||||
environmentContext,
|
||||
locale,
|
||||
isMultiLanguageAllowed,
|
||||
responseCount,
|
||||
}: SurveyRendererProps) => {
|
||||
const locale = await findMatchingLocale();
|
||||
const langParam = searchParams.lang;
|
||||
const isEmbed = searchParams.embed === "true";
|
||||
|
||||
@@ -70,27 +50,27 @@ export const renderSurvey = async ({
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Extract project from pre-fetched context
|
||||
const { project } = environmentContext;
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
|
||||
|
||||
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
|
||||
|
||||
if (survey.status !== "inProgress") {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return (
|
||||
<SurveyInactive
|
||||
status={survey.status}
|
||||
surveyClosedMessage={survey.surveyClosedMessage}
|
||||
project={project}
|
||||
project={project || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if single-use survey has already been completed
|
||||
if (singleUseResponse?.finished) {
|
||||
return <SurveyCompletedMessage singleUseMessage={survey.singleUse} project={project} />;
|
||||
}
|
||||
|
||||
// Handle email verification flow if enabled
|
||||
// verify email: Check if the survey requires email verification
|
||||
let emailVerificationStatus = "";
|
||||
let verifiedEmail: string | undefined = undefined;
|
||||
|
||||
@@ -104,42 +84,40 @@ export const renderSurvey = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
|
||||
if (emailVerificationStatus === "fishy") {
|
||||
return (
|
||||
<VerifyEmail
|
||||
survey={survey}
|
||||
isErrorComponent={true}
|
||||
languageCode={getLanguageCode(langParam, isMultiLanguageAllowed, survey)}
|
||||
styling={project.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VerifyEmail
|
||||
singleUseId={searchParams.suId ?? ""}
|
||||
survey={survey}
|
||||
languageCode={getLanguageCode(langParam, isMultiLanguageAllowed, survey)}
|
||||
styling={project.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
// get project
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
// Compute final styling based on project and survey settings
|
||||
const styling = computeStyling(project.styling, survey.styling);
|
||||
const languageCode = getLanguageCode(langParam, isMultiLanguageAllowed, survey);
|
||||
const getLanguageCode = (): string => {
|
||||
if (!langParam || !isMultiLanguageAllowed) return "default";
|
||||
else {
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code === langParam.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
|
||||
);
|
||||
});
|
||||
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
|
||||
return "default";
|
||||
}
|
||||
return selectedLanguage.language.code;
|
||||
}
|
||||
};
|
||||
|
||||
const languageCode = getLanguageCode();
|
||||
const isSurveyPinProtected = Boolean(survey.pin);
|
||||
const responseCount = await getResponseCountBySurveyId(survey.id);
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
// Handle PIN-protected surveys
|
||||
if (survey.pin) {
|
||||
if (isSurveyPinProtected) {
|
||||
return (
|
||||
<PinScreen
|
||||
surveyId={survey.id}
|
||||
styling={styling}
|
||||
publicDomain={publicDomain}
|
||||
project={project}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponse={singleUseResponse}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
@@ -148,74 +126,35 @@ export const renderSurvey = async ({
|
||||
verifiedEmail={verifiedEmail}
|
||||
languageCode={languageCode}
|
||||
isEmbed={isEmbed}
|
||||
locale={locale}
|
||||
isPreview={isPreview}
|
||||
contactId={contactId}
|
||||
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render interactive survey with client component for interactivity
|
||||
return (
|
||||
<SurveyClientWrapper
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
project={project}
|
||||
styling={styling}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponse={singleUseResponse}
|
||||
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
|
||||
verifiedEmail={verifiedEmail}
|
||||
languageCode={languageCode}
|
||||
isEmbed={isEmbed}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponseId={singleUseResponse?.id}
|
||||
contactId={contactId}
|
||||
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
isPreview={isPreview}
|
||||
verifiedEmail={verifiedEmail}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
locale={locale}
|
||||
isPreview={isPreview}
|
||||
contactId={contactId}
|
||||
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines which styling to use based on project and survey settings.
|
||||
* Returns survey styling if theme overwriting is enabled, otherwise returns project styling.
|
||||
*/
|
||||
function computeStyling(
|
||||
projectStyling: TProjectStyling,
|
||||
surveyStyling?: TSurveyStyling | null
|
||||
): TProjectStyling | TSurveyStyling {
|
||||
if (!projectStyling.allowStyleOverwrite) {
|
||||
return projectStyling;
|
||||
}
|
||||
return surveyStyling?.overwriteThemeStyling ? surveyStyling : projectStyling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the language code to use for the survey.
|
||||
* Checks URL parameter against available survey languages and returns
|
||||
* "default" if multi-language is not allowed or language is not found.
|
||||
*/
|
||||
function getLanguageCode(
|
||||
langParam: string | undefined,
|
||||
isMultiLanguageAllowed: boolean,
|
||||
survey: TSurvey
|
||||
): string {
|
||||
if (!langParam || !isMultiLanguageAllowed) return "default";
|
||||
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code === langParam.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
|
||||
return "default";
|
||||
}
|
||||
return selectedLanguage.language.code;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
|
||||
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
|
||||
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
@@ -97,41 +93,18 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
if (isSingleUseSurvey) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
if (!validatedSingleUseId) {
|
||||
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
// Parallel fetch of environment context and locale
|
||||
const [environmentContext, locale, singleUseResponse] = await Promise.all([
|
||||
getEnvironmentContextForLinkSurvey(survey.environmentId),
|
||||
findMatchingLocale(),
|
||||
// Fetch existing response for this contact
|
||||
getExistingContactResponse(survey.id, contactId)(),
|
||||
]);
|
||||
|
||||
// Get multi-language permission
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(
|
||||
environmentContext.organizationBilling.plan
|
||||
);
|
||||
|
||||
// Fetch responseCount only if needed
|
||||
const responseCount = survey.welcomeCard.showResponseCount
|
||||
? await getResponseCountBySurveyId(survey.id)
|
||||
: undefined;
|
||||
|
||||
return renderSurvey({
|
||||
survey,
|
||||
searchParams,
|
||||
contactId,
|
||||
isPreview,
|
||||
singleUseId,
|
||||
singleUseResponse,
|
||||
environmentContext,
|
||||
locale,
|
||||
isMultiLanguageAllowed,
|
||||
responseCount,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -398,7 +398,7 @@ describe("data", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should return undefined when contact response not found", async () => {
|
||||
test("should return null when contact response not found", async () => {
|
||||
const surveyId = "survey-1";
|
||||
const contactId = "nonexistent-contact";
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("data", () => {
|
||||
|
||||
const result = await getExistingContactResponse(surveyId, contactId)();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
|
||||
@@ -66,11 +66,11 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
language: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
code: true,
|
||||
projectId: true,
|
||||
alias: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -93,15 +93,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
},
|
||||
},
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
isPrivate: true,
|
||||
filters: true,
|
||||
include: {
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -216,7 +208,7 @@ export const getExistingContactResponse = reactCache((surveyId: string, contactI
|
||||
},
|
||||
});
|
||||
|
||||
return response ?? undefined;
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentContextForLinkSurvey } from "./environment";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock React cache
|
||||
vi.mock("react", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
describe("getEnvironmentContextForLinkSurvey", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should successfully fetch environment context with all required data", async () => {
|
||||
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9i";
|
||||
const mockData = {
|
||||
project: {
|
||||
id: "clh1a2b3c4d5e6f7g8h9j",
|
||||
name: "Test Project",
|
||||
styling: { primaryColor: "#000000" },
|
||||
logo: { url: "https://example.com/logo.png" },
|
||||
linkSurveyBranding: true,
|
||||
organizationId: "clh1a2b3c4d5e6f7g8h9k",
|
||||
organization: {
|
||||
id: "clh1a2b3c4d5e6f7g8h9k",
|
||||
billing: {
|
||||
plan: "free",
|
||||
limits: {
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: 1000,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
inAppSurvey: {
|
||||
status: "active",
|
||||
},
|
||||
linkSurvey: {
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
|
||||
|
||||
const result = await getEnvironmentContextForLinkSurvey(mockEnvironmentId);
|
||||
|
||||
expect(result).toEqual({
|
||||
project: {
|
||||
id: "clh1a2b3c4d5e6f7g8h9j",
|
||||
name: "Test Project",
|
||||
styling: { primaryColor: "#000000" },
|
||||
logo: { url: "https://example.com/logo.png" },
|
||||
linkSurveyBranding: true,
|
||||
},
|
||||
organizationId: "clh1a2b3c4d5e6f7g8h9k",
|
||||
organizationBilling: mockData.project.organization.billing,
|
||||
});
|
||||
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockEnvironmentId },
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
linkSurveyBranding: true,
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
billing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ValidationError for invalid environment ID", async () => {
|
||||
const invalidId = "invalid-id";
|
||||
|
||||
await expect(getEnvironmentContextForLinkSurvey(invalidId)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when environment has no project", async () => {
|
||||
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9m";
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
||||
project: null,
|
||||
} as any);
|
||||
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
|
||||
ResourceNotFoundError
|
||||
);
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Project");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when environment is not found", async () => {
|
||||
const mockEnvironmentId = "cuid123456789012345";
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
|
||||
ResourceNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when project has no organization", async () => {
|
||||
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9n";
|
||||
const mockData = {
|
||||
project: {
|
||||
id: "clh1a2b3c4d5e6f7g8h9o",
|
||||
name: "Test Project",
|
||||
styling: {},
|
||||
logo: null,
|
||||
linkSurveyBranding: true,
|
||||
organizationId: "clh1a2b3c4d5e6f7g8h9p",
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
|
||||
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(
|
||||
ResourceNotFoundError
|
||||
);
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Organization");
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9q";
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(DatabaseError);
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow("Database error");
|
||||
});
|
||||
|
||||
test("should rethrow non-Prisma errors", async () => {
|
||||
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9r";
|
||||
const genericError = new Error("Generic error");
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getEnvironmentContextForLinkSurvey(mockEnvironmentId)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should handle project with minimal data", async () => {
|
||||
const mockEnvironmentId = "clh1a2b3c4d5e6f7g8h9s";
|
||||
const mockData = {
|
||||
project: {
|
||||
id: "clh1a2b3c4d5e6f7g8h9t",
|
||||
name: "Minimal Project",
|
||||
styling: null,
|
||||
logo: null,
|
||||
linkSurveyBranding: false,
|
||||
organizationId: "clh1a2b3c4d5e6f7g8h9u",
|
||||
organization: {
|
||||
id: "clh1a2b3c4d5e6f7g8h9u",
|
||||
billing: {
|
||||
plan: "free",
|
||||
limits: {
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: 1000,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
inAppSurvey: {
|
||||
status: "inactive",
|
||||
},
|
||||
linkSurvey: {
|
||||
status: "inactive",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockData as any);
|
||||
|
||||
const result = await getEnvironmentContextForLinkSurvey(mockEnvironmentId);
|
||||
|
||||
expect(result).toEqual({
|
||||
project: {
|
||||
id: "clh1a2b3c4d5e6f7g8h9t",
|
||||
name: "Minimal Project",
|
||||
styling: null,
|
||||
logo: null,
|
||||
linkSurveyBranding: false,
|
||||
},
|
||||
organizationId: "clh1a2b3c4d5e6f7g8h9u",
|
||||
organizationBilling: mockData.project.organization.billing,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma, Project } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
/**
|
||||
* @file Data access layer for link surveys - optimized environment context fetching
|
||||
* @module modules/survey/link/lib/environment
|
||||
*
|
||||
* This module provides optimized data fetching for link survey rendering by combining
|
||||
* related queries into a single database call. Uses React cache for automatic request
|
||||
* deduplication within the same render cycle.
|
||||
*/
|
||||
|
||||
type TProjectForLinkSurvey = Pick<Project, "id" | "name" | "styling" | "logo" | "linkSurveyBranding">;
|
||||
|
||||
export interface TEnvironmentContextForLinkSurvey {
|
||||
project: TProjectForLinkSurvey;
|
||||
organizationId: string;
|
||||
organizationBilling: TOrganizationBilling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all environment-related data needed for link surveys in a single optimized query.
|
||||
* Combines project, organization, and billing data using Prisma relationships to minimize
|
||||
* database round trips.
|
||||
*
|
||||
* This function is specifically optimized for link survey rendering and only fetches the
|
||||
* fields required for that use case. Other parts of the application may need different
|
||||
* field combinations and should use their own specialized functions.
|
||||
*
|
||||
* @param environmentId - The environment identifier
|
||||
* @returns Object containing project styling data, organization ID, and billing information
|
||||
* @throws ResourceNotFoundError if environment, project, or organization not found
|
||||
* @throws DatabaseError if database query fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In server components, function is automatically cached per request
|
||||
* const { project, organizationId, organizationBilling } =
|
||||
* await getEnvironmentContextForLinkSurvey(survey.environmentId);
|
||||
* ```
|
||||
*/
|
||||
export const getEnvironmentContextForLinkSurvey = reactCache(
|
||||
async (environmentId: string): Promise<TEnvironmentContextForLinkSurvey> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
try {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
linkSurveyBranding: true,
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
billing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Fail early pattern: validate data before proceeding
|
||||
if (!environment?.project) {
|
||||
throw new ResourceNotFoundError("Project", null);
|
||||
}
|
||||
|
||||
if (!environment.project.organization) {
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
|
||||
// Return structured, typed data
|
||||
return {
|
||||
project: {
|
||||
id: environment.project.id,
|
||||
name: environment.project.name,
|
||||
styling: environment.project.styling,
|
||||
logo: environment.project.logo,
|
||||
linkSurveyBranding: environment.project.linkSurveyBranding,
|
||||
},
|
||||
organizationId: environment.project.organizationId,
|
||||
organizationBilling: environment.project.organization.billing as TOrganizationBilling,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -19,21 +19,16 @@ export const getNameForURL = (value: string) => encodeURIComponent(value);
|
||||
export const getBrandColorForURL = (value: string) => encodeURIComponent(value);
|
||||
|
||||
/**
|
||||
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name.
|
||||
*
|
||||
* @param surveyId - Survey identifier
|
||||
* @param languageCode - Language code for localization (default: "default")
|
||||
* @param survey - Optional survey data if already available (e.g., from generateMetadata)
|
||||
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name
|
||||
*/
|
||||
export const getBasicSurveyMetadata = async (
|
||||
surveyId: string,
|
||||
languageCode = "default",
|
||||
survey?: Awaited<ReturnType<typeof getSurvey>> | null
|
||||
languageCode = "default"
|
||||
): Promise<TBasicSurveyMetadata> => {
|
||||
const surveyData = survey ?? (await getSurvey(surveyId));
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
// If survey doesn't exist, return default metadata
|
||||
if (!surveyData) {
|
||||
if (!survey) {
|
||||
return {
|
||||
title: "Survey",
|
||||
description: "Please complete this survey.",
|
||||
@@ -42,11 +37,11 @@ export const getBasicSurveyMetadata = async (
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = surveyData.metadata;
|
||||
const welcomeCard = surveyData.welcomeCard;
|
||||
const metadata = survey.metadata;
|
||||
const welcomeCard = survey.welcomeCard;
|
||||
const useDefaultLanguageCode =
|
||||
languageCode === "default" ||
|
||||
surveyData.languages.find((lang) => lang.language.code === languageCode)?.default;
|
||||
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
|
||||
|
||||
// Determine language code to use for metadata
|
||||
const langCode = useDefaultLanguageCode ? "default" : languageCode;
|
||||
@@ -56,10 +51,10 @@ export const getBasicSurveyMetadata = async (
|
||||
const titleFromWelcome =
|
||||
welcomeCard?.enabled && welcomeCard.headline
|
||||
? getTextContent(
|
||||
getLocalizedValue(recallToHeadline(welcomeCard.headline, surveyData, false, langCode), langCode)
|
||||
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
|
||||
) || ""
|
||||
: undefined;
|
||||
let title = titleFromMetadata || titleFromWelcome || surveyData.name;
|
||||
let title = titleFromMetadata || titleFromWelcome || survey.name;
|
||||
|
||||
// Set description - priority: custom link metadata > default
|
||||
const descriptionFromMetadata = metadata?.description
|
||||
@@ -68,7 +63,7 @@ export const getBasicSurveyMetadata = async (
|
||||
let description = descriptionFromMetadata || "Please complete this survey.";
|
||||
|
||||
// Get OG image from link metadata if available
|
||||
const ogImage = metadata?.ogImage;
|
||||
const { ogImage } = metadata;
|
||||
|
||||
if (!titleFromMetadata) {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
@@ -79,7 +74,7 @@ export const getBasicSurveyMetadata = async (
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
survey: surveyData,
|
||||
survey,
|
||||
ogImage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
|
||||
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
|
||||
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
|
||||
import { getMetadataForLinkSurvey } from "./metadata";
|
||||
|
||||
vi.mock("@/modules/survey/link/lib/data", () => ({
|
||||
getSurveyWithMetadata: vi.fn(),
|
||||
getSurveyMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -54,12 +54,12 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
status: "published",
|
||||
} as any;
|
||||
|
||||
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
|
||||
|
||||
const result = await getMetadataForLinkSurvey(mockSurveyId);
|
||||
|
||||
expect(getSurveyWithMetadata).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined, mockSurvey);
|
||||
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
|
||||
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -98,7 +98,7 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
status: "published",
|
||||
} as any;
|
||||
|
||||
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getBasicSurveyMetadata).mockResolvedValue({
|
||||
title: mockSurveyName,
|
||||
description: mockDescription,
|
||||
@@ -120,7 +120,7 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
status: "published",
|
||||
};
|
||||
|
||||
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey as any);
|
||||
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey as any);
|
||||
|
||||
await getMetadataForLinkSurvey(mockSurveyId);
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
status: "draft",
|
||||
} as any;
|
||||
|
||||
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
|
||||
|
||||
await getMetadataForLinkSurvey(mockSurveyId);
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
status: "published",
|
||||
} as any;
|
||||
|
||||
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
|
||||
twitter: {
|
||||
title: mockSurveyName,
|
||||
@@ -192,7 +192,7 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
status: "published",
|
||||
} as any;
|
||||
|
||||
vi.mocked(getSurveyWithMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getSurveyMetadata).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getSurveyOpenGraphMetadata).mockReturnValue({
|
||||
openGraph: {
|
||||
title: mockSurveyName,
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
|
||||
import { getSurveyMetadata } from "@/modules/survey/link/lib/data";
|
||||
import { getBasicSurveyMetadata, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
|
||||
|
||||
export const getMetadataForLinkSurvey = async (
|
||||
surveyId: string,
|
||||
languageCode?: string
|
||||
): Promise<Metadata> => {
|
||||
const survey = await getSurveyWithMetadata(surveyId);
|
||||
const survey = await getSurveyMetadata(surveyId);
|
||||
|
||||
if (!survey || survey?.type !== "link" || survey?.status === "draft") {
|
||||
if (!survey || survey.type !== "link" || survey.status === "draft") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode, survey);
|
||||
// Get enhanced metadata that includes custom link metadata
|
||||
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode);
|
||||
const surveyBrandColor = survey.styling?.brandColor?.light;
|
||||
|
||||
// Use the shared function for creating the base metadata but override with custom data
|
||||
|
||||
@@ -3,14 +3,11 @@ import { notFound } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
|
||||
import { getEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
|
||||
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
|
||||
|
||||
interface LinkSurveyPageProps {
|
||||
@@ -50,29 +47,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
|
||||
const isPreview = searchParams.preview === "true";
|
||||
|
||||
/**
|
||||
* Optimized data fetching strategy for link surveys
|
||||
*
|
||||
* PERFORMANCE OPTIMIZATION:
|
||||
* We fetch data in carefully staged parallel operations to minimize latency.
|
||||
* Each sequential database call adds ~100-300ms for users far from servers.
|
||||
*
|
||||
* Fetch stages:
|
||||
* Stage 1: Survey (required first - provides config for all other fetches)
|
||||
* Stage 2: Parallel fetch of environment context, locale, and conditional single-use response
|
||||
* Stage 3: Multi-language permission (depends on billing from Stage 2)
|
||||
*
|
||||
* This reduces waterfall from 4-5 levels to 3 levels:
|
||||
* - Before: ~400-1500ms added latency for distant users
|
||||
* - After: ~200-600ms added latency for distant users
|
||||
* - Improvement: 50-60% latency reduction
|
||||
*
|
||||
* CACHING NOTE:
|
||||
* getSurveyWithMetadata is wrapped in React's cache(), so the call from
|
||||
* generateMetadata and this page component are automatically deduplicated.
|
||||
*/
|
||||
|
||||
// Stage 1: Fetch survey first (required for all subsequent logic)
|
||||
// Use optimized survey data fetcher (includes all necessary data)
|
||||
let survey: TSurvey | null = null;
|
||||
try {
|
||||
survey = await getSurveyWithMetadata(params.surveyId);
|
||||
@@ -81,60 +56,40 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!survey) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const suId = searchParams.suId;
|
||||
|
||||
// Validate single-use ID early (no I/O, just validation)
|
||||
const isSingleUseSurvey = survey.singleUse?.enabled;
|
||||
const isSingleUseSurveyEncrypted = survey.singleUse?.isEncrypted;
|
||||
const isSingleUseSurvey = survey?.singleUse?.enabled;
|
||||
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
|
||||
|
||||
let singleUseId: string | undefined = undefined;
|
||||
|
||||
if (isSingleUseSurvey) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
if (!validatedSingleUseId) {
|
||||
// Need to fetch project for error page - fetch environmentContext for it
|
||||
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
// Stage 2: Parallel fetch of all remaining data
|
||||
const [environmentContext, locale, singleUseResponse] = await Promise.all([
|
||||
getEnvironmentContextForLinkSurvey(survey.environmentId),
|
||||
findMatchingLocale(),
|
||||
// Only fetch single-use response if we have a validated ID
|
||||
isSingleUseSurvey && singleUseId
|
||||
? getResponseBySingleUseId(survey.id, singleUseId)()
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
let singleUseResponse;
|
||||
if (isSingleUseSurvey && singleUseId) {
|
||||
try {
|
||||
// Use optimized response fetcher with proper caching
|
||||
const fetchResponseFn = getResponseBySingleUseId(survey.id, singleUseId);
|
||||
singleUseResponse = await fetchResponseFn();
|
||||
} catch (error) {
|
||||
logger.error("Error fetching single use response:", error);
|
||||
singleUseResponse = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Get multi-language permission (depends on environmentContext)
|
||||
// Future optimization: Consider caching getMultiLanguagePermission by plan tier
|
||||
// since it's a pure computation based on billing plan. Could be memoized at
|
||||
// the plan level rather than per-request.
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(
|
||||
environmentContext.organizationBilling.plan
|
||||
);
|
||||
|
||||
// Fetch responseCount only if needed (depends on survey config)
|
||||
const responseCount = survey.welcomeCard.showResponseCount
|
||||
? await getResponseCountBySurveyId(survey.id)
|
||||
: undefined;
|
||||
|
||||
// Pass all pre-fetched data to renderer
|
||||
return renderSurvey({
|
||||
survey,
|
||||
searchParams,
|
||||
singleUseId,
|
||||
singleUseResponse: singleUseResponse ?? undefined,
|
||||
singleUseResponse,
|
||||
isPreview,
|
||||
environmentContext,
|
||||
locale,
|
||||
isMultiLanguageAllowed,
|
||||
responseCount,
|
||||
});
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user