mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50156d474c | |||
| 87b859d02a | |||
| df7e768216 |
@@ -185,11 +185,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||
# that need to send webhooks to internal services.
|
||||
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
||||
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
|
||||
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
|
||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||
|
||||
## Date and Time Rendering
|
||||
|
||||
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
||||
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
||||
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
||||
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
||||
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
||||
|
||||
## Database & Prisma Performance
|
||||
|
||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
@@ -21,12 +20,12 @@ const Page = async (props: ConnectPageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||
@@ -24,22 +23,22 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const t = await getTranslate();
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(session.user.id, organizationId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -29,7 +28,7 @@ const OnboardingLayout = async (props: {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
@@ -46,7 +45,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new ResourceNotFoundError(t("common.team"), null);
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
@@ -18,13 +17,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -50,7 +46,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
@@ -43,7 +42,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_permission_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -21,15 +20,15 @@ const AccountSettingsLayout = async (props: {
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
+3
-4
@@ -1,6 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
@@ -147,18 +146,18 @@ const Page = async (props: {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
const autoDisableNotificationType = searchParams["type"];
|
||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||
|
||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
if (!memberships) {
|
||||
throw new ResourceNotFoundError(t("common.membership"), null);
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
|
||||
if (user?.notificationSettings) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
@@ -29,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const user = session?.user ? await getUser(session.user.id) : null;
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -26,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||
|
||||
+10
-5
@@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
@@ -50,8 +49,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
gracePeriodEnd,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isRechecking, setIsRechecking] = useState(false);
|
||||
|
||||
@@ -99,7 +97,14 @@ export const EnterpriseLicenseStatus = ({
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
||||
{t("common.updated_at")}{" "}
|
||||
{new Date(lastChecked).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +132,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
||||
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -18,15 +17,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
+3
-2
@@ -96,8 +96,8 @@ export const ResponseTable = ({
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
@@ -300,6 +300,7 @@ export const ResponseTable = ({
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
survey={survey}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
|
||||
+3
-10
@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } 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 { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -35,7 +34,6 @@ const getElementColumnsData = (
|
||||
element: TSurveyElement,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||
@@ -169,7 +167,6 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -221,7 +218,6 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
isReadOnly: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementColumns = elements.flatMap((element) =>
|
||||
getElementColumnsData(element, survey, isExpanded, locale, t)
|
||||
);
|
||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.createdAt);
|
||||
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
||||
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+8
-7
@@ -1,4 +1,3 @@
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
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";
|
||||
@@ -8,6 +7,7 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -23,24 +23,25 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(organization.id),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
@@ -49,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||
@@ -85,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={user.locale}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
|
||||
+9
-10
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
@@ -32,14 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
||||
const parsedDate = new Date(value);
|
||||
|
||||
return (
|
||||
formattedDate ??
|
||||
t("common.invalid_date_with_value", {
|
||||
value,
|
||||
})
|
||||
);
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -60,7 +59,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
@@ -85,7 +84,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 md:px-6">
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -10,11 +9,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
const t = await getTranslate();
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
|
||||
+65
-54
@@ -11,7 +11,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import {
|
||||
getElementSummary,
|
||||
getResponsesForSummary,
|
||||
@@ -43,7 +44,7 @@ vi.mock("@/lib/survey/service", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/surveyLogic/utils", () => ({
|
||||
evaluateLogic: vi.fn(),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
@@ -228,6 +229,12 @@ describe("getSurveySummaryDropOff", () => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
);
|
||||
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
||||
vi.mocked(performActions).mockReturnValue({
|
||||
jumpTarget: undefined,
|
||||
requiredElementIds: [],
|
||||
calculations: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||
@@ -239,7 +246,7 @@ describe("getSurveySummaryDropOff", () => {
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
|
||||
ttc: { q1: 10 },
|
||||
finished: false,
|
||||
}, // Dropped at q2
|
||||
{
|
||||
@@ -262,55 +269,22 @@ describe("getSurveySummaryDropOff", () => {
|
||||
);
|
||||
|
||||
expect(dropOff.length).toBe(2);
|
||||
// Q1: welcome card disabled so impressions = displayCount
|
||||
// Q1
|
||||
expect(dropOff[0].elementId).toBe("q1");
|
||||
expect(dropOff[0].impressions).toBe(displayCount);
|
||||
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||
expect(dropOff[0].ttc).toBe(10);
|
||||
|
||||
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
|
||||
// Q2
|
||||
expect(dropOff[1].elementId).toBe("q2");
|
||||
expect(dropOff[1].impressions).toBe(2);
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
||||
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
|
||||
expect(dropOff[1].ttc).toBe(10);
|
||||
});
|
||||
|
||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
|
||||
const surveyWithWelcome: TSurvey = {
|
||||
...surveyWithBlocks,
|
||||
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
||||
};
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10 }, // Only saw q1, never reached q2
|
||||
finished: false,
|
||||
},
|
||||
] as any;
|
||||
const displayCount = 1;
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithWelcome,
|
||||
getElementsFromBlocks(surveyWithWelcome.blocks),
|
||||
responses,
|
||||
displayCount
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // Saw q1
|
||||
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
|
||||
expect(dropOff[1].impressions).toBe(0); // Never saw q2
|
||||
expect(dropOff[1].dropOffCount).toBe(0);
|
||||
});
|
||||
|
||||
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
|
||||
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
|
||||
test("handles logic jumps", () => {
|
||||
const surveyWithLogic: TSurvey = {
|
||||
...mockBaseSurvey,
|
||||
blocks: [
|
||||
@@ -341,6 +315,36 @@ describe("getSurveySummaryDropOff", () => {
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
logic: [
|
||||
{
|
||||
id: "logic1",
|
||||
conditions: {
|
||||
id: "condition1",
|
||||
connector: "and" as const,
|
||||
conditions: [
|
||||
{
|
||||
id: "c1",
|
||||
leftOperand: {
|
||||
type: "element" as const,
|
||||
value: "q2",
|
||||
},
|
||||
operator: "equals" as const,
|
||||
rightOperand: {
|
||||
type: "static" as const,
|
||||
value: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "action1",
|
||||
objective: "jumpToBlock" as const,
|
||||
target: "q4",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block3",
|
||||
@@ -373,21 +377,28 @@ describe("getSurveySummaryDropOff", () => {
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
|
||||
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a", q2: "b", q4: "d" },
|
||||
data: { q1: "a", q2: "b" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
|
||||
ttc: { q1: 10, q2: 10 },
|
||||
finished: false,
|
||||
},
|
||||
}, // Jumps from q2 to q4, drops at q4
|
||||
];
|
||||
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
|
||||
// Simulate logic on q2 triggering
|
||||
return data.q2 === "b";
|
||||
});
|
||||
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
||||
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
||||
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
||||
}
|
||||
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
||||
});
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithLogic,
|
||||
@@ -396,11 +407,11 @@ describe("getSurveySummaryDropOff", () => {
|
||||
1
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // q1: seen
|
||||
expect(dropOff[1].impressions).toBe(1); // q2: seen
|
||||
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
|
||||
expect(dropOff[0].impressions).toBe(1); // q1
|
||||
expect(dropOff[1].impressions).toBe(1); // q2
|
||||
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+112
-24
@@ -11,6 +11,7 @@ import {
|
||||
TResponseData,
|
||||
TResponseFilterCriteria,
|
||||
TResponseTtc,
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
@@ -36,7 +37,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
|
||||
@@ -91,13 +93,63 @@ export const getSurveySummaryMeta = (
|
||||
};
|
||||
};
|
||||
|
||||
// Determine whether a response interacted with a given element.
|
||||
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
|
||||
// This is more reliable than replaying survey logic, which can misattribute impressions
|
||||
// when branching logic skips elements or when partial response data is insufficient
|
||||
// to evaluate conditions correctly.
|
||||
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
|
||||
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
|
||||
const evaluateLogicAndGetNextElementId = (
|
||||
localSurvey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentElementIndex: number,
|
||||
currElementTemp: TSurveyElement,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextElementId: string | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
||||
|
||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
logic.actions,
|
||||
data,
|
||||
updatedVariables
|
||||
);
|
||||
|
||||
if (requiredElementIds.length > 0) {
|
||||
// Update blocks to mark elements as required
|
||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((e) =>
|
||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
||||
),
|
||||
}));
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
if (jumpTarget && !firstJumpTarget) {
|
||||
firstJumpTarget = jumpTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no jump target was set, check for a fallback logic
|
||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next element
|
||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
||||
|
||||
return { nextElementId, updatedSurvey, updatedVariables };
|
||||
};
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
@@ -118,8 +170,16 @@ export const getSurveySummaryDropOff = (
|
||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
acc[variable.id] = variable.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion per element
|
||||
// Calculate total time-to-completion
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
@@ -127,21 +187,51 @@ export const getSurveySummaryDropOff = (
|
||||
}
|
||||
});
|
||||
|
||||
// Count impressions based on actual interaction data (ttc + response data)
|
||||
// instead of replaying survey logic which is unreliable with branching
|
||||
let lastSeenIdx = -1;
|
||||
let localSurvey = structuredClone(survey);
|
||||
let localResponseData: TResponseData = { ...response.data };
|
||||
let localVariables: TResponseVariables = {
|
||||
...surveyVariablesData,
|
||||
};
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (wasElementSeen(response, element.id)) {
|
||||
impressionsArr[i]++;
|
||||
lastSeenIdx = i;
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < elements.length) {
|
||||
const currQues = elements[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// element is not answered and required
|
||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
impressionsArr[currQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Attribute drop-off to the last element the respondent interacted with
|
||||
if (!response.finished && lastSeenIdx >= 0) {
|
||||
dropOffArr[lastSeenIdx]++;
|
||||
impressionsArr[currQuesIdx]++;
|
||||
|
||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
||||
localSurvey,
|
||||
elements,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
currQues,
|
||||
response.language
|
||||
);
|
||||
|
||||
localSurvey = updatedSurvey;
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextElementId) {
|
||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
||||
if (!response.data[nextElementId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
currQuesIdx = nextQuesIdx;
|
||||
} else {
|
||||
currQuesIdx++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,8 +240,6 @@ export const getSurveySummaryDropOff = (
|
||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||
});
|
||||
|
||||
// When the welcome card is disabled, the first element's impressions should equal displayCount
|
||||
// because every survey display is an impression of the first element
|
||||
if (!survey.welcomeCard.enabled) {
|
||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||
@@ -163,7 +251,7 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
impressionsArr[0] = displayCount;
|
||||
} else {
|
||||
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
}
|
||||
|
||||
for (let i = 1; i < elements.length; i++) {
|
||||
|
||||
+4
-5
@@ -1,5 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
@@ -33,13 +32,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
@@ -47,11 +46,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), organizationId);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||
|
||||
|
||||
+1
-26
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -55,25 +54,6 @@ export enum OptionsType {
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
||||
switch (type) {
|
||||
case OptionsType.ELEMENTS:
|
||||
return t("common.elements");
|
||||
case OptionsType.TAGS:
|
||||
return t("common.tags");
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return t("common.attributes");
|
||||
case OptionsType.OTHERS:
|
||||
return t("common.other_filters");
|
||||
case OptionsType.META:
|
||||
return t("common.meta");
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return t("common.hidden_fields");
|
||||
case OptionsType.QUOTAS:
|
||||
return t("common.quotas");
|
||||
}
|
||||
};
|
||||
|
||||
export type ElementOption = {
|
||||
label: string;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={
|
||||
<p className="text-sm font-medium text-slate-600">
|
||||
{getOptionsTypeTranslationKey(data.header, t)}
|
||||
</p>
|
||||
}>
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SurveyContextWrapper } from "./context/survey-context";
|
||||
|
||||
interface SurveyLayoutProps {
|
||||
@@ -12,10 +10,9 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
|
||||
const resolvedParams = await params;
|
||||
|
||||
const survey = await getSurvey(resolvedParams.surveyId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
|
||||
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
||||
|
||||
+8
-6
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -18,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, integrations, locale] = await Promise.all([
|
||||
const [surveys, integrations] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
if (airtableIntegration?.config.key) {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
}
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
+7
-6
@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, integrations, locale] = await Promise.all([
|
||||
const [surveys, integrations] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
||||
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
||||
);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
+5
-6
@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
NOTION_OAUTH_CLIENT_SECRET,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getNotionDatabases } from "@/lib/notion/service";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
NOTION_REDIRECT_URI
|
||||
);
|
||||
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, notionIntegration, locale] = await Promise.all([
|
||||
const [surveys, notionIntegration] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "notion"),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
let databasesArray: TIntegrationNotionDatabase[] = [];
|
||||
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
||||
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
||||
}
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
notionIntegration={notionIntegration as TIntegrationNotion}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
databasesArray={databasesArray}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
+7
-6
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, slackIntegration, locale] = await Promise.all([
|
||||
const [surveys, slackIntegration] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "slack"),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
CHATWOOT_WEBSITE_TOKEN,
|
||||
IS_CHATWOOT_CONFIGURED,
|
||||
POSTHOG_KEY,
|
||||
SESSION_MAX_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
@@ -25,7 +23,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
{POSTHOG_KEY && user && (
|
||||
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
||||
@@ -41,7 +39,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
)}
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</NextAuthProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const nextAuth = vi.fn(() => nextAuthHandler);
|
||||
|
||||
return {
|
||||
nextAuth,
|
||||
nextAuthHandler,
|
||||
baseSignIn: vi.fn(async () => true),
|
||||
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
|
||||
baseEventSignIn: vi.fn(),
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
default: mocks.nextAuth,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: mocks.captureException,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
error: mocks.loggerError,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {
|
||||
callbacks: {
|
||||
signIn: mocks.baseSignIn,
|
||||
session: mocks.baseSession,
|
||||
},
|
||||
events: {
|
||||
signIn: mocks.baseEventSignIn,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: mocks.queueAuditEventBackground,
|
||||
}));
|
||||
|
||||
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
|
||||
const request = new Request("http://localhost/api/auth/signin", {
|
||||
headers: { "x-request-id": requestId },
|
||||
});
|
||||
|
||||
await GET(request, {} as any);
|
||||
|
||||
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
|
||||
|
||||
return mocks.nextAuth.mock.calls[0][0];
|
||||
};
|
||||
|
||||
describe("auth route audit logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
|
||||
const authOptions = await getWrappedAuthOptions();
|
||||
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
|
||||
const account = { provider: "keycloak" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
|
||||
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
|
||||
|
||||
await authOptions.events.signIn({ user, account, isNewUser: false });
|
||||
|
||||
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: "user_1",
|
||||
targetId: "user_1",
|
||||
organizationId: "unknown",
|
||||
status: "success",
|
||||
userType: "user",
|
||||
newObject: expect.objectContaining({
|
||||
email: "user@example.com",
|
||||
authMethod: "sso",
|
||||
provider: "keycloak",
|
||||
sessionStrategy: "database",
|
||||
isNewUser: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
|
||||
const error = new Error("Access denied");
|
||||
mocks.baseSignIn.mockRejectedValueOnce(error);
|
||||
|
||||
const authOptions = await getWrappedAuthOptions("req-failure");
|
||||
const user = { id: "user_2", email: "user2@example.com" };
|
||||
const account = { provider: "credentials" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
|
||||
|
||||
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: "user_2",
|
||||
targetId: "user_2",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
userType: "user",
|
||||
eventId: "req-failure",
|
||||
newObject: expect.objectContaining({
|
||||
email: "user2@example.com",
|
||||
authMethod: "password",
|
||||
provider: "credentials",
|
||||
errorMessage: "Access denied",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,26 +6,10 @@ import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
export const fetchCache = "force-no-store";
|
||||
|
||||
const getAuthMethod = (account: Account | null) => {
|
||||
if (account?.provider === "credentials") {
|
||||
return "password";
|
||||
}
|
||||
|
||||
if (account?.provider === "token") {
|
||||
return "email_verification";
|
||||
}
|
||||
|
||||
if (account?.provider) {
|
||||
return "sso";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const handler = async (req: Request, ctx: any) => {
|
||||
const eventId = req.headers.get("x-request-id") ?? undefined;
|
||||
|
||||
@@ -33,6 +17,44 @@ const handler = async (req: Request, ctx: any) => {
|
||||
...baseAuthOptions,
|
||||
callbacks: {
|
||||
...baseAuthOptions.callbacks,
|
||||
async jwt(params: any) {
|
||||
let result: any = params.token;
|
||||
let error: any = undefined;
|
||||
|
||||
try {
|
||||
if (baseAuthOptions.callbacks?.jwt) {
|
||||
result = await baseAuthOptions.callbacks.jwt(params);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
logger.withContext({ eventId, err }).error("JWT callback failed");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Audit JWT operations (token refresh, updates)
|
||||
if (params.trigger && params.token?.profile?.id) {
|
||||
const status: TAuditStatus = error ? "failure" : "success";
|
||||
const auditLog = {
|
||||
action: "jwtTokenCreated" as const,
|
||||
targetType: "user" as const,
|
||||
userId: params.token.profile.id,
|
||||
targetId: params.token.profile.id,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status,
|
||||
userType: "user" as const,
|
||||
newObject: { trigger: params.trigger, tokenType: "jwt" },
|
||||
...(error ? { eventId } : {}),
|
||||
};
|
||||
|
||||
queueAuditEventBackground(auditLog);
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
},
|
||||
async session(params: any) {
|
||||
let result: any = params.session;
|
||||
let error: any = undefined;
|
||||
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
|
||||
}) {
|
||||
let result: boolean | string = true;
|
||||
let error: any = undefined;
|
||||
const authMethod = getAuthMethod(account);
|
||||
let authMethod = "unknown";
|
||||
|
||||
try {
|
||||
if (baseAuthOptions.callbacks?.signIn) {
|
||||
@@ -80,6 +102,15 @@ const handler = async (req: Request, ctx: any) => {
|
||||
credentials,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine authentication method for more detailed logging
|
||||
if (account?.provider === "credentials") {
|
||||
authMethod = "password";
|
||||
} else if (account?.provider === "token") {
|
||||
authMethod = "email_verification";
|
||||
} else if (account?.provider && account.provider !== "credentials") {
|
||||
authMethod = "sso";
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
result = false;
|
||||
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (result === false) {
|
||||
queueAuditEventBackground({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: user?.id ?? UNKNOWN_DATA,
|
||||
targetId: user?.id ?? UNKNOWN_DATA,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "failure",
|
||||
userType: "user",
|
||||
newObject: {
|
||||
...user,
|
||||
authMethod,
|
||||
provider: account?.provider,
|
||||
...(error instanceof Error ? { errorMessage: error.message } : {}),
|
||||
},
|
||||
eventId,
|
||||
});
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
...baseAuthOptions.events,
|
||||
async signIn({ user, account, isNewUser }: any) {
|
||||
try {
|
||||
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
|
||||
} catch (err) {
|
||||
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
}
|
||||
|
||||
queueAuditEventBackground({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
const status: TAuditStatus = result === false ? "failure" : "success";
|
||||
const auditLog = {
|
||||
action: "signedIn" as const,
|
||||
targetType: "user" as const,
|
||||
userId: user?.id ?? UNKNOWN_DATA,
|
||||
targetId: user?.id ?? UNKNOWN_DATA,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "success",
|
||||
userType: "user",
|
||||
status,
|
||||
userType: "user" as const,
|
||||
newObject: {
|
||||
...user,
|
||||
authMethod: getAuthMethod(account),
|
||||
authMethod,
|
||||
provider: account?.provider,
|
||||
sessionStrategy: "database",
|
||||
isNewUser: isNewUser ?? false,
|
||||
...(error ? { errorMessage: error.message } : {}),
|
||||
},
|
||||
});
|
||||
...(status === "failure" ? { eventId } : {}),
|
||||
};
|
||||
|
||||
queueAuditEventBackground(auditLog);
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { TUserLocale } from "@formbricks/types/user";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
|
||||
interface NoScriptWarningProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const NoScriptWarning = async ({ locale }: NoScriptWarningProps) => {
|
||||
const t = await getTranslate(locale);
|
||||
|
||||
return (
|
||||
<noscript>
|
||||
<div className="fixed inset-0 z-[9999] flex h-dvh w-full items-center justify-center bg-slate-50">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
|
||||
<h1 className="mb-4 text-2xl font-bold text-slate-800">{t("common.javascript_required")}</h1>
|
||||
<p className="text-slate-600">{t("common.javascript_required_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import { NoScriptWarning } from "@/app/components/NoScriptWarning";
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
@@ -27,7 +26,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang={locale} translate="no">
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
<NoScriptWarning locale={locale} />
|
||||
<SentryProvider
|
||||
sentryDsn={SENTRY_DSN}
|
||||
sentryRelease={SENTRY_RELEASE}
|
||||
|
||||
+11
-18
@@ -140,7 +140,6 @@ checksums:
|
||||
common/connect: 8778ee245078a8be4a2ce855c8c56edc
|
||||
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
|
||||
common/connected: aa0ceca574641de34c74b9e590664230
|
||||
common/contact: 9afa39bc47019ee6dec6c74b6273967c
|
||||
common/contacts: d5b6c3f890b3904eaf5754081945c03d
|
||||
common/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
|
||||
@@ -148,7 +147,6 @@ checksums:
|
||||
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
|
||||
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
|
||||
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
|
||||
common/copy_to_environment: c482d26b8fd4962af6542bbf49e49a32
|
||||
common/count_attributes: 48805e836a9b50f9635ad00fed953058
|
||||
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
|
||||
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
|
||||
@@ -188,12 +186,12 @@ checksums:
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
|
||||
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
|
||||
common/environment_notice: 228a8668be1812e031f438d166861729
|
||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||
@@ -230,13 +228,11 @@ checksums:
|
||||
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
|
||||
common/integration: 40d02f65c4356003e0e90ffb944907d2
|
||||
common/integrations: 0ccce343287704cd90150c32e2fcad36
|
||||
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf
|
||||
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
|
||||
common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313
|
||||
common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e
|
||||
common/invite: 181884cea804cbde665f160811ee7ad0
|
||||
common/invite_them: d4b7aadbd3c924b04ad4fce419709f10
|
||||
common/javascript_required: d7988e5934af4d0df54fda369c0e4fb6
|
||||
common/javascript_required_description: 4b65f456db79af4898888a3dd034fe2f
|
||||
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||
common/label: a5c71bf158481233f8215dbd38cc196b
|
||||
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
@@ -257,9 +253,7 @@ checksums:
|
||||
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
common/meta: 842eac888f134f3525f8ea613d933687
|
||||
common/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
@@ -299,9 +293,10 @@ checksums:
|
||||
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
common/organization: 3dc8489af7e74121f65ce6d9677bc94d
|
||||
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
|
||||
common/organization_not_found: 4cb8c07ec2c599b6f48750e06ffa182b
|
||||
common/organization_settings: 11528aa89ae9935e55dcb54478058775
|
||||
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
@@ -393,6 +388,7 @@ checksums:
|
||||
common/survey_id: 08303e98b3d4134947256e494b0c829e
|
||||
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
|
||||
common/survey_live: d1f370505c67509e7b2759952daba20d
|
||||
common/survey_not_found: 0485ea98d13a414eeefc8f1118b9c293
|
||||
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
|
||||
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
|
||||
common/surveys: 33f68ad4111b32a6361beb9d5c184533
|
||||
@@ -407,6 +403,7 @@ checksums:
|
||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||
common/teams: b63448c05270497973ac4407047dae02
|
||||
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
|
||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
||||
@@ -430,6 +427,7 @@ checksums:
|
||||
common/url: ca97457614226960d41dd18c3c29c86b
|
||||
common/user: 61073457a5c3901084b557d065f876be
|
||||
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
||||
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
||||
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
||||
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
@@ -445,13 +443,14 @@ checksums:
|
||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
|
||||
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
|
||||
common/workspace_id: bafef925e1b57b52a69844fdf47aac3c
|
||||
common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414
|
||||
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
|
||||
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
|
||||
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
@@ -627,6 +626,7 @@ checksums:
|
||||
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
@@ -806,14 +806,8 @@ checksums:
|
||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
|
||||
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
|
||||
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
|
||||
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
|
||||
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
@@ -1350,6 +1344,7 @@ checksums:
|
||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
|
||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
|
||||
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
|
||||
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
|
||||
@@ -1611,8 +1606,6 @@ checksums:
|
||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
|
||||
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
|
||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { upsertAccount } from "./service";
|
||||
|
||||
const { mockUpsert } = vi.hoisted(() => ({
|
||||
mockUpsert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
account: {
|
||||
upsert: mockUpsert,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("account service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("upsertAccount keeps user ownership immutable on update", async () => {
|
||||
const accountData = {
|
||||
userId: "user-1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_at: 123,
|
||||
scope: "openid email",
|
||||
token_type: "Bearer",
|
||||
id_token: "id-token",
|
||||
};
|
||||
|
||||
mockUpsert.mockResolvedValue({
|
||||
id: "account-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...accountData,
|
||||
});
|
||||
|
||||
await upsertAccount(accountData);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
},
|
||||
},
|
||||
create: accountData,
|
||||
update: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_at: 123,
|
||||
scope: "openid email",
|
||||
token_type: "Bearer",
|
||||
id_token: "id-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("upsertAccount wraps Prisma known request errors", async () => {
|
||||
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
|
||||
message: "duplicate account",
|
||||
});
|
||||
|
||||
mockUpsert.mockRejectedValue(prismaError);
|
||||
|
||||
await expect(
|
||||
upsertAccount({
|
||||
userId: "user-1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
message: "duplicate account",
|
||||
});
|
||||
});
|
||||
|
||||
test("upsertAccount rethrows non-Prisma errors", async () => {
|
||||
const error = new Error("unexpected failure");
|
||||
mockUpsert.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
upsertAccount({
|
||||
userId: "user-1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
})
|
||||
).rejects.toThrow("unexpected failure");
|
||||
});
|
||||
});
|
||||
@@ -20,36 +20,3 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
|
||||
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
|
||||
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
|
||||
access_token: validatedAccountData.access_token,
|
||||
refresh_token: validatedAccountData.refresh_token,
|
||||
expires_at: validatedAccountData.expires_at,
|
||||
scope: validatedAccountData.scope,
|
||||
token_type: validatedAccountData.token_type,
|
||||
id_token: validatedAccountData.id_token,
|
||||
};
|
||||
|
||||
try {
|
||||
const account = await prisma.account.upsert({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: validatedAccountData.provider,
|
||||
providerAccountId: validatedAccountData.providerAccountId,
|
||||
},
|
||||
},
|
||||
create: validatedAccountData,
|
||||
update: updateAccountData,
|
||||
});
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export const env = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
|
||||
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
|
||||
@@ -142,7 +141,6 @@ export const env = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
|
||||
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
|
||||
|
||||
@@ -84,9 +84,7 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
|
||||
|
||||
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
|
||||
if (!surveyLanguages?.length || !languageCode) return "default";
|
||||
const language = surveyLanguages.find(
|
||||
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
|
||||
);
|
||||
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "../../organization/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "../service";
|
||||
|
||||
@@ -9,7 +9,7 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipRole(userId, organization.id);
|
||||
|
||||
@@ -378,7 +378,7 @@ export const getResponseDownloadFile = async (
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
|
||||
if (!organizationId) {
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
throw new Error("Organization ID not found");
|
||||
}
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
|
||||
+55
-28
@@ -1,13 +1,62 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
convertDateString,
|
||||
convertDateTimeString,
|
||||
convertDateTimeStringShort,
|
||||
convertDatesInObject,
|
||||
convertTimeString,
|
||||
formatDate,
|
||||
getTodaysDateFormatted,
|
||||
getTodaysDateTimeFormatted,
|
||||
timeSince,
|
||||
timeSinceDate,
|
||||
} from "./time";
|
||||
|
||||
describe("Time Utilities", () => {
|
||||
describe("convertDateString", () => {
|
||||
test("should format date string correctly", () => {
|
||||
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateString("")).toBe("");
|
||||
});
|
||||
|
||||
test("should return null for null input", () => {
|
||||
expect(convertDateString(null as any)).toBe(null);
|
||||
});
|
||||
|
||||
test("should handle invalid date strings", () => {
|
||||
expect(convertDateString("not-a-date")).toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDateTimeString", () => {
|
||||
test("should format date and time string correctly", () => {
|
||||
expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateTimeString("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDateTimeStringShort", () => {
|
||||
test("should format date and time string in short format", () => {
|
||||
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateTimeStringShort("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertTimeString", () => {
|
||||
test("should format time string correctly", () => {
|
||||
expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSince", () => {
|
||||
test("should format time since in English", () => {
|
||||
const now = new Date();
|
||||
@@ -26,18 +75,6 @@ describe("Time Utilities", () => {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
|
||||
});
|
||||
|
||||
test("should format time since in Brazilian Portuguese", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
|
||||
});
|
||||
|
||||
test("should format time since in European Portuguese", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSinceDate", () => {
|
||||
@@ -46,12 +83,6 @@ describe("Time Utilities", () => {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
|
||||
});
|
||||
|
||||
test("should format time since from Date object in the provided locale", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
@@ -59,17 +90,13 @@ describe("Time Utilities", () => {
|
||||
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
|
||||
expect(formatDate(date)).toBe("March 20, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
test("should format date with the provided locale", () => {
|
||||
const date = new Date(2024, 2, 20);
|
||||
|
||||
expect(formatDate(date, "de-DE")).toBe(
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
describe("getTodaysDateFormatted", () => {
|
||||
test("should format today's date with specified separator", () => {
|
||||
const today = new Date();
|
||||
const expected = today.toISOString().split("T")[0].split("-").join(".");
|
||||
expect(getTodaysDateFormatted(".")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+120
-27
@@ -1,33 +1,120 @@
|
||||
import { type Locale, formatDistance } from "date-fns";
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { formatDateForDisplay } from "./utils/datetime";
|
||||
|
||||
const DEFAULT_LOCALE: TUserLocale = "en-US";
|
||||
const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
|
||||
"de-DE": de,
|
||||
"en-US": enUS,
|
||||
"es-ES": es,
|
||||
"fr-FR": fr,
|
||||
"hu-HU": hu,
|
||||
"ja-JP": ja,
|
||||
"nl-NL": nl,
|
||||
"pt-BR": ptBR,
|
||||
"pt-PT": pt,
|
||||
"ro-RO": ro,
|
||||
"ru-RU": ru,
|
||||
"sv-SE": sv,
|
||||
"zh-Hans-CN": zhCN,
|
||||
"zh-Hant-TW": zhTW,
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
if (dateString === null) return null;
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale);
|
||||
export const convertDateTimeString = (dateString: string) => {
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/** Maps locale strings to date-fns locales and falls back to English for unsupported inputs. */
|
||||
const getLocaleForTimeSince = (locale: string): Locale =>
|
||||
isUserLocale(locale) ? TIME_SINCE_LOCALES[locale] : enUS;
|
||||
export const convertDateTimeStringShort = (dateString: string) => {
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => {
|
||||
export const convertTimeString = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
switch (locale) {
|
||||
case "de-DE":
|
||||
return de;
|
||||
case "en-US":
|
||||
return enUS;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "fr-FR":
|
||||
return fr;
|
||||
case "hu-HU":
|
||||
return hu;
|
||||
case "ja-JP":
|
||||
return ja;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "pt-BR":
|
||||
return ptBR;
|
||||
case "pt-PT":
|
||||
return pt;
|
||||
case "ro-RO":
|
||||
return ro;
|
||||
case "ru-RU":
|
||||
return ru;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
}
|
||||
};
|
||||
|
||||
export const timeSince = (dateString: string, locale: TUserLocale) => {
|
||||
const date = new Date(dateString);
|
||||
return formatDistance(date, new Date(), {
|
||||
addSuffix: true,
|
||||
@@ -35,21 +122,27 @@ export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) =
|
||||
});
|
||||
};
|
||||
|
||||
export const timeSinceDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
|
||||
export const timeSinceDate = (date: Date) => {
|
||||
return formatDistance(date, new Date(), {
|
||||
addSuffix: true,
|
||||
locale: getLocaleForTimeSince(locale),
|
||||
});
|
||||
};
|
||||
|
||||
export const formatDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
|
||||
return formatDateForDisplay(date, locale, {
|
||||
export const formatDate = (date: Date) => {
|
||||
return intlFormat(date, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export const getTodaysDateFormatted = (seperator: string) => {
|
||||
const date = new Date();
|
||||
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
export const getTodaysDateTimeFormatted = (seperator: string) => {
|
||||
const date = new Date();
|
||||
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
|
||||
|
||||
describe("date display utils", () => {
|
||||
test("parses ISO stored dates", () => {
|
||||
const parsedDate = parseStoredDateValue("2025-05-06");
|
||||
|
||||
expect(parsedDate).not.toBeNull();
|
||||
expect(parsedDate?.getFullYear()).toBe(2025);
|
||||
expect(parsedDate?.getMonth()).toBe(4);
|
||||
expect(parsedDate?.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
test("parses legacy stored dates using the element format", () => {
|
||||
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
|
||||
|
||||
expect(parsedDate).not.toBeNull();
|
||||
expect(parsedDate?.getFullYear()).toBe(2025);
|
||||
expect(parsedDate?.getMonth()).toBe(4);
|
||||
expect(parsedDate?.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
test("parses day-first stored dates when no format is provided", () => {
|
||||
const parsedDate = parseStoredDateValue("06-05-2025");
|
||||
|
||||
expect(parsedDate).not.toBeNull();
|
||||
expect(parsedDate?.getFullYear()).toBe(2025);
|
||||
expect(parsedDate?.getMonth()).toBe(4);
|
||||
expect(parsedDate?.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
test("formats stored dates using the selected locale", () => {
|
||||
const date = new Date(2025, 4, 6);
|
||||
|
||||
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for invalid stored dates", () => {
|
||||
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
|
||||
});
|
||||
|
||||
test("builds a date format map for survey date elements", () => {
|
||||
const elements = [
|
||||
{
|
||||
id: "dateQuestion",
|
||||
type: "date",
|
||||
format: "d-M-y",
|
||||
},
|
||||
{
|
||||
id: "textQuestion",
|
||||
type: "openText",
|
||||
},
|
||||
] as TSurveyElement[];
|
||||
|
||||
expect(getSurveyDateFormatMap(elements)).toEqual({
|
||||
dateQuestion: "d-M-y",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { formatDateWithOrdinal } from "./datetime";
|
||||
|
||||
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
|
||||
|
||||
const ISO_STORED_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
|
||||
|
||||
const buildDate = (year: number, month: number, day: number): Date | null => {
|
||||
if ([year, month, day].some((value) => Number.isNaN(value))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(year, month - 1, day);
|
||||
|
||||
if (
|
||||
parsedDate.getFullYear() !== year ||
|
||||
parsedDate.getMonth() !== month - 1 ||
|
||||
parsedDate.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedDate;
|
||||
};
|
||||
|
||||
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
|
||||
const parts = value.split("-");
|
||||
|
||||
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [first, second, third] = parts.map(Number);
|
||||
|
||||
switch (format) {
|
||||
case "M-d-y":
|
||||
return buildDate(third, first, second);
|
||||
case "d-M-y":
|
||||
return buildDate(third, second, first);
|
||||
case "y-M-d":
|
||||
return buildDate(first, second, third);
|
||||
}
|
||||
};
|
||||
|
||||
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
|
||||
const isoMatch = ISO_STORED_DATE_PATTERN.exec(value);
|
||||
|
||||
if (isoMatch) {
|
||||
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
|
||||
}
|
||||
|
||||
if (format) {
|
||||
return parseLegacyStoredDateValue(value, format);
|
||||
}
|
||||
|
||||
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
|
||||
return parseLegacyStoredDateValue(value, "d-M-y");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatStoredDateForDisplay = (
|
||||
value: string,
|
||||
format: TSurveyDateElement["format"] | undefined,
|
||||
locale: string = "en-US"
|
||||
): string | null => {
|
||||
const parsedDate = parseStoredDateValue(value, format);
|
||||
|
||||
if (!parsedDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatDateWithOrdinal(parsedDate, locale);
|
||||
};
|
||||
|
||||
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
|
||||
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
|
||||
if (element.type === "date") {
|
||||
dateFormats[element.id] = element.format;
|
||||
}
|
||||
|
||||
return dateFormats;
|
||||
}, {});
|
||||
};
|
||||
@@ -1,12 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
diffInDays,
|
||||
formatDateForDisplay,
|
||||
formatDateTimeForDisplay,
|
||||
formatDateWithOrdinal,
|
||||
getFormattedDateTimeString,
|
||||
isValidDateString,
|
||||
} from "./datetime";
|
||||
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
|
||||
|
||||
describe("datetime utils", () => {
|
||||
test("diffInDays calculates the difference in days between two dates", () => {
|
||||
@@ -15,45 +8,13 @@ describe("datetime utils", () => {
|
||||
expect(diffInDays(date1, date2)).toBe(5);
|
||||
});
|
||||
|
||||
test("formatDateWithOrdinal formats a date using the provided locale", () => {
|
||||
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
|
||||
// Create a date that's fixed to May 6, 2025 at noon UTC
|
||||
// Using noon ensures the date won't change in most timezones
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
|
||||
|
||||
expect(formatDateWithOrdinal(date)).toBe(
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("formatDateForDisplay uses the provided locale", () => {
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
|
||||
|
||||
expect(formatDateForDisplay(date, "de-DE")).toBe(
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("formatDateTimeForDisplay uses the provided locale", () => {
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
|
||||
|
||||
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
|
||||
new Intl.DateTimeFormat("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date)
|
||||
);
|
||||
// Test the function
|
||||
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
|
||||
});
|
||||
|
||||
test("isValidDateString validates correct date strings", () => {
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
|
||||
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
const relevantDigits = day < 30 ? day % 20 : day % 30;
|
||||
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
|
||||
};
|
||||
|
||||
// Helper function to calculate difference in days between two dates
|
||||
@@ -20,44 +10,23 @@ export const diffInDays = (date1: Date, date2: Date) => {
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const formatDateForDisplay = (
|
||||
date: Date,
|
||||
locale: string = DEFAULT_LOCALE,
|
||||
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS
|
||||
): string => {
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
};
|
||||
|
||||
export const formatDateTimeForDisplay = (
|
||||
date: Date,
|
||||
locale: string = DEFAULT_LOCALE,
|
||||
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
|
||||
): string => {
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
};
|
||||
|
||||
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
|
||||
return formatDateForDisplay(date, locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
|
||||
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
|
||||
const day = date.getDate();
|
||||
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
|
||||
const year = date.getFullYear();
|
||||
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
||||
};
|
||||
|
||||
export const isValidDateString = (value: string) => {
|
||||
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
|
||||
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
|
||||
|
||||
if (!regex.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value)
|
||||
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1")
|
||||
: value;
|
||||
|
||||
const date = new Date(normalizedValue);
|
||||
return !Number.isNaN(date.getTime());
|
||||
const date = new Date(value);
|
||||
return date;
|
||||
};
|
||||
|
||||
export const getFormattedDateTimeString = (date: Date): string => {
|
||||
|
||||
@@ -32,17 +32,16 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/date-display", () => ({
|
||||
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => {
|
||||
if (value === "2023-01-01") {
|
||||
return `formatted-${locale}-${format ?? "iso"}`;
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
isValidDateString: vi.fn((value) => {
|
||||
try {
|
||||
return !isNaN(new Date(value as string).getTime());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === "01-02-2023" && format === "M-d-y") {
|
||||
return `legacy-${locale}-${format}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
formatDateWithOrdinal: vi.fn(() => {
|
||||
return "January 1st, 2023";
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -478,20 +477,7 @@ describe("recall utility functions", () => {
|
||||
};
|
||||
|
||||
const result = parseRecallInfo(text, responseData);
|
||||
expect(result).toBe("You joined on formatted-en-US-iso");
|
||||
});
|
||||
|
||||
test("formats legacy date values using the provided locale and stored format", () => {
|
||||
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
|
||||
const responseData: TResponseData = {
|
||||
joinDate: "01-02-2023",
|
||||
};
|
||||
|
||||
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
|
||||
joinDate: "M-d-y",
|
||||
});
|
||||
|
||||
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
|
||||
expect(result).toBe("You joined on January 1st, 2023");
|
||||
});
|
||||
|
||||
test("formats array values as comma-separated list", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
|
||||
export interface fallbacks {
|
||||
[id: string]: string;
|
||||
@@ -224,9 +224,7 @@ export const parseRecallInfo = (
|
||||
text: string,
|
||||
responseData?: TResponseData,
|
||||
variables?: TResponseVariables,
|
||||
withSlash: boolean = false,
|
||||
locale: string = "en-US",
|
||||
dateFormats?: TSurveyDateFormatMap
|
||||
withSlash: boolean = false
|
||||
) => {
|
||||
let modifiedText = text;
|
||||
const questionIds = responseData ? Object.keys(responseData) : [];
|
||||
@@ -256,14 +254,12 @@ export const parseRecallInfo = (
|
||||
value = responseData[recallItemId];
|
||||
|
||||
// Apply formatting for special value types
|
||||
if (typeof value === "string") {
|
||||
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
|
||||
|
||||
if (formattedDate) {
|
||||
value = formattedDate;
|
||||
if (value) {
|
||||
if (isValidDateString(value as string)) {
|
||||
value = formatDateWithOrdinal(new Date(value as string));
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ vi.mock("node:dns", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
|
||||
}));
|
||||
|
||||
const mockResolve = vi.mocked(dns.resolve);
|
||||
const mockResolve6 = vi.mocked(dns.resolve6);
|
||||
|
||||
@@ -298,78 +294,4 @@ describe("validateWebhookUrl", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
|
||||
test("allows private IP URLs when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows localhost when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows localhost.localdomain when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows hostname resolving to private IP when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
setupDnsResolution(["192.168.1.1"]);
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("still rejects unresolvable hostnames when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
setupDnsResolution(null, null);
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
|
||||
"Could not resolve webhook URL hostname"
|
||||
);
|
||||
});
|
||||
|
||||
test("still rejects invalid URL format when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
|
||||
});
|
||||
|
||||
test("still rejects non-HTTP protocols when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
|
||||
"Webhook URL must use HTTPS or HTTP protocol"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "server-only";
|
||||
import dns from "node:dns";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
@@ -140,10 +139,8 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
|
||||
// Direct IP literal — validate without DNS resolution
|
||||
@@ -152,17 +149,12 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
|
||||
if (isIPv4Literal || isIPv6Literal) {
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
|
||||
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Domain name — resolve DNS and validate every resolved IP
|
||||
let resolvedIPs: string[];
|
||||
try {
|
||||
@@ -176,11 +168,9 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Verbinden",
|
||||
"connect_formbricks": "Formbricks verbinden",
|
||||
"connected": "Verbunden",
|
||||
"contact": "Kontakt",
|
||||
"contacts": "Kontakte",
|
||||
"continue": "Weitermachen",
|
||||
"copied": "Kopiert",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Kopieren",
|
||||
"copy_code": "Code kopieren",
|
||||
"copy_link": "Link kopieren",
|
||||
"copy_to_environment": "In {{environment}} kopieren",
|
||||
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
|
||||
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
|
||||
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(Kopie {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"elements": "Elemente",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment": "Umgebung",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Inaktive Umfragen",
|
||||
"integration": "Integration",
|
||||
"integrations": "Integrationen",
|
||||
"invalid_date_with_value": "Ungültiges Datum: {value}",
|
||||
"invalid_date": "Ungültiges Datum",
|
||||
"invalid_file_name": "Ungültiger Dateiname, bitte benennen Sie Ihre Datei um und versuchen Sie es erneut",
|
||||
"invalid_file_type": "Ungültiger Dateityp",
|
||||
"invite": "Einladen",
|
||||
"invite_them": "Lade sie ein",
|
||||
"javascript_required": "JavaScript erforderlich",
|
||||
"javascript_required_description": "Formbricks benötigt JavaScript, um ordnungsgemäß zu funktionieren. Bitte aktiviere JavaScript in deinen Browsereinstellungen, um fortzufahren.",
|
||||
"key": "Schlüssel",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Mitglieder",
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership": "Mitgliedschaft",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadaten",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "oder",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_not_found": "Organisation nicht gefunden",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
|
||||
"other": "Andere",
|
||||
"other_filters": "Weitere Filter",
|
||||
"others": "Andere",
|
||||
"overlay_color": "Overlay-Farbe",
|
||||
"overview": "Überblick",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "Umfrage-ID",
|
||||
"survey_languages": "Umfragesprachen",
|
||||
"survey_live": "Umfrage live",
|
||||
"survey_not_found": "Umfrage nicht gefunden",
|
||||
"survey_paused": "Umfrage pausiert.",
|
||||
"survey_type": "Umfragetyp",
|
||||
"surveys": "Umfragen",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Teamname",
|
||||
"team_role": "Team-Rolle",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams nicht gefunden",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
"time_to_finish": "Zeit zum Fertigstellen",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Benutzer",
|
||||
"user_id": "Benutzer-ID",
|
||||
"user_not_found": "Benutzer nicht gefunden",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variablen-IDs",
|
||||
"variables": "Variablen",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "Wochen",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Arbeitsbereich",
|
||||
"workspace_configuration": "Projektkonfiguration",
|
||||
"workspace_created_successfully": "Projekt erfolgreich erstellt",
|
||||
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
|
||||
"workspace_id": "Projekt-ID",
|
||||
"workspace_name": "Projektname",
|
||||
"workspace_name_placeholder": "z. B. Formbricks",
|
||||
"workspace_not_found": "Projekt nicht gefunden",
|
||||
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
|
||||
"workspaces": "Projekte",
|
||||
"years": "Jahre",
|
||||
"you": "Du",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Benutzerdefinierter Hostname",
|
||||
"customize_survey_logo": "Umfragelogo anpassen",
|
||||
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
|
||||
"date_format": "Datumsformat",
|
||||
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
|
||||
"delete_anyways": "Trotzdem löschen",
|
||||
"delete_block": "Block löschen",
|
||||
|
||||
+11
-54
@@ -167,7 +167,6 @@
|
||||
"connect": "Connect",
|
||||
"connect_formbricks": "Connect Formbricks",
|
||||
"connected": "Connected",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continue",
|
||||
"copied": "Copied",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Copy",
|
||||
"copy_code": "Copy code",
|
||||
"copy_link": "Copy Link",
|
||||
"copy_to_environment": "Copy to {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
|
||||
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
|
||||
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(copy {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"elements": "Elements",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment": "Environment",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You are currently in the {environment} environment.",
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Inactive surveys",
|
||||
"integration": "integration",
|
||||
"integrations": "Integrations",
|
||||
"invalid_date_with_value": "Invalid date: {value}",
|
||||
"invalid_date": "Invalid date",
|
||||
"invalid_file_name": "Invalid file name, please rename your file and try again",
|
||||
"invalid_file_type": "Invalid file type",
|
||||
"invite": "Invite",
|
||||
"invite_them": "Invite them",
|
||||
"javascript_required": "JavaScript Required",
|
||||
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
|
||||
"key": "Key",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Members",
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership": "Membership",
|
||||
"membership_not_found": "Membership not found",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "or",
|
||||
"organization": "Organization",
|
||||
"organization_id": "Organization ID",
|
||||
"organization_not_found": "Organization not found",
|
||||
"organization_settings": "Organization settings",
|
||||
"organization_teams_not_found": "Organization teams not found",
|
||||
"other": "Other",
|
||||
"other_filters": "Other Filters",
|
||||
"others": "Others",
|
||||
"overlay_color": "Overlay color",
|
||||
"overview": "Overview",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "Survey ID",
|
||||
"survey_languages": "Survey Languages",
|
||||
"survey_live": "Survey live",
|
||||
"survey_not_found": "Survey not found",
|
||||
"survey_paused": "Survey paused.",
|
||||
"survey_type": "Survey Type",
|
||||
"surveys": "Surveys",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Team name",
|
||||
"team_role": "Team role",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams not found",
|
||||
"text": "Text",
|
||||
"time": "Time",
|
||||
"time_to_finish": "Time to finish",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"user_not_found": "User not found",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variable IDs",
|
||||
"variables": "Variables",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "weeks",
|
||||
"welcome_card": "Welcome card",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Workspace",
|
||||
"workspace_configuration": "Workspace Configuration",
|
||||
"workspace_created_successfully": "Workspace created successfully",
|
||||
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
|
||||
"workspace_id": "Workspace ID",
|
||||
"workspace_name": "Workspace Name",
|
||||
"workspace_name_placeholder": "e.g. Formbricks",
|
||||
"workspace_not_found": "Workspace not found",
|
||||
"workspace_permission_not_found": "Workspace permission not found",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "years",
|
||||
"you": "You",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Custom hostname",
|
||||
"customize_survey_logo": "Customize the survey logo",
|
||||
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
|
||||
"date_format": "Date format",
|
||||
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
|
||||
"delete_anyways": "Delete anyways",
|
||||
"delete_block": "Delete block",
|
||||
@@ -1532,28 +1533,6 @@
|
||||
"ignore_global_waiting_time": "Ignore Cooldown Period",
|
||||
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
||||
"image": "Image",
|
||||
"import_error_invalid_json": "Invalid JSON file",
|
||||
"import_error_validation": "Survey validation failed",
|
||||
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
|
||||
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
|
||||
"import_survey": "Import Survey",
|
||||
"import_survey_description": "Import a survey from a JSON file",
|
||||
"import_survey_error": "Failed to import survey",
|
||||
"import_survey_errors": "Errors",
|
||||
"import_survey_file_label": "Select JSON file",
|
||||
"import_survey_import": "Import Survey",
|
||||
"import_survey_name_label": "Survey Name",
|
||||
"import_survey_new_id": "New Survey ID",
|
||||
"import_survey_success": "Survey imported successfully",
|
||||
"import_survey_upload": "Upload File",
|
||||
"import_survey_validate": "Validating...",
|
||||
"import_survey_warnings": "Warnings",
|
||||
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
|
||||
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan and might be removed.",
|
||||
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
|
||||
"import_warning_multi_language": "Multi-language surveys require an enterprise plan and might be removed.",
|
||||
"import_warning_recaptcha": "Spam protection requires an enterprise plan and might be disabled.",
|
||||
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
|
||||
"includes_all_of": "Includes all of",
|
||||
"includes_one_of": "Includes one of",
|
||||
"initial_value": "Initial value",
|
||||
@@ -1859,28 +1838,6 @@
|
||||
"complete_responses": "Complete responses",
|
||||
"partial_responses": "Partial responses"
|
||||
},
|
||||
"import_error_invalid_json": "Invalid JSON file",
|
||||
"import_error_validation": "Survey validation failed",
|
||||
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
|
||||
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
|
||||
"import_survey": "Import Survey",
|
||||
"import_survey_description": "Import a survey from a JSON file",
|
||||
"import_survey_error": "Failed to import survey",
|
||||
"import_survey_errors": "Errors",
|
||||
"import_survey_file_label": "Select JSON file",
|
||||
"import_survey_import": "Import Survey",
|
||||
"import_survey_name_label": "Survey Name",
|
||||
"import_survey_new_id": "New Survey ID",
|
||||
"import_survey_success": "Survey imported successfully",
|
||||
"import_survey_upload": "Upload File",
|
||||
"import_survey_validate": "Validating...",
|
||||
"import_survey_warnings": "Warnings",
|
||||
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
|
||||
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
|
||||
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
|
||||
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
|
||||
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
|
||||
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
|
||||
"new_survey": "New Survey",
|
||||
"no_surveys_created_yet": "No surveys created yet",
|
||||
"open_options": "Open options",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contact": "Contacto",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar enlace",
|
||||
"copy_to_environment": "Copiar a {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
|
||||
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
|
||||
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(copia {copyNumber})",
|
||||
"e_commerce": "Comercio electrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Tarjeta final",
|
||||
"enter_url": "Introducir URL",
|
||||
"enterprise_license": "Licencia empresarial",
|
||||
"environment": "Entorno",
|
||||
"environment_not_found": "Entorno no encontrado",
|
||||
"environment_notice": "Actualmente estás en el entorno {environment}.",
|
||||
"error": "Error",
|
||||
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Encuestas inactivas",
|
||||
"integration": "integración",
|
||||
"integrations": "Integraciones",
|
||||
"invalid_date_with_value": "Fecha no válida: {value}",
|
||||
"invalid_date": "Fecha no válida",
|
||||
"invalid_file_name": "Nombre de archivo no válido, por favor renombre su archivo e inténtelo de nuevo",
|
||||
"invalid_file_type": "Tipo de archivo no válido",
|
||||
"invite": "Invitar",
|
||||
"invite_them": "Invítales",
|
||||
"javascript_required": "Se requiere JavaScript",
|
||||
"javascript_required_description": "Formbricks requiere JavaScript para funcionar correctamente. Por favor, activa JavaScript en la configuración de tu navegador para continuar.",
|
||||
"key": "Clave",
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Miembros",
|
||||
"members_and_teams": "Miembros y equipos",
|
||||
"membership": "Membresía",
|
||||
"membership_not_found": "Membresía no encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadatos",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "o",
|
||||
"organization": "Organización",
|
||||
"organization_id": "ID de organización",
|
||||
"organization_not_found": "Organización no encontrada",
|
||||
"organization_settings": "Ajustes de la organización",
|
||||
"organization_teams_not_found": "Equipos de la organización no encontrados",
|
||||
"other": "Otro",
|
||||
"other_filters": "Otros Filtros",
|
||||
"others": "Otros",
|
||||
"overlay_color": "Color de superposición",
|
||||
"overview": "Resumen",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "ID de encuesta",
|
||||
"survey_languages": "Idiomas de la encuesta",
|
||||
"survey_live": "Encuesta activa",
|
||||
"survey_not_found": "Encuesta no encontrada",
|
||||
"survey_paused": "Encuesta pausada.",
|
||||
"survey_type": "Tipo de encuesta",
|
||||
"surveys": "Encuestas",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Nombre del equipo",
|
||||
"team_role": "Rol del equipo",
|
||||
"teams": "Equipos",
|
||||
"teams_not_found": "Equipos no encontrados",
|
||||
"text": "Texto",
|
||||
"time": "Hora",
|
||||
"time_to_finish": "Tiempo para finalizar",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Usuario",
|
||||
"user_id": "ID de usuario",
|
||||
"user_not_found": "Usuario no encontrado",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "IDs de variables",
|
||||
"variables": "Variables",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Tarjeta de bienvenida",
|
||||
"workflows": "Flujos de trabajo",
|
||||
"workspace": "Espacio de trabajo",
|
||||
"workspace_configuration": "Configuración del proyecto",
|
||||
"workspace_created_successfully": "Proyecto creado correctamente",
|
||||
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
|
||||
"workspace_id": "ID del proyecto",
|
||||
"workspace_name": "Nombre del proyecto",
|
||||
"workspace_name_placeholder": "p. ej. Formbricks",
|
||||
"workspace_not_found": "Proyecto no encontrado",
|
||||
"workspace_permission_not_found": "Permiso del proyecto no encontrado",
|
||||
"workspaces": "Proyectos",
|
||||
"years": "años",
|
||||
"you": "Tú",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
|
||||
"contact_deleted_successfully": "Contacto eliminado correctamente",
|
||||
"contact_not_found": "No se ha encontrado dicho contacto",
|
||||
"contacts_table_refresh": "Actualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
||||
"create_attribute": "Crear atributo",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Nombre de host personalizado",
|
||||
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
|
||||
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
|
||||
"date_format": "Formato de fecha",
|
||||
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
|
||||
"delete_anyways": "Eliminar de todos modos",
|
||||
"delete_block": "Eliminar bloque",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Connecter",
|
||||
"connect_formbricks": "Connecter Formbricks",
|
||||
"connected": "Connecté",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continuer",
|
||||
"copied": "Copié",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Copier",
|
||||
"copy_code": "Copier le code",
|
||||
"copy_link": "Copier le lien",
|
||||
"copy_to_environment": "Copier vers {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
|
||||
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
|
||||
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"elements": "Éléments",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment": "Environnement",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
"error": "Erreur",
|
||||
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Sondages inactifs",
|
||||
"integration": "intégration",
|
||||
"integrations": "Intégrations",
|
||||
"invalid_date_with_value": "Date invalide: {value}",
|
||||
"invalid_date": "Date invalide",
|
||||
"invalid_file_name": "Nom de fichier invalide, veuillez renommer votre fichier et réessayer",
|
||||
"invalid_file_type": "Type de fichier invalide",
|
||||
"invite": "Inviter",
|
||||
"invite_them": "Invitez-les",
|
||||
"javascript_required": "JavaScript requis",
|
||||
"javascript_required_description": "Formbricks nécessite JavaScript pour fonctionner correctement. Veuillez activer JavaScript dans les paramètres de votre navigateur pour continuer.",
|
||||
"key": "Clé",
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Membres",
|
||||
"members_and_teams": "Membres & Équipes",
|
||||
"membership": "Adhésion",
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"meta": "Méta",
|
||||
"metadata": "Métadonnées",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "ou",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Identifiant de l'organisation",
|
||||
"organization_not_found": "Organisation non trouvée",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"organization_teams_not_found": "Équipes d'organisation non trouvées",
|
||||
"other": "Autre",
|
||||
"other_filters": "Autres filtres",
|
||||
"others": "Autres",
|
||||
"overlay_color": "Couleur de superposition",
|
||||
"overview": "Aperçu",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "ID de l'enquête",
|
||||
"survey_languages": "Langues de l'enquête",
|
||||
"survey_live": "Sondage en direct",
|
||||
"survey_not_found": "Sondage non trouvé",
|
||||
"survey_paused": "Sondage en pause.",
|
||||
"survey_type": "Type de sondage",
|
||||
"surveys": "Enquêtes",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Nom de l'équipe",
|
||||
"team_role": "Rôle dans l'équipe",
|
||||
"teams": "Équipes",
|
||||
"teams_not_found": "Équipes non trouvées",
|
||||
"text": "Texte",
|
||||
"time": "Temps",
|
||||
"time_to_finish": "Temps de finir",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Utilisateur",
|
||||
"user_id": "Identifiant d'utilisateur",
|
||||
"user_not_found": "Utilisateur non trouvé",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Identifiants variables",
|
||||
"variables": "Variables",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "semaines",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Espace de travail",
|
||||
"workspace_configuration": "Configuration du projet",
|
||||
"workspace_created_successfully": "Projet créé avec succès",
|
||||
"workspace_creation_description": "Organisez les enquêtes dans des projets pour un meilleur contrôle d'accès.",
|
||||
"workspace_id": "ID du projet",
|
||||
"workspace_name": "Nom du projet",
|
||||
"workspace_name_placeholder": "par ex. Formbricks",
|
||||
"workspace_not_found": "Projet introuvable",
|
||||
"workspace_permission_not_found": "Permission du projet introuvable",
|
||||
"workspaces": "Projets",
|
||||
"years": "années",
|
||||
"you": "Vous",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"contact_not_found": "Aucun contact trouvé",
|
||||
"contacts_table_refresh": "Actualiser les contacts",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"create_attribute": "Créer un attribut",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Nom d'hôte personnalisé",
|
||||
"customize_survey_logo": "Personnaliser le logo de l'enquête",
|
||||
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
|
||||
"date_format": "Format de date",
|
||||
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
|
||||
"delete_anyways": "Supprimer quand même",
|
||||
"delete_block": "Supprimer le bloc",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Kapcsolódás",
|
||||
"connect_formbricks": "Kapcsolódás a Formbrickshez",
|
||||
"connected": "Kapcsolódva",
|
||||
"contact": "Kapcsolat",
|
||||
"contacts": "Partnerek",
|
||||
"continue": "Folytatás",
|
||||
"copied": "Másolva",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Másolás",
|
||||
"copy_code": "Kód másolása",
|
||||
"copy_link": "Hivatkozás másolása",
|
||||
"copy_to_environment": "Másolás ide: {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
|
||||
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
|
||||
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "({copyNumber}. másolat)",
|
||||
"e_commerce": "E-kereskedelem",
|
||||
"edit": "Szerkesztés",
|
||||
"elements": "Elemek",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Befejező kártya",
|
||||
"enter_url": "URL megadása",
|
||||
"enterprise_license": "Vállalati licenc",
|
||||
"environment": "Környezet",
|
||||
"environment_not_found": "A környezet nem található",
|
||||
"environment_notice": "Ön jelenleg a(z) {environment} környezetben van.",
|
||||
"error": "Hiba",
|
||||
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Inaktív kérdőívek",
|
||||
"integration": "integráció",
|
||||
"integrations": "Integrációk",
|
||||
"invalid_date_with_value": "Érvénytelen dátum: {value}",
|
||||
"invalid_date": "Érvénytelen dátum",
|
||||
"invalid_file_name": "Érvénytelen fájlnév, nevezze át a fájlt, és próbálja újra",
|
||||
"invalid_file_type": "Érvénytelen fájltípus",
|
||||
"invite": "Meghívás",
|
||||
"invite_them": "Meghívó nekik",
|
||||
"javascript_required": "JavaScript szükséges",
|
||||
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
|
||||
"key": "Kulcs",
|
||||
"label": "Címke",
|
||||
"language": "Nyelv",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Tagok",
|
||||
"members_and_teams": "Tagok és csapatok",
|
||||
"membership": "Tagság",
|
||||
"membership_not_found": "A tagság nem található",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metaadatok",
|
||||
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
|
||||
"mobile_overlay_surveys_look_good": "Ne aggódjon – a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "vagy",
|
||||
"organization": "Szervezet",
|
||||
"organization_id": "Szervezetazonosító",
|
||||
"organization_not_found": "A szervezet nem található",
|
||||
"organization_settings": "Szervezet beállításai",
|
||||
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
|
||||
"other": "Egyéb",
|
||||
"other_filters": "Egyéb szűrők",
|
||||
"others": "Mások",
|
||||
"overlay_color": "Rávetítés színe",
|
||||
"overview": "Áttekintés",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "Kérdőív-azonosító",
|
||||
"survey_languages": "Kérdőív nyelvei",
|
||||
"survey_live": "A kérdőív élő",
|
||||
"survey_not_found": "A kérdőív nem található",
|
||||
"survey_paused": "A kérdőív szüneteltetve.",
|
||||
"survey_type": "Kérdőív típusa",
|
||||
"surveys": "Kérdőívek",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Csapat neve",
|
||||
"team_role": "Csapatszerep",
|
||||
"teams": "Csapatok",
|
||||
"teams_not_found": "A csapatok nem találhatók",
|
||||
"text": "Szöveg",
|
||||
"time": "Idő",
|
||||
"time_to_finish": "Idő a befejezésig",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Felhasználó",
|
||||
"user_id": "Felhasználó-azonosító",
|
||||
"user_not_found": "A felhasználó nem található",
|
||||
"variable": "Változó",
|
||||
"variable_ids": "Változóazonosítók",
|
||||
"variables": "Változók",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "hét",
|
||||
"welcome_card": "Üdvözlő kártya",
|
||||
"workflows": "Munkafolyamatok",
|
||||
"workspace": "Munkaterület",
|
||||
"workspace_configuration": "Munkaterület beállítása",
|
||||
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
|
||||
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
|
||||
"workspace_id": "Munkaterület-azonosító",
|
||||
"workspace_name": "Munkaterület neve",
|
||||
"workspace_name_placeholder": "például Formbricks",
|
||||
"workspace_not_found": "A munkaterület nem található",
|
||||
"workspace_permission_not_found": "A munkaterület-jogosultság nem található",
|
||||
"workspaces": "Munkaterületek",
|
||||
"years": "év",
|
||||
"you": "Ön",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
|
||||
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
|
||||
"contact_deleted_successfully": "A partner sikeresen törölve",
|
||||
"contact_not_found": "Nem található ilyen partner",
|
||||
"contacts_table_refresh": "Partnerek frissítése",
|
||||
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
|
||||
"create_attribute": "Attribútum létrehozása",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Egyéni gépnév",
|
||||
"customize_survey_logo": "A kérdőív logójának személyre szabása",
|
||||
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
|
||||
"date_format": "Dátumformátum",
|
||||
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
|
||||
"delete_anyways": "Törlés mindenképp",
|
||||
"delete_block": "Blokk törlése",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "接続",
|
||||
"connect_formbricks": "Formbricksを接続",
|
||||
"connected": "接続済み",
|
||||
"contact": "連絡先",
|
||||
"contacts": "連絡先",
|
||||
"continue": "続行",
|
||||
"copied": "コピーしました",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "コピー",
|
||||
"copy_code": "コードをコピー",
|
||||
"copy_link": "リンクをコピー",
|
||||
"copy_to_environment": "{{environment}} にコピー",
|
||||
"count_attributes": "{count, plural, other {{count} 個の属性}}",
|
||||
"count_contacts": "{count, plural, other {{count} 件の連絡先}}",
|
||||
"count_members": "{count, plural, other {{count} 名のメンバー}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(コピー {copyNumber})",
|
||||
"e_commerce": "Eコマース",
|
||||
"edit": "編集",
|
||||
"elements": "要素",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
"environment": "環境",
|
||||
"environment_not_found": "環境が見つかりません",
|
||||
"environment_notice": "現在、{environment} 環境にいます。",
|
||||
"error": "エラー",
|
||||
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "非アクティブなフォーム",
|
||||
"integration": "連携",
|
||||
"integrations": "連携",
|
||||
"invalid_date_with_value": "無効な日付です: {value}",
|
||||
"invalid_date": "無効な日付です",
|
||||
"invalid_file_name": "ファイル名が無効です。ファイル名を変更して再試行してください",
|
||||
"invalid_file_type": "無効なファイルタイプです",
|
||||
"invite": "招待",
|
||||
"invite_them": "招待する",
|
||||
"javascript_required": "JavaScriptが必要です",
|
||||
"javascript_required_description": "Formbricksを正常に動作させるには、JavaScriptが必要です。続行するには、ブラウザの設定でJavaScriptを有効にしてください。",
|
||||
"key": "キー",
|
||||
"label": "ラベル",
|
||||
"language": "言語",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "マーケティング",
|
||||
"members": "メンバー",
|
||||
"members_and_teams": "メンバー&チーム",
|
||||
"membership": "メンバーシップ",
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"meta": "メタ",
|
||||
"metadata": "メタデータ",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "または",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織ID",
|
||||
"organization_not_found": "組織が見つかりません",
|
||||
"organization_settings": "組織設定",
|
||||
"organization_teams_not_found": "組織のチームが見つかりません",
|
||||
"other": "その他",
|
||||
"other_filters": "その他のフィルター",
|
||||
"others": "その他",
|
||||
"overlay_color": "オーバーレイの色",
|
||||
"overview": "概要",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "フォームID",
|
||||
"survey_languages": "フォームの言語",
|
||||
"survey_live": "フォーム公開中",
|
||||
"survey_not_found": "フォームが見つかりません",
|
||||
"survey_paused": "フォームは一時停止中です。",
|
||||
"survey_type": "フォームの種類",
|
||||
"surveys": "フォーム",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "チーム名",
|
||||
"team_role": "チームの役割",
|
||||
"teams": "チーム",
|
||||
"teams_not_found": "チームが見つかりません",
|
||||
"text": "テキスト",
|
||||
"time": "時間",
|
||||
"time_to_finish": "所要時間",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "ユーザー",
|
||||
"user_id": "ユーザーID",
|
||||
"user_not_found": "ユーザーが見つかりません",
|
||||
"variable": "変数",
|
||||
"variable_ids": "変数ID",
|
||||
"variables": "変数",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "週間",
|
||||
"welcome_card": "ウェルカムカード",
|
||||
"workflows": "ワークフロー",
|
||||
"workspace": "ワークスペース",
|
||||
"workspace_configuration": "ワークスペース設定",
|
||||
"workspace_created_successfully": "ワークスペースが正常に作成されました",
|
||||
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
|
||||
"workspace_id": "ワークスペースID",
|
||||
"workspace_name": "ワークスペース名",
|
||||
"workspace_name_placeholder": "例: Formbricks",
|
||||
"workspace_not_found": "ワークスペースが見つかりません",
|
||||
"workspace_permission_not_found": "ワークスペースの権限が見つかりません",
|
||||
"workspaces": "ワークスペース",
|
||||
"years": "年",
|
||||
"you": "あなた",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
|
||||
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
|
||||
"contact_deleted_successfully": "連絡先を正常に削除しました",
|
||||
"contact_not_found": "そのような連絡先は見つかりません",
|
||||
"contacts_table_refresh": "連絡先を更新",
|
||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||
"create_attribute": "属性を作成",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "カスタムホスト名",
|
||||
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
|
||||
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
|
||||
"date_format": "日付形式",
|
||||
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
|
||||
"delete_anyways": "削除する",
|
||||
"delete_block": "ブロックを削除",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Verbinden",
|
||||
"connect_formbricks": "Sluit Formbricks aan",
|
||||
"connected": "Aangesloten",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacten",
|
||||
"continue": "Doorgaan",
|
||||
"copied": "Gekopieerd",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Kopiëren",
|
||||
"copy_code": "Kopieer code",
|
||||
"copy_link": "Kopieer link",
|
||||
"copy_to_environment": "Kopiëren naar {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} attribuut} other {{count} attributen}}",
|
||||
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacten}}",
|
||||
"count_members": "{count, plural, one {{count} lid} other {{count} leden}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(kopie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Bewerking",
|
||||
"elements": "Elementen",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Einde kaart",
|
||||
"enter_url": "URL invoeren",
|
||||
"enterprise_license": "Enterprise-licentie",
|
||||
"environment": "Omgeving",
|
||||
"environment_not_found": "Omgeving niet gevonden",
|
||||
"environment_notice": "U bevindt zich momenteel in de {environment}-omgeving.",
|
||||
"error": "Fout",
|
||||
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Inactieve enquêtes",
|
||||
"integration": "integratie",
|
||||
"integrations": "Integraties",
|
||||
"invalid_date_with_value": "Ongeldige datum: {value}",
|
||||
"invalid_date": "Ongeldige datum",
|
||||
"invalid_file_name": "Ongeldige bestandsnaam. Hernoem uw bestand en probeer het opnieuw",
|
||||
"invalid_file_type": "Ongeldig bestandstype",
|
||||
"invite": "Uitnodiging",
|
||||
"invite_them": "Nodig ze uit",
|
||||
"javascript_required": "JavaScript vereist",
|
||||
"javascript_required_description": "Formbricks heeft JavaScript nodig om correct te functioneren. Schakel JavaScript in je browserinstellingen in om door te gaan.",
|
||||
"key": "Sleutel",
|
||||
"label": "Label",
|
||||
"language": "Taal",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Leden",
|
||||
"members_and_teams": "Leden & teams",
|
||||
"membership": "Lidmaatschap",
|
||||
"membership_not_found": "Lidmaatschap niet gevonden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metagegevens",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "of",
|
||||
"organization": "Organisatie",
|
||||
"organization_id": "Organisatie-ID",
|
||||
"organization_not_found": "Organisatie niet gevonden",
|
||||
"organization_settings": "Organisatie-instellingen",
|
||||
"organization_teams_not_found": "Organisatieteams niet gevonden",
|
||||
"other": "Ander",
|
||||
"other_filters": "Overige filters",
|
||||
"others": "Anderen",
|
||||
"overlay_color": "Overlaykleur",
|
||||
"overview": "Overzicht",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "Enquête-ID",
|
||||
"survey_languages": "Enquêtetalen",
|
||||
"survey_live": "Enquête live",
|
||||
"survey_not_found": "Enquête niet gevonden",
|
||||
"survey_paused": "Enquête onderbroken.",
|
||||
"survey_type": "Enquêtetype",
|
||||
"surveys": "Enquêtes",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Teamnaam",
|
||||
"team_role": "Teamrol",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams niet gevonden",
|
||||
"text": "Tekst",
|
||||
"time": "Tijd",
|
||||
"time_to_finish": "Tijd om af te ronden",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Gebruiker",
|
||||
"user_id": "Gebruikers-ID",
|
||||
"user_not_found": "Gebruiker niet gevonden",
|
||||
"variable": "Variabel",
|
||||
"variable_ids": "Variabele ID's",
|
||||
"variables": "Variabelen",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "weken",
|
||||
"welcome_card": "Welkomstkaart",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Werkruimte",
|
||||
"workspace_configuration": "Werkruimte-configuratie",
|
||||
"workspace_created_successfully": "Project succesvol aangemaakt",
|
||||
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
|
||||
"workspace_id": "Werkruimte-ID",
|
||||
"workspace_name": "Werkruimtenaam",
|
||||
"workspace_name_placeholder": "bijv. Formbricks",
|
||||
"workspace_not_found": "Werkruimte niet gevonden",
|
||||
"workspace_permission_not_found": "Werkruimte-machtiging niet gevonden",
|
||||
"workspaces": "Werkruimtes",
|
||||
"years": "jaren",
|
||||
"you": "Jij",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"contact_deleted_successfully": "Contact succesvol verwijderd",
|
||||
"contact_not_found": "Er is geen dergelijk contact gevonden",
|
||||
"contacts_table_refresh": "Vernieuw contacten",
|
||||
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
|
||||
"create_attribute": "Attribuut aanmaken",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Aangepaste hostnaam",
|
||||
"customize_survey_logo": "Pas het enquêtelogo aan",
|
||||
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
|
||||
"date_format": "Datumformaat",
|
||||
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
|
||||
"delete_anyways": "Toch verwijderen",
|
||||
"delete_block": "Blok verwijderen",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "conectado",
|
||||
"contact": "Contato",
|
||||
"contacts": "Contatos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar Link",
|
||||
"copy_to_environment": "Copiar para {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
|
||||
"count_contacts": "{count, plural, one {{count} contato} other {{count} contatos}}",
|
||||
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment": "Ambiente",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Pesquisas inativas",
|
||||
"integration": "integração",
|
||||
"integrations": "Integrações",
|
||||
"invalid_date_with_value": "Data inválida: {value}",
|
||||
"invalid_date": "Data inválida",
|
||||
"invalid_file_name": "Nome de arquivo inválido, por favor renomeie seu arquivo e tente novamente",
|
||||
"invalid_file_type": "Tipo de arquivo inválido",
|
||||
"invite": "convidar",
|
||||
"invite_them": "Convida eles",
|
||||
"javascript_required": "JavaScript Necessário",
|
||||
"javascript_required_description": "O Formbricks precisa do JavaScript para funcionar corretamente. Por favor, ative o JavaScript nas configurações do seu navegador para continuar.",
|
||||
"key": "Chave",
|
||||
"label": "Etiqueta",
|
||||
"language": "Língua",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "marketing",
|
||||
"members": "Membros",
|
||||
"members_and_teams": "Membros e equipes",
|
||||
"membership": "Associação",
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "ou",
|
||||
"organization": "organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"organization_teams_not_found": "Equipes da organização não encontradas",
|
||||
"other": "outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão Geral",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "ID da Pesquisa",
|
||||
"survey_languages": "Idiomas da Pesquisa",
|
||||
"survey_live": "Pesquisa ao vivo",
|
||||
"survey_not_found": "Pesquisa não encontrada",
|
||||
"survey_paused": "Pesquisa pausada.",
|
||||
"survey_type": "Tipo de Pesquisa",
|
||||
"surveys": "Pesquisas",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Nome da equipe",
|
||||
"team_role": "Função na equipe",
|
||||
"teams": "Equipes",
|
||||
"teams_not_found": "Equipes não encontradas",
|
||||
"text": "Texto",
|
||||
"time": "tempo",
|
||||
"time_to_finish": "Hora de terminar",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Usuário",
|
||||
"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",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"workflows": "Fluxos de trabalho",
|
||||
"workspace": "Espaço de trabalho",
|
||||
"workspace_configuration": "Configuração do projeto",
|
||||
"workspace_created_successfully": "Projeto criado com sucesso",
|
||||
"workspace_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
|
||||
"workspace_id": "ID do projeto",
|
||||
"workspace_name": "Nome do projeto",
|
||||
"workspace_name_placeholder": "ex: Formbricks",
|
||||
"workspace_not_found": "Projeto não encontrado",
|
||||
"workspace_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"you": "Você",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"contact_not_found": "Nenhum contato encontrado",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Hostname personalizado",
|
||||
"customize_survey_logo": "Personalizar o logo da pesquisa",
|
||||
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
|
||||
"date_format": "Formato de data",
|
||||
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
|
||||
"delete_anyways": "Excluir mesmo assim",
|
||||
"delete_block": "Excluir bloco",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Ligar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contact": "Contacto",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar Link",
|
||||
"copy_to_environment": "Copiar para {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
|
||||
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
|
||||
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment": "Ambiente",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Inquéritos inativos",
|
||||
"integration": "integração",
|
||||
"integrations": "Integrações",
|
||||
"invalid_date_with_value": "Data inválida: {value}",
|
||||
"invalid_date": "Data inválida",
|
||||
"invalid_file_name": "Nome de ficheiro inválido, por favor renomeie o seu ficheiro e tente novamente",
|
||||
"invalid_file_type": "Tipo de ficheiro inválido",
|
||||
"invite": "Convidar",
|
||||
"invite_them": "Convide-os",
|
||||
"javascript_required": "JavaScript Necessário",
|
||||
"javascript_required_description": "O Formbricks necessita de JavaScript para funcionar corretamente. Por favor, ativa o JavaScript nas definições do teu navegador para continuar.",
|
||||
"key": "Chave",
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Membros",
|
||||
"members_and_teams": "Membros e equipas",
|
||||
"membership": "Subscrição",
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "ou",
|
||||
"organization": "Organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"organization_teams_not_found": "Equipas da organização não encontradas",
|
||||
"other": "Outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão geral",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "ID do Inquérito",
|
||||
"survey_languages": "Idiomas da Pesquisa",
|
||||
"survey_live": "Inquérito ao vivo",
|
||||
"survey_not_found": "Inquérito não encontrado",
|
||||
"survey_paused": "Inquérito pausado.",
|
||||
"survey_type": "Tipo de Inquérito",
|
||||
"surveys": "Inquéritos",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Nome da equipa",
|
||||
"team_role": "Função na equipa",
|
||||
"teams": "Equipas",
|
||||
"teams_not_found": "Equipas não encontradas",
|
||||
"text": "Texto",
|
||||
"time": "Tempo",
|
||||
"time_to_finish": "Tempo para concluir",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Utilizador",
|
||||
"user_id": "ID do Utilizador",
|
||||
"user_not_found": "Utilizador não encontrado",
|
||||
"variable": "Variável",
|
||||
"variable_ids": "IDs de variáveis",
|
||||
"variables": "Variáveis",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"workflows": "Fluxos de trabalho",
|
||||
"workspace": "Espaço de trabalho",
|
||||
"workspace_configuration": "Configuração do projeto",
|
||||
"workspace_created_successfully": "Projeto criado com sucesso",
|
||||
"workspace_creation_description": "Organize inquéritos em projetos para melhor controlo de acesso.",
|
||||
"workspace_id": "ID do projeto",
|
||||
"workspace_name": "Nome do projeto",
|
||||
"workspace_name_placeholder": "ex. Formbricks",
|
||||
"workspace_not_found": "Projeto não encontrado",
|
||||
"workspace_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"you": "Você",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"contact_not_found": "Nenhum contacto encontrado",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Nome do host personalizado",
|
||||
"customize_survey_logo": "Personalizar o logótipo do inquérito",
|
||||
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
|
||||
"date_format": "Formato da data",
|
||||
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
|
||||
"delete_anyways": "Eliminar mesmo assim",
|
||||
"delete_block": "Eliminar bloco",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Conectează",
|
||||
"connect_formbricks": "Conectează Formbricks",
|
||||
"connected": "Conectat",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacte",
|
||||
"continue": "Continuă",
|
||||
"copied": "Copiat",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Copiază",
|
||||
"copy_code": "Copiază codul",
|
||||
"copy_link": "Copiază legătura",
|
||||
"copy_to_environment": "Copiază în {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} atribut} few {{count} atribute} other {{count} de atribute}}",
|
||||
"count_contacts": "{count, plural, one {{count} contact} few {{count} contacte} other {{count} de contacte}}",
|
||||
"count_members": "{count, plural, one {{count} membru} few {{count} membri} other {{count} de membri}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "Comerț electronic",
|
||||
"edit": "Editare",
|
||||
"elements": "Elemente",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
"environment": "Mediu",
|
||||
"environment_not_found": "Mediul nu a fost găsit",
|
||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||
"error": "Eroare",
|
||||
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Sondaje inactive",
|
||||
"integration": "integrare",
|
||||
"integrations": "Integrări",
|
||||
"invalid_date_with_value": "Dată invalidă: {value}",
|
||||
"invalid_date": "Dată invalidă",
|
||||
"invalid_file_name": "Nume de fișier invalid, vă rugăm să redenumiți fișierul și să încercați din nou",
|
||||
"invalid_file_type": "Tip de fișier nevalid",
|
||||
"invite": "Invită",
|
||||
"invite_them": "Invită-i",
|
||||
"javascript_required": "JavaScript necesar",
|
||||
"javascript_required_description": "Formbricks necesită JavaScript pentru a funcționa corect. Te rugăm să activezi JavaScript în setările browserului tău pentru a continua.",
|
||||
"key": "Cheie",
|
||||
"label": "Etichetă",
|
||||
"language": "Limba",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Membri",
|
||||
"members_and_teams": "Membri și echipe",
|
||||
"membership": "Abonament",
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadate",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "sau",
|
||||
"organization": "Organizație",
|
||||
"organization_id": "ID Organizație",
|
||||
"organization_not_found": "Organizația nu a fost găsită",
|
||||
"organization_settings": "Setări Organizație",
|
||||
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
|
||||
"other": "Altele",
|
||||
"other_filters": "Alte Filtre",
|
||||
"others": "Altele",
|
||||
"overlay_color": "Culoare overlay",
|
||||
"overview": "Prezentare generală",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "ID Chestionar",
|
||||
"survey_languages": "Limbi chestionar",
|
||||
"survey_live": "Chestionar activ",
|
||||
"survey_not_found": "Sondajul nu a fost găsit",
|
||||
"survey_paused": "Chestionar oprit.",
|
||||
"survey_type": "Tip Chestionar",
|
||||
"surveys": "Sondaje",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Nume echipă",
|
||||
"team_role": "Rol în echipă",
|
||||
"teams": "Echipe",
|
||||
"teams_not_found": "Echipele nu au fost găsite",
|
||||
"text": "Text",
|
||||
"time": "Timp",
|
||||
"time_to_finish": "Timp până la finalizare",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Utilizator",
|
||||
"user_id": "ID Utilizator",
|
||||
"user_not_found": "Utilizatorul nu a fost găsit",
|
||||
"variable": "Variabilă",
|
||||
"variable_ids": "ID-uri variabile",
|
||||
"variables": "Variante",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "săptămâni",
|
||||
"welcome_card": "Card de bun venit",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Spațiu de lucru",
|
||||
"workspace_configuration": "Configurare workspace",
|
||||
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
|
||||
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
|
||||
"workspace_id": "ID workspace",
|
||||
"workspace_name": "Nume workspace",
|
||||
"workspace_name_placeholder": "ex: Formbricks",
|
||||
"workspace_not_found": "Workspace-ul nu a fost găsit",
|
||||
"workspace_permission_not_found": "Permisiunea pentru workspace nu a fost găsită",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "ani",
|
||||
"you": "Tu",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"contact_deleted_successfully": "Contact șters cu succes",
|
||||
"contact_not_found": "Nu a fost găsit niciun contact",
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||
"create_attribute": "Creează atribut",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Gazdă personalizată",
|
||||
"customize_survey_logo": "Personalizează logo-ul chestionarului",
|
||||
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
|
||||
"date_format": "Format dată",
|
||||
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
|
||||
"delete_anyways": "Șterge oricum",
|
||||
"delete_block": "Șterge blocul",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Подключить",
|
||||
"connect_formbricks": "Подключить Formbricks",
|
||||
"connected": "Подключено",
|
||||
"contact": "Контакт",
|
||||
"contacts": "Контакты",
|
||||
"continue": "Продолжить",
|
||||
"copied": "Скопировано",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Копировать",
|
||||
"copy_code": "Скопировать код",
|
||||
"copy_link": "Скопировать ссылку",
|
||||
"copy_to_environment": "Копировать в {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} атрибут} few {{count} атрибута} many {{count} атрибутов} other {{count} атрибута}}",
|
||||
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контакта}}",
|
||||
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(копия {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Редактировать",
|
||||
"elements": "Элементы",
|
||||
"email": "Email",
|
||||
"ending_card": "Завершающая карточка",
|
||||
"enter_url": "Введите URL",
|
||||
"enterprise_license": "Корпоративная лицензия",
|
||||
"environment": "Окружение",
|
||||
"environment_not_found": "Среда не найдена",
|
||||
"environment_notice": "В данный момент вы находитесь в среде {environment}.",
|
||||
"error": "Ошибка",
|
||||
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Неактивные опросы",
|
||||
"integration": "интеграция",
|
||||
"integrations": "Интеграции",
|
||||
"invalid_date_with_value": "Неверная дата: {value}",
|
||||
"invalid_date": "Неверная дата",
|
||||
"invalid_file_name": "Недопустимое имя файла, переименуйте файл и попробуйте снова",
|
||||
"invalid_file_type": "Недопустимый тип файла",
|
||||
"invite": "Пригласить",
|
||||
"invite_them": "Пригласить их",
|
||||
"javascript_required": "Требуется JavaScript",
|
||||
"javascript_required_description": "Для корректной работы Formbricks необходим JavaScript. Пожалуйста, включите JavaScript в настройках вашего браузера, чтобы продолжить.",
|
||||
"key": "Ключ",
|
||||
"label": "Метка",
|
||||
"language": "Язык",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Маркетинг",
|
||||
"members": "Участники",
|
||||
"members_and_teams": "Участники и команды",
|
||||
"membership": "Членство",
|
||||
"membership_not_found": "Участие не найдено",
|
||||
"meta": "Мета",
|
||||
"metadata": "Метаданные",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "или",
|
||||
"organization": "Организация",
|
||||
"organization_id": "ID организации",
|
||||
"organization_not_found": "Организация не найдена",
|
||||
"organization_settings": "Настройки организации",
|
||||
"organization_teams_not_found": "Команды организации не найдены",
|
||||
"other": "Другое",
|
||||
"other_filters": "Другие фильтры",
|
||||
"others": "Другие",
|
||||
"overlay_color": "Цвет наложения",
|
||||
"overview": "Обзор",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "ID опроса",
|
||||
"survey_languages": "Языки опроса",
|
||||
"survey_live": "Опрос активен",
|
||||
"survey_not_found": "Опрос не найден",
|
||||
"survey_paused": "Опрос приостановлен.",
|
||||
"survey_type": "Тип опроса",
|
||||
"surveys": "Опросы",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Название команды",
|
||||
"team_role": "Роль в команде",
|
||||
"teams": "Команды",
|
||||
"teams_not_found": "Команды не найдены",
|
||||
"text": "Текст",
|
||||
"time": "Время",
|
||||
"time_to_finish": "Время до завершения",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Пользователь",
|
||||
"user_id": "ID пользователя",
|
||||
"user_not_found": "Пользователь не найден",
|
||||
"variable": "Переменная",
|
||||
"variable_ids": "ID переменных",
|
||||
"variables": "Переменные",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "недели",
|
||||
"welcome_card": "Приветственная карточка",
|
||||
"workflows": "Воркфлоу",
|
||||
"workspace": "Рабочее пространство",
|
||||
"workspace_configuration": "Настройка рабочего пространства",
|
||||
"workspace_created_successfully": "Рабочий проект успешно создан",
|
||||
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
|
||||
"workspace_id": "ID рабочего пространства",
|
||||
"workspace_name": "Название рабочего пространства",
|
||||
"workspace_name_placeholder": "например, Formbricks",
|
||||
"workspace_not_found": "Рабочее пространство не найдено",
|
||||
"workspace_permission_not_found": "Разрешение на рабочее пространство не найдено",
|
||||
"workspaces": "Рабочие пространства",
|
||||
"years": "годы",
|
||||
"you": "Вы",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
|
||||
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
|
||||
"contact_deleted_successfully": "Контакт успешно удалён",
|
||||
"contact_not_found": "Такой контакт не найден",
|
||||
"contacts_table_refresh": "Обновить контакты",
|
||||
"contacts_table_refresh_success": "Контакты успешно обновлены",
|
||||
"create_attribute": "Создать атрибут",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Пользовательский хостнейм",
|
||||
"customize_survey_logo": "Настроить логотип опроса",
|
||||
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
|
||||
"date_format": "Формат даты",
|
||||
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
|
||||
"delete_anyways": "Удалить в любом случае",
|
||||
"delete_block": "Удалить блок",
|
||||
|
||||
+11
-10
@@ -167,7 +167,6 @@
|
||||
"connect": "Anslut",
|
||||
"connect_formbricks": "Anslut Formbricks",
|
||||
"connected": "Ansluten",
|
||||
"contact": "Kontakt",
|
||||
"contacts": "Kontakter",
|
||||
"continue": "Fortsätt",
|
||||
"copied": "Kopierad",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "Kopiera",
|
||||
"copy_code": "Kopiera kod",
|
||||
"copy_link": "Kopiera länk",
|
||||
"copy_to_environment": "Kopiera till {{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attribut}}",
|
||||
"count_contacts": "{count, plural, one {{count} kontakt} other {{count} kontakter}}",
|
||||
"count_members": "{count, plural, one {{count} medlem} other {{count} medlemmar}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(kopia {copyNumber})",
|
||||
"e_commerce": "E-handel",
|
||||
"edit": "Redigera",
|
||||
"elements": "Element",
|
||||
"email": "E-post",
|
||||
"ending_card": "Avslutningskort",
|
||||
"enter_url": "Ange URL",
|
||||
"enterprise_license": "Företagslicens",
|
||||
"environment": "Miljö",
|
||||
"environment_not_found": "Miljö hittades inte",
|
||||
"environment_notice": "Du är för närvarande i {environment}-miljön.",
|
||||
"error": "Fel",
|
||||
"error_component_description": "Denna resurs finns inte eller så har du inte de nödvändiga rättigheterna för att komma åt den.",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "Inaktiva enkäter",
|
||||
"integration": "integration",
|
||||
"integrations": "Integrationer",
|
||||
"invalid_date_with_value": "Ogiltigt datum: {value}",
|
||||
"invalid_date": "Ogiltigt datum",
|
||||
"invalid_file_name": "Ogiltigt filnamn, vänligen byt namn på din fil och försök igen",
|
||||
"invalid_file_type": "Ogiltig filtyp",
|
||||
"invite": "Bjud in",
|
||||
"invite_them": "Bjud in dem",
|
||||
"javascript_required": "JavaScript krävs",
|
||||
"javascript_required_description": "Formbricks kräver JavaScript för att fungera korrekt. Vänligen aktivera JavaScript i dina webbläsarinställningar för att fortsätta.",
|
||||
"key": "Nyckel",
|
||||
"label": "Etikett",
|
||||
"language": "Språk",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "Marknadsföring",
|
||||
"members": "Medlemmar",
|
||||
"members_and_teams": "Medlemmar och team",
|
||||
"membership": "Medlemskap",
|
||||
"membership_not_found": "Medlemskap hittades inte",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "eller",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_not_found": "Organisation hittades inte",
|
||||
"organization_settings": "Organisationsinställningar",
|
||||
"organization_teams_not_found": "Organisationsteam hittades inte",
|
||||
"other": "Annat",
|
||||
"other_filters": "Andra filter",
|
||||
"others": "Andra",
|
||||
"overlay_color": "Overlay-färg",
|
||||
"overview": "Översikt",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "Enkät-ID",
|
||||
"survey_languages": "Enkätspråk",
|
||||
"survey_live": "Enkät live",
|
||||
"survey_not_found": "Enkät hittades inte",
|
||||
"survey_paused": "Enkät pausad.",
|
||||
"survey_type": "Enkättyp",
|
||||
"surveys": "Enkäter",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "Teamnamn",
|
||||
"team_role": "Teamroll",
|
||||
"teams": "Åtkomstkontroll",
|
||||
"teams_not_found": "Team hittades inte",
|
||||
"text": "Text",
|
||||
"time": "Tid",
|
||||
"time_to_finish": "Tid att slutföra",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "Användare",
|
||||
"user_id": "Användar-ID",
|
||||
"user_not_found": "Användare hittades inte",
|
||||
"variable": "Variabel",
|
||||
"variable_ids": "Variabel-ID:n",
|
||||
"variables": "Variabler",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "veckor",
|
||||
"welcome_card": "Välkomstkort",
|
||||
"workflows": "Arbetsflöden",
|
||||
"workspace": "Arbetsyta",
|
||||
"workspace_configuration": "Arbetsytans konfiguration",
|
||||
"workspace_created_successfully": "Arbetsytan har skapats",
|
||||
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
|
||||
"workspace_id": "Arbetsyte-ID",
|
||||
"workspace_name": "Arbetsytans namn",
|
||||
"workspace_name_placeholder": "t.ex. Formbricks",
|
||||
"workspace_not_found": "Arbetsyta hittades inte",
|
||||
"workspace_permission_not_found": "Arbetsytebehörighet hittades inte",
|
||||
"workspaces": "Arbetsytor",
|
||||
"years": "år",
|
||||
"you": "Du",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
|
||||
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
|
||||
"contact_deleted_successfully": "Kontakt borttagen",
|
||||
"contact_not_found": "Ingen sådan kontakt hittades",
|
||||
"contacts_table_refresh": "Uppdatera kontakter",
|
||||
"contacts_table_refresh_success": "Kontakter uppdaterade",
|
||||
"create_attribute": "Skapa attribut",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "Anpassat värdnamn",
|
||||
"customize_survey_logo": "Anpassa undersökningens logotyp",
|
||||
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
|
||||
"date_format": "Datumformat",
|
||||
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
|
||||
"delete_anyways": "Ta bort ändå",
|
||||
"delete_block": "Ta bort block",
|
||||
|
||||
@@ -167,7 +167,6 @@
|
||||
"connect": "连接",
|
||||
"connect_formbricks": "连接 Formbricks",
|
||||
"connected": "已连接",
|
||||
"contact": "联系人",
|
||||
"contacts": "联系人",
|
||||
"continue": "继续",
|
||||
"copied": "已复制",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "复制",
|
||||
"copy_code": "复制 代码",
|
||||
"copy_link": "复制 链接",
|
||||
"copy_to_environment": "复制到{{environment}}",
|
||||
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
|
||||
"count_contacts": "{count, plural, other {{count} 联系人} }",
|
||||
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(副本 {copyNumber})",
|
||||
"e_commerce": "电子商务",
|
||||
"edit": "编辑",
|
||||
"elements": "元素",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
"enterprise_license": "企业 许可证",
|
||||
"environment": "环境",
|
||||
"environment_not_found": "环境 未找到",
|
||||
"environment_notice": "你 目前 位于 {environment} 环境。",
|
||||
"error": "错误",
|
||||
"error_component_description": "这个资源不存在或您没有权限访问它。",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "不 活跃 调查",
|
||||
"integration": "集成",
|
||||
"integrations": "集成",
|
||||
"invalid_date_with_value": "无效 日期: {value}",
|
||||
"invalid_date": "无效 日期",
|
||||
"invalid_file_name": "文件名无效,请重命名文件后重试",
|
||||
"invalid_file_type": "无效 的 文件 类型",
|
||||
"invite": "邀请",
|
||||
"invite_them": "邀请 他们",
|
||||
"javascript_required": "需要启用 JavaScript",
|
||||
"javascript_required_description": "Formbricks 需要 JavaScript 才能正常运行。请在浏览器设置中启用 JavaScript 以继续。",
|
||||
"key": "键",
|
||||
"label": "标签",
|
||||
"language": "语言",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "市场营销",
|
||||
"members": "成员",
|
||||
"members_and_teams": "成员和团队",
|
||||
"membership": "会员资格",
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"meta": "元数据",
|
||||
"metadata": "元数据",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "或",
|
||||
"organization": "组织",
|
||||
"organization_id": "组织 ID",
|
||||
"organization_not_found": "组织 未找到",
|
||||
"organization_settings": "组织 设置",
|
||||
"organization_teams_not_found": "未找到 组织 团队",
|
||||
"other": "其他",
|
||||
"other_filters": "其他筛选条件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆盖层颜色",
|
||||
"overview": "概览",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "调查 ID",
|
||||
"survey_languages": "调查 语言",
|
||||
"survey_live": "调查 运行中",
|
||||
"survey_not_found": "调查 未找到",
|
||||
"survey_paused": "调查 暂停。",
|
||||
"survey_type": "调查 类型",
|
||||
"surveys": "调查",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "团队 名称",
|
||||
"team_role": "团队角色",
|
||||
"teams": "团队",
|
||||
"teams_not_found": "未找到 团队",
|
||||
"text": "文本",
|
||||
"time": "时间",
|
||||
"time_to_finish": "完成 时间",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "URL",
|
||||
"user": "用户",
|
||||
"user_id": "用户 ID",
|
||||
"user_not_found": "用户 不存在",
|
||||
"variable": "变量",
|
||||
"variable_ids": "变量 ID",
|
||||
"variables": "变量",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "周",
|
||||
"welcome_card": "欢迎 卡片",
|
||||
"workflows": "工作流",
|
||||
"workspace": "工作区",
|
||||
"workspace_configuration": "工作区配置",
|
||||
"workspace_created_successfully": "工作区创建成功",
|
||||
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
|
||||
"workspace_id": "工作区 ID",
|
||||
"workspace_name": "工作区名称",
|
||||
"workspace_name_placeholder": "例如:Formbricks",
|
||||
"workspace_not_found": "未找到工作区",
|
||||
"workspace_permission_not_found": "未找到工作区权限",
|
||||
"workspaces": "工作区",
|
||||
"years": "年",
|
||||
"you": "你 ",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
|
||||
"contact_deleted_successfully": "联系人 删除 成功",
|
||||
"contact_not_found": "未找到此 联系人",
|
||||
"contacts_table_refresh": "刷新 联系人",
|
||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||
"create_attribute": "创建属性",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "自 定 义 主 机 名",
|
||||
"customize_survey_logo": "自定义调查 logo",
|
||||
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
|
||||
"delete_anyways": "仍然删除",
|
||||
"delete_block": "删除区块",
|
||||
|
||||
@@ -167,7 +167,6 @@
|
||||
"connect": "連線",
|
||||
"connect_formbricks": "連線 Formbricks",
|
||||
"connected": "已連線",
|
||||
"contact": "聯絡人",
|
||||
"contacts": "聯絡人",
|
||||
"continue": "繼續",
|
||||
"copied": "已 複製",
|
||||
@@ -175,7 +174,6 @@
|
||||
"copy": "複製",
|
||||
"copy_code": "複製程式碼",
|
||||
"copy_link": "複製連結",
|
||||
"copy_to_environment": "複製到{{environment}}",
|
||||
"count_attributes": "{count, plural, other {{count} 個屬性}}",
|
||||
"count_contacts": "{count, plural, other {{count} 位聯絡人}}",
|
||||
"count_members": "{count, plural, other {{count} 位成員}}",
|
||||
@@ -215,12 +213,12 @@
|
||||
"duplicate_copy_number": "(複製 {copyNumber})",
|
||||
"e_commerce": "電子商務",
|
||||
"edit": "編輯",
|
||||
"elements": "元素",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment": "環境",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
"error": "錯誤",
|
||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||
@@ -257,13 +255,11 @@
|
||||
"inactive_surveys": "停用中的問卷",
|
||||
"integration": "整合",
|
||||
"integrations": "整合",
|
||||
"invalid_date_with_value": "無效日期: {value}",
|
||||
"invalid_date": "無效日期",
|
||||
"invalid_file_name": "檔案名稱無效,請重新命名檔案後再試一次",
|
||||
"invalid_file_type": "無效的檔案類型",
|
||||
"invite": "邀請",
|
||||
"invite_them": "邀請他們",
|
||||
"javascript_required": "需要 JavaScript",
|
||||
"javascript_required_description": "Formbricks 需要 JavaScript 才能正常運作。請在瀏覽器設定中啟用 JavaScript 以繼續使用。",
|
||||
"key": "金鑰",
|
||||
"label": "標籤",
|
||||
"language": "語言",
|
||||
@@ -284,9 +280,7 @@
|
||||
"marketing": "行銷",
|
||||
"members": "成員",
|
||||
"members_and_teams": "成員與團隊",
|
||||
"membership": "會員資格",
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"meta": "Meta",
|
||||
"metadata": "元數據",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
@@ -326,9 +320,10 @@
|
||||
"or": "或",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織 ID",
|
||||
"organization_not_found": "找不到組織",
|
||||
"organization_settings": "組織設定",
|
||||
"organization_teams_not_found": "找不到組織團隊",
|
||||
"other": "其他",
|
||||
"other_filters": "其他篩選條件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆蓋層顏色",
|
||||
"overview": "概覽",
|
||||
@@ -420,6 +415,7 @@
|
||||
"survey_id": "問卷 ID",
|
||||
"survey_languages": "問卷語言",
|
||||
"survey_live": "問卷已上線",
|
||||
"survey_not_found": "找不到問卷",
|
||||
"survey_paused": "問卷已暫停。",
|
||||
"survey_type": "問卷類型",
|
||||
"surveys": "問卷",
|
||||
@@ -434,6 +430,7 @@
|
||||
"team_name": "團隊名稱",
|
||||
"team_role": "團隊角色",
|
||||
"teams": "團隊",
|
||||
"teams_not_found": "找不到團隊",
|
||||
"text": "文字",
|
||||
"time": "時間",
|
||||
"time_to_finish": "完成時間",
|
||||
@@ -457,6 +454,7 @@
|
||||
"url": "網址",
|
||||
"user": "使用者",
|
||||
"user_id": "使用者 ID",
|
||||
"user_not_found": "找不到使用者",
|
||||
"variable": "變數",
|
||||
"variable_ids": "變數 ID",
|
||||
"variables": "變數",
|
||||
@@ -472,13 +470,14 @@
|
||||
"weeks": "週",
|
||||
"welcome_card": "歡迎卡片",
|
||||
"workflows": "工作流程",
|
||||
"workspace": "工作區",
|
||||
"workspace_configuration": "工作區設定",
|
||||
"workspace_created_successfully": "工作區已成功建立",
|
||||
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
|
||||
"workspace_id": "工作區 ID",
|
||||
"workspace_name": "工作區名稱",
|
||||
"workspace_name_placeholder": "例如:Formbricks",
|
||||
"workspace_not_found": "找不到工作區",
|
||||
"workspace_permission_not_found": "找不到工作區權限",
|
||||
"workspaces": "工作區",
|
||||
"years": "年",
|
||||
"you": "您",
|
||||
@@ -663,6 +662,7 @@
|
||||
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
|
||||
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"contact_not_found": "找不到此聯絡人",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"create_attribute": "建立屬性",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"custom_hostname": "自訂主機名稱",
|
||||
"customize_survey_logo": "自訂問卷標誌",
|
||||
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
|
||||
"delete_anyways": "仍要刪除",
|
||||
"delete_block": "刪除區塊",
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
import { Languages } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
@@ -18,7 +18,11 @@ interface LanguageDropdownProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
|
||||
export const LanguageDropdown = ({
|
||||
survey,
|
||||
setLanguage,
|
||||
locale,
|
||||
}: LanguageDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
|
||||
|
||||
@@ -29,10 +33,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={t("common.select_language")}
|
||||
aria-label={t("common.select_language")}>
|
||||
<Button variant="secondary" title={t("common.select_language")} aria-label={t("common.select_language")}>
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { createTag } from "@/lib/tag/service";
|
||||
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
|
||||
@@ -69,7 +68,7 @@ export const createTagToResponseAction = authenticatedActionClient
|
||||
const tagEnvironment = await getTag(parsedInput.tagId);
|
||||
|
||||
if (!responseEnvironmentId || !tagEnvironment) {
|
||||
throw new ResourceNotFoundError("Environment", null);
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
if (responseEnvironmentId !== tagEnvironment.environmentId) {
|
||||
@@ -114,7 +113,7 @@ export const deleteTagOnResponseAction = authenticatedActionClient
|
||||
const tagEnvironment = await getTag(parsedInput.tagId);
|
||||
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
|
||||
if (!responseEnvironmentId || !tagEnvironment) {
|
||||
throw new ResourceNotFoundError("Environment", null);
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
if (responseEnvironmentId !== tagEnvironment.environmentId) {
|
||||
|
||||
@@ -5,9 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
@@ -17,7 +15,6 @@ interface ElementSkipProps {
|
||||
elements: TSurveyElement[];
|
||||
isFirstElementAnswered?: boolean;
|
||||
responseData: TResponseData;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ElementSkip = ({
|
||||
@@ -26,10 +23,8 @@ export const ElementSkip = ({
|
||||
elements,
|
||||
isFirstElementAnswered,
|
||||
responseData,
|
||||
locale,
|
||||
}: ElementSkipProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dateFormats = getSurveyDateFormatMap(elements);
|
||||
return (
|
||||
<div>
|
||||
{skippedElements && (
|
||||
@@ -86,11 +81,7 @@ export const ElementSkip = ({
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData,
|
||||
undefined,
|
||||
false,
|
||||
locale,
|
||||
dateFormats
|
||||
responseData
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
@@ -129,11 +120,7 @@ export const ElementSkip = ({
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData,
|
||||
undefined,
|
||||
false,
|
||||
locale,
|
||||
dateFormats
|
||||
responseData
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
|
||||
+4
-6
@@ -3,12 +3,11 @@ import React from "react";
|
||||
import { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
|
||||
@@ -22,7 +21,6 @@ interface RenderResponseProps {
|
||||
element: TSurveyElement;
|
||||
survey: TSurvey;
|
||||
language: string | null;
|
||||
locale: TUserLocale;
|
||||
isExpanded?: boolean;
|
||||
showId: boolean;
|
||||
}
|
||||
@@ -32,7 +30,6 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
element,
|
||||
survey,
|
||||
language,
|
||||
locale,
|
||||
isExpanded = true,
|
||||
showId,
|
||||
}) => {
|
||||
@@ -66,8 +63,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
break;
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
if (typeof responseData === "string") {
|
||||
const formattedDate =
|
||||
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
|
||||
const parsedDate = new Date(responseData);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
|
||||
}
|
||||
|
||||
+1
-11
@@ -6,9 +6,7 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { TSurvey } 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 { getSurveyDateFormatMap } from "@/lib/utils/date-display";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -23,17 +21,14 @@ interface SingleResponseCardBodyProps {
|
||||
survey: TSurvey;
|
||||
response: TResponseWithQuotas;
|
||||
skippedQuestions: string[][];
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const SingleResponseCardBody = ({
|
||||
survey,
|
||||
response,
|
||||
skippedQuestions,
|
||||
locale,
|
||||
}: SingleResponseCardBodyProps) => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const dateFormats = getSurveyDateFormatMap(elements);
|
||||
const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false;
|
||||
const { t } = useTranslation();
|
||||
const formatTextWithSlashes = (text: string) => {
|
||||
@@ -66,7 +61,6 @@ export const SingleResponseCardBody = ({
|
||||
status={"welcomeCard"}
|
||||
isFirstElementAnswered={isFirstElementAnswered}
|
||||
responseData={response.data}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
@@ -104,9 +98,7 @@ export const SingleResponseCardBody = ({
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
response.data,
|
||||
response.variables,
|
||||
true,
|
||||
locale,
|
||||
dateFormats
|
||||
true
|
||||
)
|
||||
)
|
||||
)}
|
||||
@@ -117,7 +109,6 @@ export const SingleResponseCardBody = ({
|
||||
survey={survey}
|
||||
responseData={response.data[question.id]}
|
||||
language={response.language}
|
||||
locale={locale}
|
||||
showId={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -127,7 +118,6 @@ export const SingleResponseCardBody = ({
|
||||
skippedElements={skipped}
|
||||
elements={elements}
|
||||
responseData={response.data}
|
||||
locale={locale}
|
||||
status={
|
||||
response.finished ||
|
||||
(skippedQuestions.length > 0 &&
|
||||
|
||||
@@ -137,12 +137,7 @@ export const SingleResponseCard = ({
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<SingleResponseCardBody
|
||||
survey={survey}
|
||||
response={response}
|
||||
skippedQuestions={skippedQuestions}
|
||||
locale={locale}
|
||||
/>
|
||||
<SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
|
||||
|
||||
<ResponseTagsWrapper
|
||||
key={response.id}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
interface NextAuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
sessionMaxAge: number;
|
||||
}
|
||||
|
||||
export const NextAuthProvider = ({ children, sessionMaxAge }: NextAuthProviderProps) => {
|
||||
// Refresh at 1/3 of session max age, capped at 5 minutes
|
||||
const refetchInterval = Math.min(Math.max(Math.floor(sessionMaxAge / 3), 60), 300);
|
||||
|
||||
return <SessionProvider refetchInterval={refetchInterval}>{children}</SessionProvider>;
|
||||
};
|
||||
@@ -10,25 +10,6 @@ import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
|
||||
vi.mock("@next-auth/prisma-adapter", () => ({
|
||||
PrismaAdapter: vi.fn(() => ({
|
||||
createUser: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
getUserByEmail: vi.fn(),
|
||||
getUserByAccount: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
linkAccount: vi.fn(),
|
||||
unlinkAccount: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
getSessionAndUser: vi.fn(),
|
||||
updateSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
createVerificationToken: vi.fn(),
|
||||
useVerificationToken: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock encryption utilities
|
||||
vi.mock("@/lib/encryption", () => ({
|
||||
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
|
||||
@@ -319,20 +300,51 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
describe("Callbacks", () => {
|
||||
describe("session callback", () => {
|
||||
test("should add user id and isActive to session from database user", async () => {
|
||||
const session = { user: { email: "user6@example.com" } };
|
||||
const user = { id: "user6", isActive: false };
|
||||
describe("jwt callback", () => {
|
||||
test("should add profile information to token if user is found", async () => {
|
||||
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
locale: mockUser.locale,
|
||||
email: mockUser.email,
|
||||
emailVerified: mockUser.emailVerified,
|
||||
} as any);
|
||||
|
||||
const token = { email: mockUser.email };
|
||||
if (!authOptions.callbacks?.jwt) {
|
||||
throw new Error("jwt callback is not defined");
|
||||
}
|
||||
const result = await authOptions.callbacks.jwt({ token } as any);
|
||||
expect(result).toEqual({
|
||||
...token,
|
||||
profile: { id: mockUser.id },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return token unchanged if no existing user is found", async () => {
|
||||
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
|
||||
|
||||
const token = { email: "nonexistent@example.com" };
|
||||
if (!authOptions.callbacks?.jwt) {
|
||||
throw new Error("jwt callback is not defined");
|
||||
}
|
||||
const result = await authOptions.callbacks.jwt({ token } as any);
|
||||
expect(result).toEqual(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session callback", () => {
|
||||
test("should add user profile to session", async () => {
|
||||
const token = {
|
||||
id: "user6",
|
||||
profile: { id: "user6", email: "user6@example.com" },
|
||||
};
|
||||
|
||||
const session = { user: {} };
|
||||
if (!authOptions.callbacks?.session) {
|
||||
throw new Error("session callback is not defined");
|
||||
}
|
||||
const result = await authOptions.callbacks.session({ session, user } as any);
|
||||
expect(result.user).toEqual({
|
||||
email: "user6@example.com",
|
||||
id: "user6",
|
||||
isActive: false,
|
||||
});
|
||||
const result = await authOptions.callbacks.session({ session, token } as any);
|
||||
expect(result.user).toEqual(token.profile);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { cookies } from "next/headers";
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import {
|
||||
logAuthAttempt,
|
||||
logAuthEvent,
|
||||
@@ -32,7 +31,6 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
|
||||
import { createBrevoCustomer } from "./brevo";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
id: "credentials",
|
||||
@@ -312,17 +310,30 @@ export const authOptions: NextAuthOptions = {
|
||||
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
|
||||
],
|
||||
session: {
|
||||
strategy: "database",
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
},
|
||||
callbacks: {
|
||||
async session({ session, user }) {
|
||||
if (session.user) {
|
||||
session.user.id = user.id;
|
||||
if ("isActive" in user && typeof user.isActive === "boolean") {
|
||||
session.user.isActive = user.isActive;
|
||||
}
|
||||
async jwt({ token }) {
|
||||
const existingUser = await getUserByEmail(token?.email!);
|
||||
|
||||
if (!existingUser) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return {
|
||||
...token,
|
||||
profile: { id: existingUser.id },
|
||||
isActive: existingUser.isActive,
|
||||
};
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// @ts-expect-error
|
||||
session.user.id = token?.id;
|
||||
// @ts-expect-error
|
||||
session.user = token.profile;
|
||||
// @ts-expect-error
|
||||
session.user.isActive = token.isActive;
|
||||
|
||||
return session;
|
||||
},
|
||||
async signIn({ user, account }) {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
|
||||
|
||||
const { mockFindUnique } = vi.hoisted(() => ({
|
||||
mockFindUnique: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
session: {
|
||||
findUnique: mockFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const createRequest = (cookies: Record<string, string> = {}) => ({
|
||||
cookies: {
|
||||
get: (name: string) => {
|
||||
const value = cookies[name];
|
||||
return value ? { value } : undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("proxy-session", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("reads the secure session cookie when present", () => {
|
||||
const request = createRequest({
|
||||
"__Secure-next-auth.session-token": "secure-token",
|
||||
});
|
||||
|
||||
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
|
||||
});
|
||||
|
||||
test("returns null when no session cookie is present", async () => {
|
||||
const request = createRequest();
|
||||
|
||||
const session = await getProxySession(request);
|
||||
|
||||
expect(session).toBeNull();
|
||||
expect(mockFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when the session is expired", async () => {
|
||||
mockFindUnique.mockResolvedValue({
|
||||
userId: "user-1",
|
||||
expires: new Date(Date.now() - 60_000),
|
||||
user: {
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const request = createRequest({
|
||||
"next-auth.session-token": "expired-token",
|
||||
});
|
||||
|
||||
const session = await getProxySession(request);
|
||||
|
||||
expect(session).toBeNull();
|
||||
expect(mockFindUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
sessionToken: "expired-token",
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
expires: true,
|
||||
user: {
|
||||
select: {
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when the session belongs to an inactive user", async () => {
|
||||
mockFindUnique.mockResolvedValue({
|
||||
userId: "user-1",
|
||||
expires: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
const request = createRequest({
|
||||
"next-auth.session-token": "inactive-user-token",
|
||||
});
|
||||
|
||||
const session = await getProxySession(request);
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("returns the session when the cookie maps to a valid session", async () => {
|
||||
const validSession = {
|
||||
userId: "user-1",
|
||||
expires: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
mockFindUnique.mockResolvedValue(validSession);
|
||||
|
||||
const request = createRequest({
|
||||
"next-auth.session-token": "valid-token",
|
||||
});
|
||||
|
||||
const session = await getProxySession(request);
|
||||
|
||||
expect(session).toEqual(validSession);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
|
||||
"__Secure-next-auth.session-token",
|
||||
"next-auth.session-token",
|
||||
] as const;
|
||||
|
||||
type TCookieStore = {
|
||||
get: (name: string) => { value: string } | undefined;
|
||||
};
|
||||
|
||||
type TRequestWithCookies = {
|
||||
cookies: TCookieStore;
|
||||
};
|
||||
|
||||
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
|
||||
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
|
||||
const cookie = request.cookies.get(cookieName);
|
||||
if (cookie?.value) {
|
||||
return cookie.value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getProxySession = async (request: TRequestWithCookies) => {
|
||||
const sessionToken = getSessionTokenFromRequest(request);
|
||||
|
||||
if (!sessionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: {
|
||||
sessionToken,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
expires: true,
|
||||
user: {
|
||||
select: {
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session || session.expires <= new Date() || session.user.isActive === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
@@ -106,7 +106,10 @@ describe("billing actions", () => {
|
||||
});
|
||||
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
|
||||
"org_1",
|
||||
"start-hobby"
|
||||
);
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
@@ -125,7 +128,10 @@ describe("billing actions", () => {
|
||||
} as any);
|
||||
|
||||
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
|
||||
"org_1",
|
||||
"start-hobby"
|
||||
);
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
@@ -139,7 +145,7 @@ describe("billing actions", () => {
|
||||
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
@@ -159,7 +165,7 @@ describe("billing actions", () => {
|
||||
|
||||
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
||||
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
|
||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
|
||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
|
||||
}
|
||||
|
||||
await createProTrialSubscription(parsedInput.organizationId, customerId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
await handleSetupCheckoutCompleted(event.data.object, stripe);
|
||||
}
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
|
||||
await syncOrganizationBillingFromStripe(organizationId, {
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "@formbricks/types/organizations";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -78,6 +77,14 @@ const formatMoney = (currency: string, unitAmount: number | null, locale: string
|
||||
}).format(unitAmount / 100);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date, locale: string) =>
|
||||
date.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
type TPlanCardData = {
|
||||
plan: TStandardPlan;
|
||||
interval: TCloudBillingInterval;
|
||||
@@ -161,17 +168,7 @@ export const PricingTable = ({
|
||||
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
|
||||
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
|
||||
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
|
||||
const usageCycleLabel = `${formatDateForDisplay(usageCycleStart, locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})} - ${formatDateForDisplay(usageCycleEnd, locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}`;
|
||||
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
|
||||
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
|
||||
const currentPlanLevel =
|
||||
@@ -436,15 +433,7 @@ export const PricingTable = ({
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.pending_plan_change_description")
|
||||
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
|
||||
.replace(
|
||||
"{{date}}",
|
||||
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
)}
|
||||
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
|
||||
</AlertDescription>
|
||||
{hasBillingRights && (
|
||||
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
|
||||
|
||||
@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
|
||||
items: [{ price: "price_hobby_monthly", quantity: 1 }],
|
||||
metadata: { organizationId: "org_1" },
|
||||
},
|
||||
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
|
||||
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
|
||||
);
|
||||
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
|
||||
where: { organizationId: "org_1" },
|
||||
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
|
||||
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
|
||||
|
||||
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
|
||||
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
|
||||
|
||||
@@ -458,21 +458,18 @@ const resolvePendingChangeEffectiveAt = (
|
||||
const ensureHobbySubscription = async (
|
||||
organizationId: string,
|
||||
customerId: string,
|
||||
subscriptionCount: number
|
||||
idempotencySuffix: string
|
||||
): Promise<void> => {
|
||||
if (!stripeClient) return;
|
||||
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
|
||||
|
||||
// Include subscriptionCount so the key is stable across concurrent calls (same
|
||||
// count → same key → Stripe deduplicates) but changes after a cancellation
|
||||
// (count increases → new key → allows legitimate re-creation).
|
||||
await stripeClient.subscriptions.create(
|
||||
{
|
||||
customer: customerId,
|
||||
items: hobbyItems,
|
||||
metadata: { organizationId },
|
||||
},
|
||||
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
|
||||
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1267,7 +1264,8 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
|
||||
};
|
||||
|
||||
export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
idempotencySuffix = "reconcile"
|
||||
): Promise<void> => {
|
||||
const client = stripeClient;
|
||||
if (!IS_FORMBRICKS_CLOUD || !client) return;
|
||||
@@ -1315,26 +1313,11 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
hobbySubscriptions.map(async ({ subscription }) => {
|
||||
try {
|
||||
await client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Stripe.errors.StripeInvalidRequestError &&
|
||||
err.statusCode === 404 &&
|
||||
err.code === "resource_missing"
|
||||
) {
|
||||
logger.warn(
|
||||
{ subscriptionId: subscription.id, organizationId },
|
||||
"Subscription already deleted, skipping cancel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
hobbySubscriptions.map(({ subscription }) =>
|
||||
client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1344,14 +1327,12 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
|
||||
const freshSubscriptions = await client.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
limit: 20,
|
||||
status: "active",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
|
||||
|
||||
if (freshActive.length === 0) {
|
||||
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
|
||||
if (freshSubscriptions.data.length === 0) {
|
||||
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1359,6 +1340,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
|
||||
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
|
||||
await ensureStripeCustomerForOrganization(organizationId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
|
||||
await syncOrganizationBillingFromStripe(organizationId);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
@@ -36,12 +35,12 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
if (!responses) {
|
||||
@@ -50,11 +49,11 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
||||
const locale = user.locale ?? DEFAULT_LOCALE;
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
return (
|
||||
<ActivityTimeline
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getLocale } from "@/lingodotdev/language";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
@@ -11,14 +9,13 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
const t = await getTranslate();
|
||||
const [locale, contact, attributesWithKeyInfo] = await Promise.all([
|
||||
getLocale(),
|
||||
const [contact, attributesWithKeyInfo] = await Promise.all([
|
||||
getContact(contactId),
|
||||
getContactAttributesWithKeyInfo(contactId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError(t("common.contact"), contactId);
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
}
|
||||
|
||||
const [responses, displays] = await Promise.all([
|
||||
@@ -46,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
return <IdBadge id={attr.value} />;
|
||||
}
|
||||
|
||||
return formatAttributeValue(attr.value, attr.dataType, locale);
|
||||
return formatAttributeValue(attr.value, attr.dataType);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
@@ -32,7 +31,7 @@ export const SingleContactPage = async (props: {
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError(t("common.contact"), params.contactId);
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
@@ -165,7 +164,7 @@ export const updateContactAttributesAction = authenticatedActionClient
|
||||
// Get contact to access environmentId for revalidation
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
throw new Error("Contact not found");
|
||||
}
|
||||
|
||||
const result = await updateContactAttributes(parsedInput.contactId, parsedInput.attributes);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
@@ -61,15 +61,7 @@ export const generateAttributeTableColumns = (
|
||||
header: t("common.created_at"),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return (
|
||||
<span>
|
||||
{formatDateForDisplay(createdAt, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const AttributesTable = ({
|
||||
// Generate columns
|
||||
const columns = useMemo(() => {
|
||||
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale);
|
||||
}, [searchValue, isReadOnly, isExpanded, locale, t]);
|
||||
}, [searchValue, isReadOnly, isExpanded]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { TFunction } from "i18next";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
@@ -13,7 +12,6 @@ export const generateContactTableColumns = (
|
||||
searchValue: string,
|
||||
data: TContactTableData[],
|
||||
isReadOnly: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TContactTableData>[] => {
|
||||
const userColumn: ColumnDef<TContactTableData> = {
|
||||
@@ -77,7 +75,7 @@ export const generateContactTableColumns = (
|
||||
cell: ({ row }: { row: { original: TContactTableData } }) => {
|
||||
const attribute = row.original.attributes.find((a) => a.key === attr.key);
|
||||
if (!attribute) return null;
|
||||
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType, locale);
|
||||
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
|
||||
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -21,7 +20,7 @@ export const ContactsSecondaryNavigation = async ({
|
||||
project = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@ta
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -66,15 +65,14 @@ export const ContactsTable = ({
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Generate columns
|
||||
const columns = useMemo(() => {
|
||||
return generateContactTableColumns(searchValue, data, isReadOnly, locale, t);
|
||||
}, [searchValue, data, isReadOnly, locale, t]);
|
||||
return generateContactTableColumns(searchValue, data, isReadOnly, t);
|
||||
}, [searchValue, data, isReadOnly]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -24,11 +24,11 @@ const ConfigLayout = async (props: {
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
@@ -45,7 +45,7 @@ const ConfigLayout = async (props: {
|
||||
|
||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
return children;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
|
||||
/**
|
||||
* Formats an attribute value for display based on its data type.
|
||||
@@ -28,11 +27,12 @@ export const formatAttributeValue = (
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return formatDateForDisplay(date, locale, {
|
||||
// Use Intl.DateTimeFormat for locale-aware date formatting
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}).format(date);
|
||||
} catch {
|
||||
// If date parsing fails, return the raw value
|
||||
return String(value);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { loadNewSegmentInSurvey } from "@/lib/survey/service";
|
||||
@@ -35,7 +35,7 @@ const checkAdvancedTargetingPermission = async (organizationId: string) => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,63 +15,23 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { SegmentActivityTab } from "./segment-activity-tab";
|
||||
import { TSegmentActivitySummary } from "./segment-activity-utils";
|
||||
|
||||
interface EditSegmentModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
currentSegment: TSegmentWithSurveyRefs;
|
||||
activitySummary: TSegmentActivitySummary;
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const SegmentSettingsTab = ({
|
||||
activitySummary,
|
||||
contactAttributeKeys,
|
||||
currentSegment,
|
||||
environmentId,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
segments,
|
||||
setOpen,
|
||||
}: Pick<
|
||||
EditSegmentModalProps,
|
||||
| "activitySummary"
|
||||
| "contactAttributeKeys"
|
||||
| "currentSegment"
|
||||
| "environmentId"
|
||||
| "isContactsEnabled"
|
||||
| "isReadOnly"
|
||||
| "segments"
|
||||
| "setOpen"
|
||||
>) => {
|
||||
if (!isContactsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentSettings
|
||||
activitySummary={activitySummary}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
environmentId={environmentId}
|
||||
initialSegment={currentSegment}
|
||||
segments={segments}
|
||||
setOpen={setOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditSegmentModal = ({
|
||||
environmentId,
|
||||
open,
|
||||
setOpen,
|
||||
currentSegment,
|
||||
activitySummary,
|
||||
contactAttributeKeys,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
@@ -80,25 +40,31 @@ export const EditSegmentModal = ({
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const SettingsTab = () => {
|
||||
if (isContactsEnabled) {
|
||||
return (
|
||||
<SegmentSettings
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
environmentId={environmentId}
|
||||
initialSegment={currentSegment}
|
||||
segments={segments}
|
||||
setOpen={setOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
title: t("common.activity"),
|
||||
children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
|
||||
children: <SegmentActivityTab currentSegment={currentSegment} />,
|
||||
},
|
||||
{
|
||||
title: t("common.settings"),
|
||||
children: (
|
||||
<SegmentSettingsTab
|
||||
activitySummary={activitySummary}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
currentSegment={currentSegment}
|
||||
environmentId={environmentId}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
segments={segments}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
children: <SettingsTab />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TSegmentActivitySummary } from "./segment-activity-utils";
|
||||
|
||||
interface SegmentActivityTabProps {
|
||||
currentSegment: TSegmentWithSurveyRefs;
|
||||
activitySummary: TSegmentActivitySummary;
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
}
|
||||
|
||||
export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { activeSurveys, inactiveSurveys } = activitySummary;
|
||||
const { activeSurveys, inactiveSurveys } = currentSegment;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
@@ -25,20 +22,20 @@ export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentA
|
||||
<Label className="text-slate-500">{t("common.active_surveys")}</Label>
|
||||
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
|
||||
|
||||
{activeSurveys?.map((surveyName) => (
|
||||
<div className="py-0.5" key={surveyName}>
|
||||
<p className="text-sm text-slate-900">{surveyName}</p>
|
||||
</div>
|
||||
{activeSurveys?.map((survey, index) => (
|
||||
<p className="text-sm text-slate-900" key={index + survey}>
|
||||
{survey}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
|
||||
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
|
||||
|
||||
{inactiveSurveys?.map((surveyName) => (
|
||||
<div className="py-0.5" key={surveyName}>
|
||||
<p className="text-sm text-slate-900">{surveyName}</p>
|
||||
</div>
|
||||
{inactiveSurveys?.map((survey, index) => (
|
||||
<p className="text-sm text-slate-900" key={index + survey}>
|
||||
{survey}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,13 +43,13 @@ export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentA
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{formatDateTimeForDisplay(currentSegment.createdAt, locale)}
|
||||
{convertDateTimeStringShort(currentSegment.createdAt?.toString())}
|
||||
</p>
|
||||
</div>{" "}
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{formatDateTimeForDisplay(currentSegment.updatedAt, locale)}
|
||||
{convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TBaseFilters, TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildSegmentActivitySummary,
|
||||
buildSegmentActivitySummaryFromSegments,
|
||||
doesSegmentReferenceSegment,
|
||||
getReferencingSegments,
|
||||
} from "./segment-activity-utils";
|
||||
|
||||
const createSurvey = (overrides: Partial<TSurvey>): TSurvey =>
|
||||
({
|
||||
id: "survey_1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: "env_1",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: {},
|
||||
html: {},
|
||||
fileUrl: "",
|
||||
buttonLabel: {},
|
||||
timeToFinish: false,
|
||||
},
|
||||
questions: [],
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
triggers: [],
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
segmentId: null,
|
||||
projectOverwrites: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
redirectUrl: null,
|
||||
displayStatus: null,
|
||||
displayCount: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
recaptcha: null,
|
||||
variables: [],
|
||||
blocks: undefined,
|
||||
followUps: [],
|
||||
verifyEmailTemplateId: null,
|
||||
...overrides,
|
||||
}) as TSurvey;
|
||||
|
||||
const createSegment = (overrides: Partial<TSegment>): TSegment =>
|
||||
({
|
||||
id: "segment_1",
|
||||
title: "Segment 1",
|
||||
description: null,
|
||||
isPrivate: false,
|
||||
environmentId: "env_1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveys: [],
|
||||
filters: [],
|
||||
...overrides,
|
||||
}) as TSegment;
|
||||
|
||||
const createSegmentWithSurveyNames = (overrides: Partial<TSegmentWithSurveyRefs>): TSegmentWithSurveyRefs =>
|
||||
({
|
||||
...createSegment(overrides),
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: [],
|
||||
...overrides,
|
||||
}) as TSegmentWithSurveyRefs;
|
||||
|
||||
describe("segment activity utils", () => {
|
||||
test("doesSegmentReferenceSegment returns true for nested segment filters", () => {
|
||||
const filters: TBaseFilters = [
|
||||
{
|
||||
id: "group_1",
|
||||
connector: null,
|
||||
resource: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsNotIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(doesSegmentReferenceSegment(filters, "segment_target")).toBe(true);
|
||||
expect(doesSegmentReferenceSegment(filters, "segment_other")).toBe(false);
|
||||
});
|
||||
|
||||
test("getReferencingSegments excludes the current segment and returns only matching segments", () => {
|
||||
const segments = [
|
||||
createSegment({ id: "segment_target" }),
|
||||
createSegment({
|
||||
id: "segment_ref",
|
||||
filters: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
createSegment({
|
||||
id: "segment_other",
|
||||
filters: [
|
||||
{
|
||||
id: "filter_2",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "attribute_filter_1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "plan",
|
||||
},
|
||||
value: "enterprise",
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
] as TSegmentWithSurveyRefs[];
|
||||
|
||||
expect(getReferencingSegments(segments, "segment_target").map((segment) => segment.id)).toEqual([
|
||||
"segment_ref",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary returns direct surveys grouped by status", () => {
|
||||
const directSurveys = [
|
||||
createSurvey({
|
||||
id: "survey_direct",
|
||||
name: "Direct Survey",
|
||||
status: "inProgress",
|
||||
}),
|
||||
createSurvey({
|
||||
id: "survey_draft",
|
||||
name: "Draft Survey",
|
||||
status: "draft",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary(directSurveys, [])).toEqual({
|
||||
activeSurveys: ["Direct Survey"],
|
||||
inactiveSurveys: ["Draft Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary includes indirect surveys when there is no direct match", () => {
|
||||
const indirectSurveyGroups = [
|
||||
{
|
||||
segmentId: "segment_ref",
|
||||
segmentTitle: "Referenced Segment",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_draft",
|
||||
name: "Draft Survey",
|
||||
status: "draft",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: ["Draft Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary prefers direct surveys over indirect duplicates", () => {
|
||||
const directSurveys = [
|
||||
createSurvey({
|
||||
id: "survey_shared",
|
||||
name: "Shared Survey",
|
||||
status: "inProgress",
|
||||
}),
|
||||
];
|
||||
const indirectSurveyGroups = [
|
||||
{
|
||||
segmentId: "segment_ref",
|
||||
segmentTitle: "Referenced Segment",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_shared",
|
||||
name: "Shared Survey",
|
||||
status: "inProgress",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary(directSurveys, indirectSurveyGroups)).toEqual({
|
||||
activeSurveys: ["Shared Survey"],
|
||||
inactiveSurveys: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary deduplicates indirect surveys referenced by multiple segments", () => {
|
||||
const indirectSurveyGroups = [
|
||||
{
|
||||
segmentId: "segment_ref_1",
|
||||
segmentTitle: "Referenced Segment 1",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_indirect",
|
||||
name: "Indirect Survey",
|
||||
status: "paused",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
segmentId: "segment_ref_2",
|
||||
segmentTitle: "Referenced Segment 2",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_indirect",
|
||||
name: "Indirect Survey",
|
||||
status: "paused",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: ["Indirect Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummaryFromSegments merges direct and indirect surveys from segment table data", () => {
|
||||
const currentSegment = createSegmentWithSurveyNames({
|
||||
id: "segment_target",
|
||||
activeSurveys: [{ id: "survey_direct", name: "Direct Survey" }],
|
||||
inactiveSurveys: [{ id: "survey_paused", name: "Paused Survey" }],
|
||||
});
|
||||
const segments = [
|
||||
currentSegment,
|
||||
createSegmentWithSurveyNames({
|
||||
id: "segment_ref",
|
||||
title: "Referenced Segment",
|
||||
activeSurveys: [{ id: "survey_indirect", name: "Indirect Survey" }],
|
||||
inactiveSurveys: [{ id: "survey_paused", name: "Paused Survey" }],
|
||||
filters: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummaryFromSegments(currentSegment, segments)).toEqual({
|
||||
activeSurveys: ["Direct Survey", "Indirect Survey"],
|
||||
inactiveSurveys: ["Paused Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummaryFromSegments includes indirect usage from private survey segments", () => {
|
||||
const currentSegment = createSegmentWithSurveyNames({
|
||||
id: "segment_target",
|
||||
});
|
||||
|
||||
const privateReferencingSegment = createSegmentWithSurveyNames({
|
||||
id: "segment_private_ref",
|
||||
title: "Private Survey Segment",
|
||||
isPrivate: true,
|
||||
activeSurveys: [{ id: "survey_private", name: "Indirect Private Survey" }],
|
||||
filters: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsNotIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildSegmentActivitySummaryFromSegments(currentSegment, [currentSegment, privateReferencingSegment])
|
||||
).toEqual({
|
||||
activeSurveys: ["Indirect Private Survey"],
|
||||
inactiveSurveys: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type TSurveySummary = Pick<TSurvey, "id" | "name" | "status">;
|
||||
type TReferencingSegmentSurveyGroup = {
|
||||
segmentId: string;
|
||||
segmentTitle: string;
|
||||
surveys: TSurveySummary[];
|
||||
};
|
||||
|
||||
export type TSegmentActivitySummary = {
|
||||
activeSurveys: string[];
|
||||
inactiveSurveys: string[];
|
||||
};
|
||||
|
||||
export const doesSegmentReferenceSegment = (filters: TBaseFilters, targetSegmentId: string): boolean => {
|
||||
for (const filter of filters) {
|
||||
const { resource } = filter;
|
||||
|
||||
if (Array.isArray(resource)) {
|
||||
if (doesSegmentReferenceSegment(resource, targetSegmentId)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resource.root.type === "segment" && resource.root.segmentId === targetSegmentId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getReferencingSegments = (
|
||||
segments: TSegmentWithSurveyRefs[],
|
||||
targetSegmentId: string
|
||||
): TSegmentWithSurveyRefs[] =>
|
||||
segments.filter(
|
||||
(segment) =>
|
||||
segment.id !== targetSegmentId && doesSegmentReferenceSegment(segment.filters, targetSegmentId)
|
||||
);
|
||||
|
||||
export const buildSegmentActivitySummary = (
|
||||
directSurveys: TSurveySummary[],
|
||||
indirectSurveyGroups: TReferencingSegmentSurveyGroup[]
|
||||
): TSegmentActivitySummary => {
|
||||
const surveyMap = new Map<string, TSurveySummary>();
|
||||
|
||||
for (const survey of directSurveys) {
|
||||
surveyMap.set(survey.id, survey);
|
||||
}
|
||||
|
||||
for (const segment of indirectSurveyGroups) {
|
||||
for (const survey of segment.surveys) {
|
||||
if (!surveyMap.has(survey.id)) {
|
||||
surveyMap.set(survey.id, survey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const surveys = Array.from(surveyMap.values());
|
||||
|
||||
return {
|
||||
activeSurveys: surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name),
|
||||
inactiveSurveys: surveys
|
||||
.filter((survey) => survey.status === "draft" || survey.status === "paused")
|
||||
.map((survey) => survey.name),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSegmentActivitySummaryFromSegments = (
|
||||
currentSegment: TSegmentWithSurveyRefs,
|
||||
segments: TSegmentWithSurveyRefs[]
|
||||
): TSegmentActivitySummary => {
|
||||
const activeSurveyMap = new Map(currentSegment.activeSurveys.map((s) => [s.id, s.name]));
|
||||
const inactiveSurveyMap = new Map(currentSegment.inactiveSurveys.map((s) => [s.id, s.name]));
|
||||
const allDirectIds = new Set([...activeSurveyMap.keys(), ...inactiveSurveyMap.keys()]);
|
||||
|
||||
const referencingSegments = getReferencingSegments(segments, currentSegment.id);
|
||||
for (const segment of referencingSegments) {
|
||||
for (const survey of segment.activeSurveys) {
|
||||
if (!allDirectIds.has(survey.id) && !activeSurveyMap.has(survey.id)) {
|
||||
activeSurveyMap.set(survey.id, survey.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const survey of segment.inactiveSurveys) {
|
||||
if (!allDirectIds.has(survey.id) && !inactiveSurveyMap.has(survey.id)) {
|
||||
inactiveSurveyMap.set(survey.id, survey.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeSurveys: Array.from(activeSurveyMap.values()),
|
||||
inactiveSurveys: Array.from(inactiveSurveyMap.values()),
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user