Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent 207ad3c7bd fix: wrap custom script execution in try-catch to prevent ReferenceError
Fixes FORMBRICKS-TK

Custom scripts injected via CustomScriptsInjector can attempt to access
undefined global variables (like zp_token) before they're defined, causing
a ReferenceError that breaks the survey page.

This fix wraps inline script content in an IIFE with try-catch to gracefully
handle errors from undefined variables. Also adds error handlers for external
scripts that fail to load.

The survey page will now continue to function even when custom scripts
encounter runtime errors, with warnings logged to the console for debugging.
2026-03-17 18:30:40 +00:00
94 changed files with 604 additions and 1671 deletions
+1 -1
View File
@@ -231,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
LINGODOTDEV_API_KEY=your_api_key_here
-8
View File
@@ -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,146 +0,0 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};
@@ -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";
@@ -16,7 +15,6 @@ import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps {
status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date;
environmentId: string;
}
@@ -46,12 +44,10 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({
status,
lastChecked,
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);
@@ -96,12 +92,7 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<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)}
</span>
</div>
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
</div>
<Button
type="button"
@@ -127,7 +118,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",
@@ -10,7 +10,6 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
@@ -94,19 +93,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/>
</PageHeader>
{hasLicense ? (
<>
<EnterpriseLicenseStatus
status={licenseState.status}
lastChecked={licenseState.lastChecked}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
<EnterpriseLicenseStatus
status={licenseState.status}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
) : (
<div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
@@ -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
@@ -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>;
},
};
@@ -1,17 +1,13 @@
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";
import {
DEFAULT_LOCALE,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
RESPONSES_PER_PAGE,
} from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
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";
@@ -27,12 +23,13 @@ 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) {
@@ -89,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={user.locale ?? DEFAULT_LOCALE}
locale={locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
@@ -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,9 +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")}(${value})`;
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
@@ -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>
@@ -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>
@@ -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>
);
@@ -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>
+19 -49
View File
@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({
userEmail,
userName,
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => {
const $chatwoot = getChatwoot();
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
});
userSetRef.current = true;
}
}, [userId, userEmail, userName, getChatwoot]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready
if (getChatwoot()) {
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = getChatwoot();
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
scriptElement?.remove();
userSetRef.current = false;
customerStatusSetRef.current = false;
};
}, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};
-18
View File
@@ -1,18 +0,0 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});
+2 -27
View File
@@ -267,7 +267,6 @@ checksums:
common/new: 126d036fae5fb6b629728ecb97e6195b
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
common/next: 89ddbcf710eba274963494f312bdc8a9
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
@@ -313,7 +312,6 @@ checksums:
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -1014,25 +1012,6 @@ checksums:
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
@@ -1380,7 +1359,6 @@ checksums:
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
@@ -1638,7 +1616,6 @@ checksums:
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
@@ -1648,12 +1625,10 @@ checksums:
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
@@ -2922,7 +2897,7 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
@@ -3175,7 +3150,7 @@ checksums:
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
-56
View File
@@ -18,18 +18,6 @@ describe("Time Utilities", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should format date string with the provided locale", () => {
const date = new Date("2024-03-20T12:30:00");
expect(convertDateString("2024-03-20T12:30:00", "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
@@ -58,20 +46,6 @@ describe("Time Utilities", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
});
test("should format date and time string in the provided locale", () => {
const date = new Date("2024-03-20T15:30:00");
expect(convertDateTimeStringShort("2024-03-20T15:30:00", "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
});
test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe("");
});
@@ -101,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", () => {
@@ -121,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", () => {
@@ -134,18 +90,6 @@ 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", () => {
+36 -26
View File
@@ -1,11 +1,8 @@
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, formatDateTimeForDisplay } from "./utils/datetime";
const DEFAULT_LOCALE = "en-US";
export const convertDateString = (dateString: string | null, locale: string = DEFAULT_LOCALE) => {
export const convertDateString = (dateString: string | null) => {
if (dateString === null) return null;
if (!dateString) {
return dateString;
@@ -15,25 +12,41 @@ export const convertDateString = (dateString: string | null, locale: string = DE
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return formatDateForDisplay(date, locale);
return intlFormat(
date,
{
year: "numeric",
month: "short",
day: "numeric",
},
{
locale: "en",
}
);
};
export const convertDateTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertDateTimeString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return formatDateTimeForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return intlFormat(
date,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
export const convertDateTimeStringShort = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertDateTimeStringShort = (dateString: string) => {
if (!dateString) {
return dateString;
}
@@ -48,12 +61,12 @@ export const convertDateTimeStringShort = (dateString: string, locale: string =
minute: "2-digit",
},
{
locale,
locale: "en",
}
);
};
export const convertTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
@@ -63,12 +76,12 @@ export const convertTimeString = (dateString: string, locale: string = DEFAULT_L
second: "2-digit",
},
{
locale,
locale: "en",
}
);
};
const getLocaleForTimeSince = (locale: TUserLocale | string) => {
const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) {
case "de-DE":
return de;
@@ -98,12 +111,10 @@ const getLocaleForTimeSince = (locale: TUserLocale | string) => {
return zhCN;
case "zh-Hant-TW":
return zhTW;
default:
return enUS;
}
};
export const timeSince = (dateString: string, locale: TUserLocale | string = DEFAULT_LOCALE) => {
export const timeSince = (dateString: string, locale: TUserLocale) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
@@ -111,15 +122,14 @@ export const timeSince = (dateString: string, locale: TUserLocale | string = DEF
});
};
export const timeSinceDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => {
export const timeSinceDate = (date: Date) => {
return formatDistance(date, new Date(), {
addSuffix: true,
locale: getLocaleForTimeSince(locale),
});
};
export const formatDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => {
return formatDateForDisplay(date, locale, {
export const formatDate = (date: Date) => {
return intlFormat(date, {
year: "numeric",
month: "long",
day: "numeric",
-67
View File
@@ -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",
});
});
});
-83
View File
@@ -1,83 +0,0 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
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 = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
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;
}, {});
};
+4 -43
View File
@@ -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", () => {
+13 -44
View File
@@ -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 => {
+10 -24
View File
@@ -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", () => {
+7 -11
View File
@@ -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(", ");
}
}
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "Neu",
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
"next": "Weiter",
"no_actions_found": "Keine Aktionen gefunden",
"no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Unternehmensfunktionen",
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
"license_feature_access_control": "Zugriffskontrolle (RBAC)",
"license_feature_audit_logs": "Audit-Protokolle",
"license_feature_contacts": "Kontakte & Segmente",
"license_feature_projects": "Arbeitsbereiche",
"license_feature_quotas": "Kontingente",
"license_feature_remove_branding": "Branding entfernen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam-Schutz",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
"license_feature_whitelabel": "White-Label-E-Mails",
"license_features_table_access": "Zugriff",
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
"license_features_table_disabled": "Deaktiviert",
"license_features_table_enabled": "Aktiviert",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Lizenzierte Funktionen",
"license_features_table_unlimited": "Unbegrenzt",
"license_features_table_value": "Wert",
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
"license_status": "Lizenzstatus",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"everyone": "Jeder",
"expand_preview": "Vorschau erweitern",
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
"fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
"spam_protection_threshold_heading": "Antwortschwelle",
"shrink_preview": "Vorschau verkleinern",
"star": "Stern",
"starts_with": "Fängt an mit",
"state": "Bundesland",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -",
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage",
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?",
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
"preview_survey_welcome_card_headline": "Willkommen!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Möchten Sie noch etwas hinzufügen?",
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
"generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "New",
"new_version_available": "Formbricks {version} is here. Upgrade now!",
"next": "Next",
"no_actions_found": "No actions found",
"no_background_image_found": "No background image found.",
"no_code": "No code",
"no_files_uploaded": "No files were uploaded",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Please select at least one survey",
"please_select_at_least_one_trigger": "Please select at least one trigger",
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Enterprise Features",
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
"license_feature_access_control": "Access control (RBAC)",
"license_feature_audit_logs": "Audit logs",
"license_feature_contacts": "Contacts & Segments",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remove branding",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam protection",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Two-factor authentication",
"license_feature_whitelabel": "White-label emails",
"license_features_table_access": "Access",
"license_features_table_description": "Enterprise features and limits currently available to this instance.",
"license_features_table_disabled": "Disabled",
"license_features_table_enabled": "Enabled",
"license_features_table_feature": "Feature",
"license_features_table_title": "Licensed Features",
"license_features_table_unlimited": "Unlimited",
"license_features_table_value": "Value",
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
"license_status": "License Status",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone",
"expand_preview": "Expand Preview",
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
"spam_protection_threshold_heading": "Response threshold",
"shrink_preview": "Shrink Preview",
"star": "Star",
"starts_with": "Starts with",
"state": "State",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Styling set to theme styles",
"subheading": "Subheading",
"subtract": "Subtract -",
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
"survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings",
"survey_placement": "Survey Placement",
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_question_2_subheader": "This is an example description.",
"preview_survey_question_open_text_headline": "Anything else you would like to share?",
"preview_survey_question_open_text_headline": "Anything else you'd like to share?",
"preview_survey_question_open_text_placeholder": "Type your answer here…",
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
"preview_survey_welcome_card_headline": "Welcome!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you would like to add?",
"follow_up_label": "Is there anything else you'd like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
"generate_button": "Generate workflow",
"heading": "What workflow do you want to create?",
-25
View File
@@ -294,7 +294,6 @@
"new": "Nuevo",
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
"next": "Siguiente",
"no_actions_found": "No se encontraron acciones",
"no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Características empresariales",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
"license_feature_access_control": "Control de acceso (RBAC)",
"license_feature_audit_logs": "Registros de auditoría",
"license_feature_contacts": "Contactos y segmentos",
"license_feature_projects": "Espacios de trabajo",
"license_feature_quotas": "Cuotas",
"license_feature_remove_branding": "Eliminar marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protección contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticación de dos factores",
"license_feature_whitelabel": "Correos sin marca",
"license_features_table_access": "Acceso",
"license_features_table_description": "Funciones y límites empresariales disponibles actualmente para esta instancia.",
"license_features_table_disabled": "Desactivado",
"license_features_table_enabled": "Activado",
"license_features_table_feature": "Función",
"license_features_table_title": "Funciones con licencia",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
"license_status": "Estado de la licencia",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Error al guardar los cambios",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
"everyone": "Todos",
"expand_preview": "Expandir vista previa",
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
"fallback_missing": "Falta respaldo",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
"spam_protection_threshold_heading": "Umbral de respuesta",
"shrink_preview": "Contraer vista previa",
"star": "Estrella",
"starts_with": "Comienza con",
"state": "Estado",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
"subheading": "Subtítulo",
"subtract": "Restar -",
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
"survey_completed_heading": "Encuesta completada",
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta",
"survey_placement": "Ubicación de la encuesta",
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "Nouveau",
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
"next": "Suivant",
"no_actions_found": "Aucune action trouvée",
"no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Sans code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Fonctionnalités d'entreprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
"license_feature_access_control": "Contrôle d'accès (RBAC)",
"license_feature_audit_logs": "Journaux d'audit",
"license_feature_contacts": "Contacts et segments",
"license_feature_projects": "Espaces de travail",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Retirer l'image de marque",
"license_feature_saml": "SSO SAML",
"license_feature_spam_protection": "Protection anti-spam",
"license_feature_sso": "SSO OIDC",
"license_feature_two_factor_auth": "Authentification à deux facteurs",
"license_feature_whitelabel": "E-mails en marque blanche",
"license_features_table_access": "Accès",
"license_features_table_description": "Fonctionnalités Enterprise et limites actuellement disponibles pour cette instance.",
"license_features_table_disabled": "Désactivé",
"license_features_table_enabled": "Activé",
"license_features_table_feature": "Fonctionnalité",
"license_features_table_title": "Fonctionnalités sous licence",
"license_features_table_unlimited": "Illimité",
"license_features_table_value": "Valeur",
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
"license_status": "Statut de la licence",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
"everyone": "Tout le monde",
"expand_preview": "Agrandir l'aperçu",
"external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher lhameçonnage.",
"fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
"spam_protection_threshold_heading": "Seuil de réponse",
"shrink_preview": "Réduire l'aperçu",
"star": "Étoile",
"starts_with": "Commence par",
"state": "État",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
"subheading": "Sous-titre",
"subtract": "Soustraire -",
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
"survey_completed_heading": "Enquête terminée",
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête",
"survey_placement": "Placement de l'enquête",
"survey_preview": "Aperçu du sondage 👀",
"survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_question_2_subheader": "Ceci est un exemple de description.",
"preview_survey_question_open_text_headline": "Souhaitez-vous partager autre chose ?",
"preview_survey_question_open_text_headline": "Autre chose que vous aimeriez partager?",
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
"preview_survey_welcome_card_headline": "Bienvenue !",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
"coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Souhaitez-vous ajouter quelque chose ?",
"follow_up_label": "Y a-t-il autre chose que vous aimeriez ajouter?",
"follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?",
"generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?",
+82 -107
View File
@@ -175,7 +175,7 @@
"copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_contacts": "{count, plural, one {{count} kontakt}} other {{count} kontakt}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
@@ -294,7 +294,6 @@
"new": "Új",
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
"next": "Következő",
"no_actions_found": "Nem találhatók műveletek",
"no_background_image_found": "Nem található háttérkép.",
"no_code": "Kód nélkül",
"no_files_uploaded": "Nem lettek fájlok feltöltve",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet",
"please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót",
"please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet",
"preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek",
@@ -362,7 +360,7 @@
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
"replace": "Csere",
"report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbaidőszaki licenc kérése",
"request_trial_license": "Próbalicenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"response": "Válasz",
"response_id": "Válaszazonosító",
@@ -401,7 +399,7 @@
"something_went_wrong": "Valami probléma történt",
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
"sort_by": "Rendezési sorrend",
"start_free_trial": "Ingyenes próbaidőszak indítása",
"start_free_trial": "Ingyenes próbaverzió indítása",
"status": "Állapot",
"step_by_step_manual": "Lépésenkénti kézikönyv",
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
@@ -436,9 +434,9 @@
"title": "Cím",
"top_left": "Balra fent",
"top_right": "Jobbra fent",
"trial_days_remaining": "{count} nap van hátra a próbaidőszakából",
"trial_expired": "A próbaidőszaka lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
"trial_days_remaining": "{count} nap van hátra a próbaidőszakból",
"trial_expired": "A próbaidőszak lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakból",
"try_again": "Próbálja újra",
"type": "Típus",
"unknown_survey": "Ismeretlen kérdőív",
@@ -446,7 +444,7 @@
"update": "Frissítés",
"updated": "Frissítve",
"updated_at": "Frissítve",
"upgrade_plan": "Magasabb csomagra váltás",
"upgrade_plan": "Csomag frissítése",
"upload": "Feltöltés",
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.",
"upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
@@ -539,7 +537,7 @@
"survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése",
"text_variable": "Szöveg változó",
"verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:",
"verification_email_heading": "Már majdnem kész vagyunk!",
"verification_email_heading": "Már majdnem megvagyunk!",
"verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
@@ -607,15 +605,15 @@
"test_match": "Illeszkedés tesztelése",
"test_your_url": "URL tesztelése",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.",
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet azután lesz aktiválva, hogy a felhasználó az oldalon marad a megadott időtartamig.",
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet akkor fog aktiválódni, miután a felhasználó a megadott ideig az oldalon tartózkodik.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
"time_in_seconds": "Idő másodpercben",
"time_in_seconds_placeholder": "például 10",
"time_in_seconds_placeholder": "pl. 10",
"time_in_seconds_with_unit": "{seconds} mp",
"time_on_page": "Idő az oldalon",
"time_on_page": "Oldalon töltött idő",
"track_new_user_action": "Új felhasználói művelet követése",
"track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.",
"url": "URL",
@@ -975,79 +973,79 @@
},
"billing": {
"add_payment_method": "Fizetési mód hozzáadása",
"add_payment_method_to_upgrade_tooltip": "Adjon hozzá fizetési módot fent, hogy fizetős csomagra váltson",
"billing_interval_toggle": "Számlázási időköz",
"add_payment_method_to_upgrade_tooltip": "Kérjük, adjon hozzá egy fizetési módot fent a fizetős csomagra való frissítéshez",
"billing_interval_toggle": "Számlázási időszak",
"current_plan_badge": "Jelenlegi",
"current_plan_cta": "Jelenlegi csomag",
"custom_plan_description": "A szervezete egyéni számlázási beállítással rendelkezik. Ugyanakkor áttérhet az alábbi szabványos csomagok egyikére.",
"custom_plan_title": "Egyéni csomag",
"failed_to_start_trial": "Nem sikerült a próbaidőszak indítása. Próbálja meg újra.",
"custom_plan_description": "A szervezete egyedi számlázási beállítással rendelkezik. Továbbra is válthat az alábbi standard csomagok egyikére.",
"custom_plan_title": "Egyedi csomag",
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.",
"keep_current_plan": "Jelenlegi csomag megtartása",
"manage_billing_details": "Kártyarészletek és számlák kezelése",
"manage_billing_details": "Kártyaadatok és számlák kezelése",
"monthly": "Havi",
"most_popular": "Legnépszerűbb",
"pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
"pending_change_removed": "Az ütemezett csomagváltás eltávolítva.",
"pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {{plan}} csomagra fog váltani ekkor: {{date}}.",
"pending_plan_change_title": "Ütemezett csomagváltoztatás",
"pending_plan_change_description": "A csomagja {{date}}-án átvált erre: {{plan}}.",
"pending_plan_change_title": "Ütemezett csomagváltás",
"pending_plan_cta": "Ütemezett",
"per_month": "havonta",
"per_year": "évente",
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
"plan_custom": "Egyéni",
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
"plan_hobby": "Hobbi",
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
"plan_hobby_feature_responses": "250 válasz/hónap",
"plan_change_scheduled": "A csomagváltás sikeresen ütemezve.",
"plan_custom": "Custom",
"plan_feature_everything_in_hobby": "Minden, ami a Hobby csomagban",
"plan_feature_everything_in_pro": "Minden, ami a Pro csomagban",
"plan_hobby": "Hobby",
"plan_hobby_description": "Magánszemélyek és kisebb csapatok számára, akik most kezdik a Formbricks Cloud használatát.",
"plan_hobby_feature_responses": "250 válasz / hó",
"plan_hobby_feature_workspaces": "1 munkaterület",
"plan_pro": "Pro",
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
"plan_pro_description": "Növekvő csapatok számára, amelyeknek magasabb korlátokra, automatizálásokra és dinamikus túlhasználatra van szükségük.",
"plan_pro_feature_responses": "2 000 válasz / hó (dinamikus túlhasználat)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Méretezés",
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek bb kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
"plan_scale": "Scale",
"plan_scale_description": "Nagyobb csapatok számára, amelyeknek nagyobb kapacitásra, erősebb irányításra és magasabb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz / hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Csomag kiválasztása",
"plan_selection_description": "Hasonlítsa össze a Hobby, Pro és Scale csomagokat, majd váltson csomagot közvetlenül a Formbricks alkalmazásból.",
"plan_selection_title": "Válassza ki az Ön csomagját",
"plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása",
"retry_setup": "Beállítás újrapróbálása",
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
"status_trialing": "Próbaidőszak",
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
"retry_setup": "Újrapróbálkozás a beállítással",
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.",
"select_plan_header_title": "Zökkenőmentesen integrált felmérések, 100%-ban az Ön márkája.",
"status_trialing": "Próbaverzió",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
"stripe_setup_incomplete": "Számlázás beállítása nem teljes",
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.",
"subscription": "Előfizetés",
"subscription_description": "Az előfizetési csomag kezelése és a használat felügyelete",
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot",
"switch_at_period_end": "Váltás az időszak végén",
"switch_plan_now": "Csomag váltása most",
"this_includes": "Ezeket tartalmazza",
"trial_alert_description": "Fizetési mód hozzáadása az összes funkcióhoz való hozzáférés megtartásához.",
"trial_already_used": "Ehhez az e-mail-címhez már használatban van egy ingyenes próbaidőszak. Váltson inkább fizetős csomagra.",
"this_includes": "Ez tartalmazza",
"trial_alert_description": "Adjon hozzá fizetési módot, hogy megtarthassa a hozzáférést az összes funkcióhoz.",
"trial_already_used": "Ehhez az e-mail címhez már igénybe vettek ingyenes próbaidőszakot. Kérjük, válasszon helyette fizetős csomagot.",
"trial_feature_api_access": "API-hozzáférés",
"trial_feature_attribute_segmentation": "Attribútumalapú szakaszolás",
"trial_feature_contact_segment_management": "Partner- és szakaszkezelés",
"trial_feature_email_followups": "E-mailes utókövetések",
"trial_feature_hide_branding": "Formbricks márkajel elrejtése",
"trial_feature_attribute_segmentation": "Attribútumalapú szegmentálás",
"trial_feature_contact_segment_management": "Kapcsolat- és szegmenskezelés",
"trial_feature_email_followups": "E-mail követések",
"trial_feature_hide_branding": "Formbricks márkajelzés elrejtése",
"trial_feature_mobile_sdks": "iOS és Android SDK-k",
"trial_feature_respondent_identification": "Válaszadó-azonosítás",
"trial_feature_unlimited_seats": "Korlátlan számú hely",
"trial_feature_webhooks": "Egyéni webhorgok",
"trial_no_credit_card": "14 napos próbaidőszak, nincs szükség hitelkártyára",
"trial_payment_method_added_description": "Mindent beállított! A Pro csomagja a próbaidőszak vége után automatikusan folytatódik.",
"trial_title": "Szerezze meg a Formbricks Pro csomagot ingyen!",
"trial_feature_unlimited_seats": "Korlátlan számú felhasználói hely",
"trial_feature_webhooks": "Egyéni webhookok",
"trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül",
"trial_payment_method_added_description": "Minden rendben! A Pro csomag automatikusan folytatódik a próbaidőszak lejárta után.",
"trial_title": "Szerezze meg a Formbricks Pro-t ingyen!",
"unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés",
"upgrade_now": "Frissítés most",
"usage_cycle": "Használati ciklus",
"used": "használva",
"yearly": "Évente",
"yearly_checkout_unavailable": "Az éves fizetési lehetőség még nem érhető el. Először adjon hozzá fizetési módot egy havi csomaghoz, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
"usage_cycle": "Usage cycle",
"used": "felhasználva",
"yearly": "Éves",
"yearly_checkout_unavailable": "Az éves fizetés még nem érhető el. Kérjük, adjon hozzá fizetési módot egy havi előfizetéshez, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
"your_plan": "Az Ön csomagja"
},
"domain": {
@@ -1073,48 +1071,29 @@
"enterprise_features": "Vállalati funkciók",
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
"license_feature_access_control": "Hozzáférés-vezérlés (RBAC)",
"license_feature_audit_logs": "Auditálási naplók",
"license_feature_contacts": "Partnerek és szakaszok",
"license_feature_projects": "Munkaterületek",
"license_feature_quotas": "Kvóták",
"license_feature_remove_branding": "Márkajel eltávolítása",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Szemét elleni védekezés",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
"license_feature_whitelabel": "Fehér címkés e-mailek",
"license_features_table_access": "Hozzáférés",
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_disabled": "Letiltva",
"license_features_table_enabled": "Engedélyezve",
"license_features_table_feature": "Funkció",
"license_features_table_title": "Licencelt funkciók",
"license_features_table_unlimited": "Korlátlan",
"license_features_table_value": "Érték",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks-példányhoz van kötve. Ha ezt a telepítést újraépítették vagy áthelyezték, akkor kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi példányhoz való kötést.",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks példányhoz van kötve. Amennyiben ez a telepítés újra lett építve vagy áthelyezésre került, kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző példány kötését.",
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
"license_status": "Licencállapot",
"license_status_active": "Aktív",
"license_status_description": "A vállalati licenc állapota.",
"license_status_expired": "Lejárt",
"license_status_instance_mismatch": "Másik példányhoz kötve",
"license_status_instance_mismatch": "Másik Példányhoz Kötve",
"license_status_invalid": "Érvénytelen licenc",
"license_status_unreachable": "Nem érhető el",
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos próbaidőszaki licencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos ingyenes próbalicencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
"no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)",
"on_request": "Kérésre",
"organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)",
"questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:",
"recheck_license": "Licenc újraellenőrzése",
"recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.",
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks-példányhoz van kötve. Kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi kötést.",
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks példányhoz van kötve. Kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző kötést.",
"recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.",
"recheck_license_success": "A licencellenőrzés sikeres",
"recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.",
"rechecking": "Újraellenőrzés…",
"request_30_day_trial_license": "30 napos próbaidőszaki licenc kérése",
"request_30_day_trial_license": "30 napos ingyenes licenc kérése",
"saml_sso": "SAML SSO",
"service_level_agreement": "Szolgáltatási megállapodás",
"soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés",
@@ -1451,22 +1430,21 @@
"error_saving_changes": "Hiba a változtatások mentésekor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).",
"everyone": "Mindenki",
"expand_preview": "Előnézet kinyitása",
"external_urls_paywall_tooltip": "Váltson a magasabb fizetős csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
"external_urls_paywall_tooltip": "Kérjük, váltson fizetős csomagra, hogy testre szabhassa a külső URL-eket. Ez segít megelőzni az adathalászatot.",
"fallback_missing": "Tartalék hiányzik",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában",
"field_name_eg_score_price": "Mező neve, például pontszám, ár",
"first_name": "Keresztnév",
"five_points_recommended": "5 pont (ajánlott)",
"follow_ups": "Utókövetések",
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt az utókövetést?",
"follow_ups_delete_modal_title": "Törli az utókövetést?",
"follow_ups": "Követések",
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt a követést?",
"follow_ups_delete_modal_title": "Törli a követést?",
"follow_ups_empty_description": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak.",
"follow_ups_empty_heading": "Automatikus utókövetések küldése",
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van az utókövetésekben. A törlése eltávolítja az összes utókövetésből. Biztosan törölni szeretné?",
"follow_ups_empty_heading": "Automatikus követések küldése",
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van a követésekben. A törlése eltávolítja az összes követésből. Biztosan törölni szeretné?",
"follow_ups_ending_card_delete_modal_title": "Törli a befejező kártyát?",
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy utókövetésben. Először távolítsa el az utókövetésből.",
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy követésben. Először távolítsa el a követésből.",
"follow_ups_include_hidden_fields": "Rejtett mezők értékeinek felvétele",
"follow_ups_include_variables": "Változó értékeinek felvétele",
"follow_ups_item_ending_tag": "Befejezések",
@@ -1490,21 +1468,21 @@
"follow_ups_modal_action_to_description": "Az az e-mail-cím, ahova az e-mail elküldésre kerül",
"follow_ups_modal_action_to_label": "Címzett",
"follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt",
"follow_ups_modal_create_heading": "Új utókövetés létrehozása",
"follow_ups_modal_created_successfull_toast": "Az utókövetés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_modal_edit_heading": "Az utókövetés szerkesztése",
"follow_ups_modal_edit_no_id": "Nincs kérdőív-utókövetési azonosító megadva, nem lehet frissíteni a kérdőív utókövetését",
"follow_ups_modal_name_label": "Utókövetés neve",
"follow_ups_modal_name_placeholder": "Az utókövetés elnevezése",
"follow_ups_modal_create_heading": "Új követés létrehozása",
"follow_ups_modal_created_successfull_toast": "A követés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_modal_edit_heading": "A követés szerkesztése",
"follow_ups_modal_edit_no_id": "Nincs kérdőívkövetési azonosító megadva, nem lehet frissíteni a kérdőívkövetést",
"follow_ups_modal_name_label": "Követés neve",
"follow_ups_modal_name_placeholder": "A követés elnevezése",
"follow_ups_modal_subheading": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak",
"follow_ups_modal_trigger_description": "Mikor kell ezt az utókövetést aktiválni?",
"follow_ups_modal_trigger_description": "Mikor kell ezt a követést aktiválni?",
"follow_ups_modal_trigger_label": "Aktiváló",
"follow_ups_modal_trigger_type_ending": "A válaszadó egy adott befejezést lát",
"follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ",
"follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát",
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
"follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új utókövetés",
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új követés",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont",
"heading": "Címsor",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
"spam_protection_threshold_heading": "Válasz küszöbszintje",
"shrink_preview": "Előnézet összecsukása",
"star": "Csillag",
"starts_with": "Ezzel kezdődik",
"state": "Állapot",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
"subheading": "Alcím",
"subtract": "Kivonás -",
"survey_closed_message_heading_required": "Címsor hozzáadása az egyéni kérdőív záró üzenetéhez.",
"survey_completed_heading": "A kérdőív kitöltve",
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
"survey_placement": "Kérdőív elhelyezése",
"survey_preview": "Kérdőív előnézete 👀",
"survey_styling": "Kérdőív stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
@@ -2789,8 +2764,8 @@
"evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…",
"evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?",
"evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…",
"fake_door_follow_up_description": "Utókövetés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
"fake_door_follow_up_name": "„Fake door” utókövetés",
"fake_door_follow_up_description": "Követés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
"fake_door_follow_up_name": "„Fake door” követés",
"fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?",
"fake_door_follow_up_question_1_lower_label": "Nem fontos",
"fake_door_follow_up_question_1_upper_label": "Nagyon fontos",
@@ -2799,7 +2774,7 @@
"fake_door_follow_up_question_2_choice_3": "3. szempont",
"fake_door_follow_up_question_2_choice_4": "4. szempont",
"fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?",
"feature_chaser_description": "Utókövetés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
"feature_chaser_description": "Követés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
"feature_chaser_name": "Funkcióvadász",
"feature_chaser_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?",
"feature_chaser_question_1_lower_label": "Nem fontos",
-25
View File
@@ -294,7 +294,6 @@
"new": "新規",
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
"next": "次へ",
"no_actions_found": "アクションが見つかりません",
"no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
"please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー",
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
@@ -1073,25 +1071,6 @@
"enterprise_features": "エンタープライズ機能",
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
"license_feature_access_control": "アクセス制御(RBAC",
"license_feature_audit_logs": "監査ログ",
"license_feature_contacts": "連絡先とセグメント",
"license_feature_projects": "ワークスペース",
"license_feature_quotas": "クォータ",
"license_feature_remove_branding": "ブランディングの削除",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "スパム保護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "二要素認証",
"license_feature_whitelabel": "ホワイトラベルメール",
"license_features_table_access": "アクセス",
"license_features_table_description": "このインスタンスで現在利用可能なエンタープライズ機能と制限。",
"license_features_table_disabled": "無効",
"license_features_table_enabled": "有効",
"license_features_table_feature": "機能",
"license_features_table_title": "ライセンス機能",
"license_features_table_unlimited": "無制限",
"license_features_table_value": "値",
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
"license_status": "ライセンスステータス",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
"everyone": "全員",
"expand_preview": "プレビューを展開",
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。",
"fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
"spam_protection_threshold_heading": "回答のしきい値",
"shrink_preview": "プレビューを縮小",
"star": "星",
"starts_with": "で始まる",
"state": "都道府県",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
"subheading": "サブ見出し",
"subtract": "減算 -",
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
"survey_completed_heading": "フォームが完了しました",
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定",
"survey_placement": "フォームの配置",
"survey_preview": "アンケートプレビュー 👀",
"survey_styling": "フォームのスタイル",
"survey_trigger": "フォームのトリガー",
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "Nieuw",
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
"next": "Volgende",
"no_actions_found": "Geen acties gevonden",
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
"no_code": "Geen code",
"no_files_uploaded": "Er zijn geen bestanden geüpload",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
"please_upgrade_your_plan": "Upgrade je abonnement",
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
"preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Enterprise-functies",
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
"license_feature_access_control": "Toegangscontrole (RBAC)",
"license_feature_audit_logs": "Auditlogboeken",
"license_feature_contacts": "Contacten & Segmenten",
"license_feature_projects": "Werkruimtes",
"license_feature_quotas": "Quota's",
"license_feature_remove_branding": "Branding verwijderen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spambescherming",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tweefactorauthenticatie",
"license_feature_whitelabel": "Whitelabel-e-mails",
"license_features_table_access": "Toegang",
"license_features_table_description": "Enterprise-functies en limieten die momenteel beschikbaar zijn voor deze instantie.",
"license_features_table_disabled": "Uitgeschakeld",
"license_features_table_enabled": "Ingeschakeld",
"license_features_table_feature": "Functie",
"license_features_table_title": "Gelicentieerde Functies",
"license_features_table_unlimited": "Onbeperkt",
"license_features_table_value": "Waarde",
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
"license_status": "Licentiestatus",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Fout bij het opslaan van wijzigingen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
"everyone": "Iedereen",
"expand_preview": "Voorbeeld uitvouwen",
"external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.",
"fallback_missing": "Terugval ontbreekt",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.",
"spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
"spam_protection_threshold_heading": "Reactiedrempel",
"shrink_preview": "Voorbeeld invouwen",
"star": "Ster",
"starts_with": "Begint met",
"state": "Staat",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
"subheading": "Ondertitel",
"subtract": "Aftrekken -",
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
"survey_completed_heading": "Enquête voltooid",
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen",
"survey_placement": "Enquête plaatsing",
"survey_preview": "Enquêtevoorbeeld 👀",
"survey_styling": "Vorm styling",
"survey_trigger": "Enquêtetrigger",
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Nee, dank je!",
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
"preview_survey_question_open_text_headline": "Wilt u nog iets anders delen?",
"preview_survey_question_open_text_headline": "Wil je nog iets delen?",
"preview_survey_question_open_text_placeholder": "Typ hier je antwoord...",
"preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.",
"preview_survey_welcome_card_headline": "Welkom!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
"coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat u wilt toevoegen?",
"follow_up_label": "Is er nog iets dat je wilt toevoegen?",
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
"generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "Novo",
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
"next": "Próximo",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Prévia",
"preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Recursos Empresariais",
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controle de acesso (RBAC)",
"license_feature_audit_logs": "Logs de auditoria",
"license_feature_contacts": "Contatos e Segmentos",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Cotas",
"license_feature_remove_branding": "Remover identidade visual",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails white-label",
"license_features_table_access": "Acesso",
"license_features_table_description": "Recursos empresariais e limites disponíveis atualmente para esta instância.",
"license_features_table_disabled": "Desabilitado",
"license_features_table_enabled": "Habilitado",
"license_features_table_feature": "Recurso",
"license_features_table_title": "Recursos Licenciados",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
"license_status": "Status da licença",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
"everyone": "Todo mundo",
"expand_preview": "Expandir prévia",
"external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.",
"fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Recolher prévia",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
"survey_completed_heading": "Pesquisa Concluída",
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa",
"survey_placement": "Posicionamento da Pesquisa",
"survey_preview": "Prévia da pesquisa 👀",
"survey_styling": "Estilização de Formulários",
"survey_trigger": "Gatilho de Pesquisa",
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Há algo mais que você gostaria de compartilhar?",
"preview_survey_question_open_text_headline": "Tem mais alguma coisa que você gostaria de compartilhar?",
"preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...",
"preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de acrescentar?",
"follow_up_label": "Há algo mais que você gostaria de adicionar?",
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?",
+1 -26
View File
@@ -294,7 +294,6 @@
"new": "Novo",
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
"next": "Seguinte",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Funcionalidades da Empresa",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controlo de acesso (RBAC)",
"license_feature_audit_logs": "Registos de auditoria",
"license_feature_contacts": "Contactos e Segmentos",
"license_feature_projects": "Áreas de trabalho",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remover marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails personalizados",
"license_features_table_access": "Acesso",
"license_features_table_description": "Funcionalidades e limites empresariais atualmente disponíveis para esta instância.",
"license_features_table_disabled": "Desativado",
"license_features_table_enabled": "Ativado",
"license_features_table_feature": "Funcionalidade",
"license_features_table_title": "Funcionalidades Licenciadas",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
"license_status": "Estado da licença",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
"everyone": "Todos",
"expand_preview": "Expandir pré-visualização",
"external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.",
"fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Reduzir pré-visualização",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Estilo definido para estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
"survey_completed_heading": "Inquérito Concluído",
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito",
"survey_placement": "Colocação do Inquérito",
"survey_preview": "Pré-visualização do questionário 👀",
"survey_styling": "Estilo do formulário",
"survey_trigger": "Desencadeador de Inquérito",
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Há mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_headline": "Mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...",
"preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "Nou",
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
"next": "Următorul",
"no_actions_found": "Nu au fost găsite acțiuni",
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
"no_code": "Fără Cod",
"no_files_uploaded": "Nu au fost încărcate fișiere",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"powered_by_formbricks": "Oferit de Formbricks",
"preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Funcții Enterprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
"license_feature_access_control": "Control acces (RBAC)",
"license_feature_audit_logs": "Jurnale de audit",
"license_feature_contacts": "Contacte și segmente",
"license_feature_projects": "Spații de lucru",
"license_feature_quotas": "Cote",
"license_feature_remove_branding": "Elimină branding-ul",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protecție spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autentificare cu doi factori",
"license_feature_whitelabel": "E-mailuri white-label",
"license_features_table_access": "Acces",
"license_features_table_description": "Funcționalități și limite enterprise disponibile în prezent pentru această instanță.",
"license_features_table_disabled": "Dezactivat",
"license_features_table_enabled": "Activat",
"license_features_table_feature": "Funcționalitate",
"license_features_table_title": "Funcționalități licențiate",
"license_features_table_unlimited": "Nelimitat",
"license_features_table_value": "Valoare",
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
"license_status": "Stare licență",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
"everyone": "Toată lumea",
"expand_preview": "Extinde previzualizarea",
"external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.",
"fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.",
"spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
"spam_protection_threshold_heading": "Pragul răspunsurilor",
"shrink_preview": "Restrânge previzualizarea",
"star": "Stea",
"starts_with": "Începe cu",
"state": "Stare",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
"subheading": "Subtitlu",
"subtract": "Scade -",
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
"survey_completed_heading": "Sondaj Completat",
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
"survey_display_settings": "Setări de afișare a sondajului",
"survey_placement": "Amplasarea sondajului",
"survey_preview": "Previzualizare chestionar 👀",
"survey_styling": "Stilizare formular",
"survey_trigger": "Declanșator sondaj",
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
"preview_survey_question_open_text_headline": "Mai aveți ceva de adăugat?",
"preview_survey_question_open_text_headline": "Mai vrei să împărtășești ceva?",
"preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...",
"preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
"preview_survey_welcome_card_headline": "Bun venit!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
"coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ați dori să adăugi?",
"follow_up_label": "Mai este ceva ce ai vrea să adaugi?",
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
"generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "Новый",
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
"next": "Далее",
"no_actions_found": "Действия не найдены",
"no_background_image_found": "Фоновое изображение не найдено.",
"no_code": "Нет кода",
"no_files_uploaded": "Файлы не были загружены",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Функции для предприятий",
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
"license_feature_access_control": "Управление доступом (RBAC)",
"license_feature_audit_logs": "Журналы аудита",
"license_feature_contacts": "Контакты и сегменты",
"license_feature_projects": "Рабочие пространства",
"license_feature_quotas": "Квоты",
"license_feature_remove_branding": "Удаление брендирования",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Защита от спама",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Двухфакторная аутентификация",
"license_feature_whitelabel": "Электронные письма без брендирования",
"license_features_table_access": "Доступ",
"license_features_table_description": "Корпоративные функции и ограничения, доступные для этого экземпляра.",
"license_features_table_disabled": "Отключено",
"license_features_table_enabled": "Включено",
"license_features_table_feature": "Функция",
"license_features_table_title": "Лицензированные функции",
"license_features_table_unlimited": "Без ограничений",
"license_features_table_value": "Значение",
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
"license_status": "Статус лицензии",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Ошибка при сохранении изменений",
"even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).",
"everyone": "Все",
"expand_preview": "Развернуть предпросмотр",
"external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.",
"fallback_missing": "Запасное значение отсутствует",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
"spam_protection_threshold_heading": "Порог ответа",
"shrink_preview": "Свернуть предпросмотр",
"star": "Звезда",
"starts_with": "Начинается с",
"state": "Состояние",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
"subheading": "Подзаголовок",
"subtract": "Вычесть -",
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
"survey_completed_heading": "Опрос завершён",
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
"survey_display_settings": "Настройки отображения опроса",
"survey_placement": "Размещение опроса",
"survey_preview": "Предпросмотр опроса 👀",
"survey_styling": "Оформление формы",
"survey_trigger": "Триггер опроса",
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Нет, спасибо!",
"preview_survey_question_2_headline": "Хотите быть в курсе событий?",
"preview_survey_question_2_subheader": "Это пример описания.",
"preview_survey_question_open_text_headline": "Хотите ли вы чем-то ещё поделиться?",
"preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?",
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
"preview_survey_welcome_card_headline": "Добро пожаловать!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хотите ли вы что-нибудь добавить?",
"follow_up_label": "Хочешь что-то ещё добавить?",
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
"generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "Ny",
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
"next": "Nästa",
"no_actions_found": "Inga åtgärder hittades",
"no_background_image_found": "Ingen bakgrundsbild hittades.",
"no_code": "Ingen kod",
"no_files_uploaded": "Inga filer laddades upp",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
"powered_by_formbricks": "Drivs av Formbricks",
"preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy",
@@ -1073,25 +1071,6 @@
"enterprise_features": "Enterprise-funktioner",
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
"license_feature_access_control": "Åtkomstkontroll (RBAC)",
"license_feature_audit_logs": "Granskningsloggar",
"license_feature_contacts": "Kontakter & Segment",
"license_feature_projects": "Arbetsytor",
"license_feature_quotas": "Kvoter",
"license_feature_remove_branding": "Ta bort varumärkning",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Skräppostskydd",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tvåfaktorsautentisering",
"license_feature_whitelabel": "White-label-mejl",
"license_features_table_access": "Åtkomst",
"license_features_table_description": "Företagsfunktioner och begränsningar som för närvarande är tillgängliga för den här instansen.",
"license_features_table_disabled": "Inaktiverad",
"license_features_table_enabled": "Aktiverad",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Licensierade funktioner",
"license_features_table_unlimited": "Obegränsad",
"license_features_table_value": "Värde",
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
"license_status": "Licensstatus",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "Fel vid sparande av ändringar",
"even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).",
"everyone": "Alla",
"expand_preview": "Expandera förhandsgranskning",
"external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
"fallback_missing": "Reservvärde saknas",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.",
"spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.",
"spam_protection_threshold_heading": "Svarströskel",
"shrink_preview": "Minimera förhandsgranskning",
"star": "Stjärna",
"starts_with": "Börjar med",
"state": "Delstat",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "Styling inställd på temastil",
"subheading": "Underrubrik",
"subtract": "Subtrahera -",
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
"survey_completed_heading": "Enkät slutförd",
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
"survey_display_settings": "Visningsinställningar för enkät",
"survey_placement": "Enkätplacering",
"survey_preview": "Enkätförhandsgranskning 👀",
"survey_styling": "Formulärstil",
"survey_trigger": "Enkätutlösare",
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Nej, tack!",
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
"preview_survey_question_open_text_headline": "Finns det något annat du vill dela med dig av?",
"preview_survey_question_open_text_headline": "Något mer du vill dela med dig av?",
"preview_survey_question_open_text_placeholder": "Skriv ditt svar här...",
"preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.",
"preview_survey_welcome_card_headline": "Välkommen!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
"coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Finns det något annat du vill lägga till?",
"follow_up_label": "Är det något mer du vill lägga till?",
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "新建",
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
"next": "下一步",
"no_actions_found": "未找到操作",
"no_background_image_found": "未找到 背景 图片。",
"no_code": "无代码",
"no_files_uploaded": "没有 文件 被 上传",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "请选择至少 一个调查",
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
"please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览",
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
@@ -1073,25 +1071,6 @@
"enterprise_features": "企业 功能",
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
"license_feature_access_control": "访问控制(RBAC",
"license_feature_audit_logs": "审计日志",
"license_feature_contacts": "联系人与细分",
"license_feature_projects": "工作空间",
"license_feature_quotas": "配额",
"license_feature_remove_branding": "移除品牌标识",
"license_feature_saml": "SAML 单点登录",
"license_feature_spam_protection": "垃圾信息防护",
"license_feature_sso": "OIDC 单点登录",
"license_feature_two_factor_auth": "双因素认证",
"license_feature_whitelabel": "白标电子邮件",
"license_features_table_access": "访问权限",
"license_features_table_description": "此实例当前可用的企业功能和限制。",
"license_features_table_disabled": "已禁用",
"license_features_table_enabled": "已启用",
"license_features_table_feature": "功能",
"license_features_table_title": "许可功能",
"license_features_table_unlimited": "无限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
"license_status": "许可证状态",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
"everyone": "所有 人",
"expand_preview": "展开预览",
"external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。",
"fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
"spam_protection_threshold_heading": "响应 阈值",
"shrink_preview": "收起预览",
"star": "星",
"starts_with": "以...开始",
"state": "状态",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "样式 设置 为 主题 风格",
"subheading": "子标题",
"subtract": "减 -",
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
"survey_completed_heading": "调查 完成",
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
"survey_display_settings": "调查显示设置",
"survey_placement": "调查 放置",
"survey_preview": "问卷预览 👀",
"survey_styling": "表单 样式",
"survey_trigger": "调查 触发",
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_question_2_subheader": "这是一个示例描述。",
"preview_survey_question_open_text_headline": "还有其他想分享的内容吗?",
"preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?",
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
"preview_survey_welcome_card_headline": "欢迎!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!",
"follow_up_label": "还有其他想补充的内容吗?",
"follow_up_label": "还有其他想补充的吗?",
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
"generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?",
+2 -27
View File
@@ -294,7 +294,6 @@
"new": "新增",
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
"next": "下一步",
"no_actions_found": "找不到動作",
"no_background_image_found": "找不到背景圖片。",
"no_code": "無程式碼",
"no_files_uploaded": "沒有上傳任何檔案",
@@ -340,7 +339,6 @@
"please_select_at_least_one_survey": "請選擇至少一個問卷",
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
"please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽",
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
@@ -1073,25 +1071,6 @@
"enterprise_features": "企業版功能",
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
"license_feature_access_control": "存取控制 (RBAC)",
"license_feature_audit_logs": "稽核日誌",
"license_feature_contacts": "聯絡人與區隔",
"license_feature_projects": "工作區",
"license_feature_quotas": "配額",
"license_feature_remove_branding": "移除品牌標識",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "垃圾訊息防護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "雙重驗證",
"license_feature_whitelabel": "白標電子郵件",
"license_features_table_access": "存取權限",
"license_features_table_description": "此執行個體目前可使用的企業功能與限制。",
"license_features_table_disabled": "已停用",
"license_features_table_enabled": "已啟用",
"license_features_table_feature": "功能",
"license_features_table_title": "授權功能",
"license_features_table_unlimited": "無限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
"license_status": "授權狀態",
@@ -1451,7 +1430,6 @@
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
"everyone": "所有人",
"expand_preview": "展開預覽",
"external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。",
"fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1711,7 +1689,6 @@
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
"spam_protection_threshold_heading": "回應閾值",
"shrink_preview": "收合預覽",
"star": "星形",
"starts_with": "開頭為",
"state": "州/省",
@@ -1721,12 +1698,10 @@
"styling_set_to_theme_styles": "樣式設定為主題樣式",
"subheading": "副標題",
"subtract": "減 -",
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
"survey_completed_heading": "問卷已完成",
"survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定",
"survey_placement": "問卷位置",
"survey_preview": "問卷預覽 👀",
"survey_styling": "表單樣式設定",
"survey_trigger": "問卷觸發器",
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
@@ -3077,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_question_2_subheader": "這是一個範例說明。",
"preview_survey_question_open_text_headline": "還有其他想分享的嗎?",
"preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎",
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
"preview_survey_welcome_card_headline": "歡迎!",
@@ -3332,7 +3307,7 @@
"workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!",
"follow_up_label": "還有其他想補充的嗎?",
"follow_up_label": "還有什麼想補充的嗎",
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
"generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?",
@@ -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>
@@ -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>;
}
@@ -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}
@@ -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"}>
@@ -2,12 +2,12 @@ import { getServerSession } from "next-auth";
import { TEnvironment } from "@formbricks/types/environment";
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";
@@ -53,7 +53,7 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
}
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const locale = user.locale ?? DEFAULT_LOCALE;
const locale = await findMatchingLocale();
return (
<ActivityTimeline
@@ -1,6 +1,5 @@
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";
@@ -10,7 +9,6 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const locale = await getLocale();
const [contact, attributesWithKeyInfo] = await Promise.all([
getContact(contactId),
getContactAttributesWithKeyInfo(contactId),
@@ -45,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,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} />;
},
};
@@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({
label: t("common.contacts"),
href: `/environments/${environmentId}/contacts`,
},
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
{
id: "segments",
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -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,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 { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
@@ -11,8 +11,7 @@ interface SegmentActivityTabProps {
}
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const { activeSurveys, inactiveSurveys } = currentSegment;
@@ -44,13 +43,13 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<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>
@@ -133,10 +133,6 @@ export function SegmentSettings({
return true;
}
if (segment.filters.length === 0) {
return true;
}
// parse the filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
@@ -1,16 +1,12 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { TFunction } from "i18next";
import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
export const generateSegmentTableColumns = (
t: TFunction,
locale: string
): ColumnDef<TSegmentWithSurveyNames>[] => {
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
id: "title",
accessorKey: "title",
@@ -37,7 +33,11 @@ export const generateSegmentTableColumns = (
accessorKey: "updatedAt",
header: t("common.updated_at"),
cell: ({ row }) => {
return <span className="text-sm text-slate-900">{timeSinceDate(row.original.updatedAt, locale)}</span>;
return (
<span className="text-sm text-slate-900">
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
</span>
);
},
};
@@ -47,13 +47,7 @@ export const generateSegmentTableColumns = (
header: t("common.created_at"),
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">
{formatDateForDisplay(row.original.createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
);
},
};
@@ -1,12 +1,10 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { EditSegmentModal } from "./edit-segment-modal";
type TSegmentTableDataRowProps = {
@@ -26,8 +24,6 @@ export const SegmentTableDataRow = ({
}: TSegmentTableDataRowProps) => {
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
const { i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
return (
<>
@@ -50,16 +46,14 @@ export const SegmentTableDataRow = ({
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(updatedAt, locale)}</div>
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(updatedAt, {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
</div>
</button>
@@ -22,13 +22,12 @@ export function SegmentTable({
isContactsEnabled,
isReadOnly,
}: SegmentTableUpdatedProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns(t, locale);
}, [locale, t]);
return generateSegmentTableColumns(t);
}, []);
const table = useReactTable({
data: segments,
@@ -1,73 +0,0 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
const validFilters = [
{
id: createId(),
connector: null,
resource: {
id: createId(),
root: {
type: "attribute" as const,
contactAttributeKey: "email",
},
value: "user@example.com",
qualifier: {
operator: "equals" as const,
},
},
},
];
describe("segment schema validation", () => {
test("keeps base segment filters compatible with empty arrays", () => {
const result = ZSegmentFilters.safeParse([]);
expect(result.success).toBe(true);
});
test("requires at least one filter when creating a segment", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: [],
surveyId: "surveyId",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment creation with a valid filter", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: validFilters,
surveyId: "surveyId",
});
expect(result.success).toBe(true);
});
test("requires at least one filter when updating a segment", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment updates with a valid filter", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: validFilters,
});
expect(result.success).toBe(true);
});
});
@@ -75,7 +75,6 @@ export async function PreviewEmailTemplate({
survey,
surveyUrl,
styling,
locale,
t,
}: PreviewEmailTemplateProps): Promise<React.JSX.Element> {
const url = `${surveyUrl}?preview=true`;
@@ -86,20 +85,8 @@ export async function PreviewEmailTemplate({
const questions = getElementsFromBlocks(survey.blocks);
const firstQuestion = questions[0];
const headline = parseRecallInfo(
getLocalizedValue(firstQuestion.headline, defaultLanguageCode),
undefined,
undefined,
false,
locale
);
const subheader = parseRecallInfo(
getLocalizedValue(firstQuestion.subheader, defaultLanguageCode),
undefined,
undefined,
false,
locale
);
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
switch (firstQuestion.type) {
@@ -108,7 +95,7 @@ export async function PreviewEmailTemplate({
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<ElementHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="border-input-border-color rounded-custom mt-4 block h-20 w-full border border-solid bg-slate-50" />
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.Consent:
@@ -137,7 +124,7 @@ export async function PreviewEmailTemplate({
{t("emails.accept")}
</EmailButton>
</Container>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.NPS:
@@ -185,7 +172,7 @@ export async function PreviewEmailTemplate({
</Row>
</Section>
</Container>
<EmailFooter t={t} />
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
@@ -206,7 +193,7 @@ export async function PreviewEmailTemplate({
</EmailButton>
</Container>
)}
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
}
@@ -259,7 +246,7 @@ export async function PreviewEmailTemplate({
</Row>
</Section>
</Container>
<EmailFooter t={t} />
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
@@ -276,7 +263,7 @@ export async function PreviewEmailTemplate({
</Section>
))}
</Container>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.Ranking:
@@ -292,7 +279,7 @@ export async function PreviewEmailTemplate({
</Section>
))}
</Container>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
@@ -309,7 +296,7 @@ export async function PreviewEmailTemplate({
</Link>
))}
</Container>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.PictureSelection:
@@ -335,7 +322,7 @@ export async function PreviewEmailTemplate({
)
)}
</Section>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.Cal:
@@ -351,7 +338,7 @@ export async function PreviewEmailTemplate({
{t("emails.schedule_your_meeting")}
</EmailButton>
</Container>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.Date:
@@ -364,7 +351,7 @@ export async function PreviewEmailTemplate({
{t("emails.select_a_date")}
</Text>
</Section>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.Matrix:
@@ -405,7 +392,7 @@ export async function PreviewEmailTemplate({
})}
</Section>
</Container>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyElementTypeEnum.Address:
@@ -420,7 +407,7 @@ export async function PreviewEmailTemplate({
{label}
</Section>
))}
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
@@ -434,7 +421,7 @@ export async function PreviewEmailTemplate({
<Text className="text-slate-400">{t("emails.click_or_drag_to_upload_files")}</Text>
</Container>
</Section>
<EmailFooter t={t} />
<EmailFooter />
</EmailTemplateWrapper>
);
}
@@ -490,11 +477,11 @@ function EmailTemplateWrapper({
);
}
function EmailFooter({ t }: { t: TFunction }): React.JSX.Element {
function EmailFooter(): React.JSX.Element {
return (
<Container className="m-auto mt-8 text-center">
<Link className="text-signature-color text-xs" href="https://formbricks.com/" target="_blank">
{t("common.powered_by_formbricks")}
Powered by Formbricks
</Link>
</Container>
);
@@ -4,7 +4,7 @@ import { Webhook } from "@prisma/client";
import { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { convertDateTimeStringShort } from "@/lib/time";
import { Label } from "@/modules/ui/components/label";
interface ActivityTabProps {
@@ -37,8 +37,7 @@ const convertTriggerIdToName = (triggerId: string, t: TFunction): string => {
};
export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
return (
<div className="grid grid-cols-3 pb-2">
<div className="col-span-2 space-y-4 pr-6">
@@ -82,11 +81,15 @@ export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(webhook.createdAt, locale)}</p>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(webhook.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(webhook.updatedAt, locale)}</p>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
</p>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import { Webhook } from "@prisma/client";
import { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
@@ -54,10 +55,16 @@ const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
}
};
export const WebhookRowData = ({ webhook, surveys }: { webhook: Webhook; surveys: TSurvey[] }) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
export const WebhookRowData = ({
webhook,
surveys,
locale,
}: {
webhook: Webhook;
surveys: TSurvey[];
locale: TUserLocale;
}) => {
const { t } = useTranslation();
return (
<div className="mt-2 grid h-auto grid-cols-12 content-center rounded-lg py-2 hover:bg-slate-100">
<div className="col-span-3 flex items-center truncate pl-6 text-sm">
@@ -84,7 +91,7 @@ export const WebhookRowData = ({ webhook, surveys }: { webhook: Webhook; surveys
{renderSelectedTriggersText(webhook, t)}
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(webhook.updatedAt.toString(), locale)}
{timeSince(webhook.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>
</div>
@@ -1,4 +1,5 @@
import { getSurveys } from "@/lib/survey/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { AddWebhookButton } from "@/modules/integrations/webhooks/components/add-webhook-button";
@@ -22,6 +23,7 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
]);
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const locale = await findMatchingLocale();
return (
<PageContentWrapper>
@@ -30,7 +32,7 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} locale={locale} />
))}
</WebhookTable>
</PageContentWrapper>
@@ -1,7 +1,7 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUserLocale } from "@/lib/user/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
@@ -12,13 +12,11 @@ import { ApiKeyList } from "./components/api-key-list";
export const APIKeysPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const locale = await findMatchingLocale();
const { currentUserMembership, organization, session } = await getEnvironmentAuth(params.environmentId);
const { currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const [projects, locale] = await Promise.all([
getProjectsByOrganizationId(organization.id),
getUserLocale(session.user.id),
]);
const projects = await getProjectsByOrganizationId(organization.id);
const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
@@ -39,7 +37,7 @@ export const APIKeysPage = async (props: { params: Promise<{ environmentId: stri
description={t("environments.settings.api_keys.api_keys_description")}>
<ApiKeyList
organizationId={organization.id}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
isReadOnly={!canAccessApiKeys}
projects={projects}
/>
@@ -39,8 +39,7 @@ export const MembersInfo = ({
isUserManagementDisabledFromUi,
}: MembersInfoProps) => {
const allMembers = [...members, ...invites];
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const getMembershipBadge = (member: TMember | TInvite) => {
if (isInvitee(member)) {
@@ -49,7 +48,7 @@ export const MembersInfo = ({
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
date: formatDateWithOrdinal(member.expiresAt, locale),
date: formatDateWithOrdinal(member.expiresAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>
@@ -89,7 +89,7 @@ export const InviteMemberModal = ({
<DialogDescription>{t("environments.settings.teams.invite_member_description")}</DialogDescription>
</DialogHeader>
<DialogBody className="flex min-h-0 flex-col gap-6 overflow-y-auto">
<DialogBody className="flex flex-col gap-6" unconstrained>
{!showTeamAdminRestrictions && (
<TabToggle
id="type"
@@ -4,9 +4,9 @@ import Link from "next/link";
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getActionClasses } from "@/lib/actionClass/service";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironments } from "@/lib/environment/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 { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
@@ -21,15 +21,17 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
const t = await getTranslate();
const { environmentId } = await params;
const { environment, isReadOnly, session } = await getEnvironmentAuth(environmentId);
const { environment, isReadOnly } = await getEnvironmentAuth(environmentId);
const [environments, actionClasses, locale] = await Promise.all([
const [environments, actionClasses] = await Promise.all([
getEnvironments(environment.projectId),
getActionClasses(environmentId),
getUserLocale(session.user.id),
]);
const otherEnvironment = environments.filter((env) => env.id !== environmentId)[0];
const otherEnvActionClasses = otherEnvironment ? await getActionClasses(otherEnvironment.id) : [];
const [otherEnvActionClasses, locale] = await Promise.all([
otherEnvironment ? getActionClasses(otherEnvironment.id) : Promise.resolve([]),
findMatchingLocale(),
]);
return (
<PageContentWrapper>
@@ -87,7 +89,7 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
environmentId={environmentId}
actionClasses={actionClasses}
isReadOnly={isReadOnly}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>
@@ -5,7 +5,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ACTION_TYPE_ICON_LOOKUP } from "@/modules/projects/settings/(setup)/app-connection/utils";
@@ -32,8 +32,7 @@ export const ActionActivityTab = ({
environment,
isReadOnly,
}: ActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const [activeSurveys, setActiveSurveys] = useState<string[] | undefined>();
const [inactiveSurveys, setInactiveSurveys] = useState<string[] | undefined>();
const [loading, setLoading] = useState(true);
@@ -137,12 +136,16 @@ export const ActionActivityTab = ({
</div>
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(actionClass.createdAt, locale)}</p>
<Label className="text-xs font-normal text-slate-500">Created on</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(actionClass.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(actionClass.updatedAt, locale)}</p>
<Label className="text-xs font-normal text-slate-500">Last updated</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(actionClass.updatedAt?.toString())}
</p>
</div>
<div>
<Label className="block text-xs font-normal text-slate-500">Type</Label>
@@ -1,6 +1,5 @@
"use client";
import { useTranslation } from "react-i18next";
import { type JSX, useState } from "react";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
@@ -25,7 +24,6 @@ export const ActionClassesTable = ({
otherEnvActionClasses,
otherEnvironment,
}: ActionClassesTableProps) => {
const { t } = useTranslation();
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
@@ -58,7 +56,7 @@ export const ActionClassesTable = ({
))
) : (
<div className="py-8 text-center">
<span className="text-sm text-slate-500">{t("common.no_actions_found")}</span>
<span className="text-sm text-slate-500">No actions found</span>
</div>
)}
</div>
@@ -38,7 +38,6 @@ export const ActionSettingsTab = ({
setOpen,
isReadOnly,
}: ActionSettingsTabProps) => {
const actionDocsHref = "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions";
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
const router = useRouter();
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
@@ -147,7 +146,7 @@ export const ActionSettingsTab = ({
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
{isReadOnly ? null : (
{!isReadOnly ? (
<Button
type="button"
variant="destructive"
@@ -156,22 +155,22 @@ export const ActionSettingsTab = ({
<TrashIcon />
{t("common.delete")}
</Button>
)}
) : null}
<Button variant="secondary" asChild>
<Link href={actionDocsHref} target="_blank">
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
{t("common.read_docs")}
</Link>
</Button>
</div>
{isReadOnly ? null : (
{!isReadOnly ? (
<div className="flex space-x-2">
<Button type="submit" loading={isUpdatingAction}>
{t("common.save_changes")}
</Button>
</div>
)}
) : null}
</div>
</form>
</FormProvider>
@@ -11,6 +11,8 @@ import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
interface IDateElementFormProps {
localSurvey: TSurvey;
@@ -25,6 +27,21 @@ interface IDateElementFormProps {
isExternalUrlsAllowed?: boolean;
}
const dateOptions = [
{
value: "M-d-y",
label: "MM-DD-YYYY",
},
{
value: "d-M-y",
label: "DD-MM-YYYY",
},
{
value: "y-M-d",
label: "YYYY-MM-DD",
},
];
export const DateElementForm = ({
element,
elementIdx,
@@ -98,6 +115,19 @@ export const DateElementForm = ({
)}
</div>
<div className="mt-3">
<Label htmlFor="elementType">{t("environments.surveys.edit.date_format")}</Label>
<div className="mt-2 flex items-center">
<OptionsSwitch
options={dateOptions}
currentOption={element.format}
handleOptionChange={(value: string) =>
updateElement(elementIdx, { format: value as "M-d-y" | "d-M-y" | "y-M-d" })
}
/>
</div>
</div>
<ValidationRulesEditor
elementType={element.type}
validation={element.validation}
@@ -4,7 +4,7 @@
import "@testing-library/jest-dom/vitest";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { z } from "zod";
import { z } from "zod";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import {
createActionClassZodResolver,
@@ -316,6 +316,10 @@ describe("validation.isEndingCardValid", () => {
const card = { ...baseRedirectUrlCard, label: " " };
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
});
// test("should return false for redirectUrl card if label is undefined", () => {
// const card = { ...baseRedirectUrlCard, label: undefined };
// expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
// });
});
describe("validation.validateElement", () => {
@@ -1025,66 +1029,6 @@ describe("validation.isSurveyValid", () => {
expect(toast.error).not.toHaveBeenCalled();
});
test("should return false and toast error if a link survey has an empty custom survey closed message heading", () => {
const surveyWithEmptyClosedMessageHeading = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "",
subheading: "Closed for now",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, "en", mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
});
test("should return false and toast error if a link survey has a whitespace-only custom survey closed message heading", () => {
const surveyWithWhitespaceClosedMessageHeading = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: " ",
subheading: "",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, "en", mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
});
test("should return true if a link survey has a custom survey closed message heading and no subheading", () => {
const surveyWithHeadingOnlyClosedMessage = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "Survey closed",
subheading: "",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, "en", mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
test("should return true if a link survey has a custom survey closed message heading and subheading", () => {
const surveyWithClosedMessageContent = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "Survey closed",
subheading: "Thanks for your interest",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithClosedMessageContent, "en", mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
describe("App Survey Segment Validation", () => {
test("should return false and toast error for app survey with invalid segment filters", () => {
const surveyWithInvalidSegment = {
@@ -151,7 +151,11 @@ export const validationRules = {
for (const field of fieldsToValidate) {
const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field];
if (fieldValue?.[defaultLanguageCode] !== undefined && fieldValue[defaultLanguageCode].trim() !== "") {
if (
fieldValue &&
typeof fieldValue[defaultLanguageCode] !== "undefined" &&
fieldValue[defaultLanguageCode].trim() !== ""
) {
isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages);
}
}
@@ -199,16 +203,6 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
};
const hasValidSurveyClosedMessageHeading = (survey: TSurvey): boolean => {
if (survey.type !== "link" || !survey.surveyClosedMessage) {
return true;
}
const heading = survey.surveyClosedMessage.heading?.trim() ?? "";
return heading.length > 0;
};
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages);
};
@@ -292,10 +286,5 @@ export const isSurveyValid = (
}
}
if (!hasValidSurveyClosedMessageHeading(survey)) {
toast.error(t("environments.surveys.edit.survey_closed_message_heading_required"));
return false;
}
return true;
};
@@ -56,11 +56,26 @@ export const CustomScriptsInjector = ({
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content
// Copy inline script content with error handling
if (script.textContent) {
newScript.textContent = script.textContent;
// Wrap inline scripts in try-catch to prevent undefined variable errors
const wrappedContent = `
(function() {
try {
${script.textContent}
} catch (error) {
console.warn("[Formbricks] Custom script error:", error);
}
})();
`;
newScript.textContent = wrappedContent;
}
// Add error handler for external scripts
newScript.onerror = (error) => {
console.warn("[Formbricks] Custom script failed to load:", error);
};
document.head.appendChild(newScript);
});
@@ -1,6 +1,5 @@
import { Project, SurveyType } from "@prisma/client";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
@@ -45,7 +44,6 @@ export const LinkSurveyWrapper = ({
isBrandingEnabled,
dir = "auto",
}: LinkSurveyWrapperProps) => {
const { t } = useTranslation();
//for embedded survey strip away all surrounding css
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
@@ -90,7 +88,7 @@ export const LinkSurveyWrapper = ({
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
{t("environments.surveys.edit.survey_preview")}
Survey Preview 👀
<ResetProgressButton onClick={handleResetSurvey} />
</div>
)}
@@ -5,8 +5,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { timeSince } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { convertDateString, timeSince } from "@/lib/time";
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
@@ -83,7 +82,7 @@ export const SurveyCard = ({
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatDateForDisplay(survey.createdAt, locale)}
{convertDateString(survey.createdAt.toString())}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.updatedAt.toString(), locale)}
@@ -146,7 +146,7 @@ export const SurveysList = ({
<div>
<div className="flex-col space-y-3" ref={parent}>
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
<div className="col-span-2 place-self-start">{t("common.name")}</div>
<div className="col-span-2 place-self-start">Name</div>
<div className="col-span-1">{t("common.status")}</div>
<div className="col-span-1">{t("common.responses")}</div>
<div className="col-span-1">{t("common.type")}</div>
@@ -163,9 +163,6 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
}
} else {
setIsMultiLanguageActivated(true);
if (!open) {
setOpen(true);
}
}
};
@@ -50,11 +50,7 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
formbricks.track(&quot;{watch("key")}&quot;)
</span>{" "}
{t("environments.actions.in_your_code_read_more_in_our")}{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
target="_blank"
rel="noreferrer"
className="underline">
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
{t("common.docs")}
</a>
{"."}
@@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import { ArrowUpFromLineIcon } from "lucide-react";
import React from "react";
import { TAllowedFileExtension } from "@formbricks/types/storage";
@@ -34,7 +33,6 @@ export const Uploader = ({
disabled = false,
isStorageConfigured = true,
}: UploaderProps) => {
const { t } = useTranslation();
return (
<label // NOSONAR - This is a label for a file input, we need the onClick to trigger storage not configured toast
htmlFor={`${id}-${name}`}
@@ -84,7 +82,7 @@ export const Uploader = ({
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500", uploadMore && "text-xs")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>
<span className="font-semibold">Click or drag to upload files.</span>
</p>
<input
data-testid="upload-file-input"
@@ -18,7 +18,6 @@ interface SegmentDetailProps {
onSegmentLoad: (surveyId: string, segmentId: string) => Promise<TSurvey>;
surveyId: string;
currentSegment: TSegment;
locale: string;
}
const SegmentDetail = ({
@@ -29,7 +28,6 @@ const SegmentDetail = ({
onSegmentLoad,
surveyId,
currentSegment,
locale,
}: SegmentDetailProps) => {
const [isLoading, setIsLoading] = useState(false);
const handleLoadNewSegment = async (segmentId: string) => {
@@ -108,11 +106,11 @@ const SegmentDetail = ({
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(segment.updatedAt, locale)}</div>
<div className="ph-no-capture text-slate-900">{timeSinceDate(segment.updatedAt)}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{formatDate(segment.createdAt, locale)}</div>
<div className="ph-no-capture text-slate-900">{formatDate(segment.createdAt)}</div>
</div>
</button>
);
@@ -142,8 +140,7 @@ export const LoadSegmentModal = ({
const handleResetState = () => {
setOpen(false);
};
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const segmentsArray = segments?.filter((segment) => !segment.isPrivate);
return (
@@ -185,7 +182,6 @@ export const LoadSegmentModal = ({
onSegmentLoad={onSegmentLoad}
surveyId={surveyId}
currentSegment={currentSegment}
locale={locale}
/>
))}
</div>
@@ -5,7 +5,6 @@ import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
interface PendingDowngradeBannerProps {
@@ -32,7 +31,7 @@ export const PendingDowngradeBanner = ({
: false;
const scheduledDowngradeDate = new Date(lastChecked.getTime() + threeDaysInMillis);
const formattedDate = formatDateForDisplay(scheduledDowngradeDate, locale, {
const formattedDate = scheduledDowngradeDate.toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
@@ -226,7 +226,7 @@ export const PreviewSurvey = ({
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
{t("common.preview")}
Preview
</p>
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton onClick={resetProgress} />
@@ -310,7 +310,7 @@ export const PreviewSurvey = ({
setIsFullScreenPreview(true);
}
}}
aria-label={isFullScreenPreview ? t("environments.surveys.edit.shrink_preview") : t("environments.surveys.edit.expand_preview")}></button>
aria-label={isFullScreenPreview ? "Shrink Preview" : "Expand Preview"}></button>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>
@@ -157,7 +157,7 @@ export const ThemeStylingPreviewSurvey = ({
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{isAppSurvey ? t("environments.surveys.edit.your_web_app") : t("common.preview")}</p>
<p>{isAppSurvey ? "Your web app" : "Preview"}</p>
<div className="flex items-center">
<ResetProgressButton onClick={resetQuestionProgress} />
+1
View File
@@ -269,6 +269,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click();
await page.getByText("Multiple languages").click();
await page.getByRole("combobox").click();
await page.getByLabel("English (en)").click();
await page.getByRole("button", { name: "Confirm" }).click();
-7
View File
@@ -45,8 +45,6 @@ const baseLoggerConfig: LoggerOptions = {
* - Both: optional pino-opentelemetry-transport for SigNoz log correlation when OTEL is configured
*/
const buildTransport = (): LoggerOptions["transport"] => {
const isEdgeRuntime = process.env.NEXT_RUNTIME === "edge";
const hasOtelEndpoint =
process.env.NEXT_RUNTIME === "nodejs" && Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
@@ -79,11 +77,6 @@ const buildTransport = (): LoggerOptions["transport"] => {
};
if (!IS_PRODUCTION) {
// Edge Runtime does not support worker_threads — skip pino-pretty to avoid crashes
if (isEdgeRuntime) {
return undefined;
}
// Development: pretty print + optional OTEL
if (hasOtelEndpoint) {
return { targets: [prettyTarget, otelTarget] };
+1 -1
View File
@@ -64,7 +64,7 @@ packages/surveys/
```bash
# packages/surveys/.env
LINGO_API_KEY=<YOUR_API_KEY>
LINGODOTDEV_API_KEY=<YOUR_API_KEY>
```
4. **Generate Translations**
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "preact/hooks";
import { useEffect } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
@@ -66,29 +66,26 @@ export function EndingCard({
</div>
);
const processAndRedirect = useCallback(
(urlString: string) => {
try {
const url = replaceRecallInfo(urlString, responseData, variablesData, languageCode);
if (url && new URL(url)) {
if (onOpenExternalURL) {
onOpenExternalURL(url);
} else {
window.top?.location.replace(url);
}
const processAndRedirect = (urlString: string) => {
try {
const url = replaceRecallInfo(urlString, responseData, variablesData);
if (url && new URL(url)) {
if (onOpenExternalURL) {
onOpenExternalURL(url);
} else {
window.top?.location.replace(url);
}
} catch (error) {
console.error("Invalid URL after recall processing:", error);
}
},
[languageCode, onOpenExternalURL, responseData, variablesData]
);
} catch (error) {
console.error("Invalid URL after recall processing:", error);
}
};
const handleSubmit = useCallback(() => {
const handleSubmit = () => {
if (!isRedirectDisabled && endingCard.type === "endScreen" && endingCard.buttonLink) {
processAndRedirect(endingCard.buttonLink);
}
}, [endingCard, isRedirectDisabled, processAndRedirect]);
};
useEffect(() => {
if (isCurrent) {
@@ -117,15 +114,7 @@ export function EndingCard({
return () => {
document.removeEventListener("keydown", handleEnter);
};
}, [
endingCard,
handleSubmit,
isCurrent,
isRedirectDisabled,
isResponseSendingFinished,
processAndRedirect,
survey.type,
]);
}, [isCurrent, isResponseSendingFinished, isRedirectDisabled, endingCard, survey.type]);
return (
<ScrollableContainer fullSizeCards={fullSizeCards}>
@@ -141,8 +130,7 @@ export function EndingCard({
headline={replaceRecallInfo(
getLocalizedValue(endingCard.headline, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
elementId="EndingCard"
/>
@@ -150,8 +138,7 @@ export function EndingCard({
subheader={replaceRecallInfo(
getLocalizedValue(endingCard.subheader, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
elementId="EndingCard"
/>
@@ -161,8 +148,7 @@ export function EndingCard({
buttonLabel={replaceRecallInfo(
getLocalizedValue(endingCard.buttonLabel, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
isLastQuestion={false}
focus={isCurrent ? autoFocusEnabled : false}
@@ -149,20 +149,14 @@ export function WelcomeCard({
) : null}
<Headline
headline={replaceRecallInfo(
getLocalizedValue(headline, languageCode),
responseData,
variablesData,
languageCode
)}
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData, variablesData)}
elementId="welcomeCard"
/>
<Subheader
subheader={replaceRecallInfo(
getLocalizedValue(subheader, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
elementId="welcomeCard"
/>
+98 -20
View File
@@ -1,6 +1,18 @@
import { describe, expect, test } from "vitest";
import { formatDateWithOrdinal, getMonthName, getOrdinalDate, isValidDateString } from "./date-time";
// Manually define getOrdinalSuffix for testing as it's not exported
// Or, if preferred, we can test it implicitly via formatDateWithOrdinal and getOrdinalDate
// For direct testing, let's replicate its logic or assume it's tested via the others.
// For this exercise, let's test what's exported and what's critical directly if possible.
// The user snippet included getOrdinalSuffix, so let's assume we can test it.
const getOrdinalSuffix = (day: number): string => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
describe("getMonthName", () => {
test("should return correct month name for en-US", () => {
expect(getMonthName(0)).toBe("January");
@@ -76,30 +88,96 @@ describe("isValidDateString", () => {
});
});
describe("formatDateWithOrdinal", () => {
const getExpectedLocaleDate = (date: Date, locale: string) =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
test("formats survey dates with locale-native en-US output", () => {
const date = new Date(2024, 0, 1);
expect(formatDateWithOrdinal(date, "en-US")).toBe(getExpectedLocaleDate(date, "en-US"));
describe("getOrdinalSuffix (helper)", () => {
test('should return "st" for 1, 21, 31', () => {
expect(getOrdinalSuffix(1)).toBe("st");
expect(getOrdinalSuffix(21)).toBe("st");
expect(getOrdinalSuffix(31)).toBe("st");
});
test("formats survey dates with locale-native fr-FR output", () => {
const date = new Date(2024, 0, 1);
expect(formatDateWithOrdinal(date, "fr-FR")).toBe(getExpectedLocaleDate(date, "fr-FR"));
test('should return "nd" for 2, 22', () => {
expect(getOrdinalSuffix(2)).toBe("nd");
expect(getOrdinalSuffix(22)).toBe("nd");
expect(getOrdinalSuffix(32)).toBe("nd"); // Test for day >= 30 leading to relevantDigits = 2
});
test("formats survey dates with locale-native de-DE output", () => {
const date = new Date(2024, 2, 20);
test('should return "rd" for 3, 23', () => {
expect(getOrdinalSuffix(3)).toBe("rd");
expect(getOrdinalSuffix(23)).toBe("rd");
expect(getOrdinalSuffix(33)).toBe("rd"); // Test for day >= 30 leading to relevantDigits = 3
});
expect(formatDateWithOrdinal(date, "de-DE")).toBe(getExpectedLocaleDate(date, "de-DE"));
test('should return "th" for 4-20, 24-30, and 11, 12, 13 variants', () => {
expect(getOrdinalSuffix(4)).toBe("th");
expect(getOrdinalSuffix(11)).toBe("th");
expect(getOrdinalSuffix(12)).toBe("th");
expect(getOrdinalSuffix(13)).toBe("th");
expect(getOrdinalSuffix(19)).toBe("th");
expect(getOrdinalSuffix(20)).toBe("th");
expect(getOrdinalSuffix(24)).toBe("th");
expect(getOrdinalSuffix(29)).toBe("th"); // Added for explicit boundary coverage
expect(getOrdinalSuffix(30)).toBe("th");
});
});
describe("formatDateWithOrdinal", () => {
test("should format date correctly for en-US", () => {
// Test with a few specific dates
// Monday, January 1st, 2024
const date1 = new Date(2024, 0, 1);
expect(formatDateWithOrdinal(date1)).toBe("Monday, January 1st, 2024");
// Wednesday, February 22nd, 2023
const date2 = new Date(2023, 1, 22);
expect(formatDateWithOrdinal(date2)).toBe("Wednesday, February 22nd, 2023");
// Sunday, March 13th, 2022
const date3 = new Date(2022, 2, 13);
expect(formatDateWithOrdinal(date3)).toBe("Sunday, March 13th, 2022");
});
test("should format date correctly for a different locale (fr-FR)", () => {
const date1 = new Date(2024, 0, 1);
// The exact output depends on Intl and Node version, it might include periods or different capitalization.
// For consistency, we'll check for key parts.
// A more robust test might involve mocking Intl.DateTimeFormat if very specific output is needed across environments.
const formattedDate1 = formatDateWithOrdinal(date1, "fr-FR");
expect(formattedDate1).toContain("lundi"); // Day of week
expect(formattedDate1).toContain("janvier"); // Month
expect(formattedDate1).toContain("1st"); // Given English-specific getOrdinalSuffix, this will be '1st'
expect(formattedDate1).toContain("2024"); // Year
// mardi 14 février 2023
const date2 = new Date(2023, 1, 14); // 14th
const formattedDate2 = formatDateWithOrdinal(date2, "fr-FR");
expect(formattedDate2).toContain("mardi");
expect(formattedDate2).toContain("février");
// French ordinals for other numbers usually don't have a special suffix like 'th' visible in the number itself
// The getOrdinalSuffix in the original code is very English-centric.
// For 'fr-FR', getOrdinalSuffix(14) -> 'th'. So it becomes '14th'. This part of the test might need adjustment
// based on how getOrdinalSuffix is supposed to behave with locales.
// Given the current getOrdinalSuffix, it will append 'th'.
expect(formattedDate2).toContain("14th");
expect(formattedDate2).toContain("2023");
});
test("should handle the 1st with French locale (specific check for 1er)", () => {
const date = new Date(2024, 0, 1); // January 1st
// The original getOrdinalSuffix is English-specific. It will produce '1st'.
// A truly internationalized getOrdinalSuffix would be needed for '1er'.
// The current formatDateWithOrdinal will use the English 'st', 'nd', 'rd', 'th'.
// This test reflects the current implementation's behavior.
expect(formatDateWithOrdinal(date, "fr-FR")).toBe("lundi, janvier 1st, 2024");
});
test("should handle other dates with French locale", () => {
const date = new Date(2024, 0, 2); // January 2nd
expect(formatDateWithOrdinal(date, "fr-FR")).toBe("mardi, janvier 2nd, 2024");
const date3 = new Date(2024, 0, 3); // January 3rd
expect(formatDateWithOrdinal(date3, "fr-FR")).toBe("mercredi, janvier 3rd, 2024");
const date4 = new Date(2024, 0, 4); // January 4th
expect(formatDateWithOrdinal(date4, "fr-FR")).toBe("jeudi, janvier 4th, 2024");
});
});
+12 -7
View File
@@ -37,11 +37,16 @@ export const isValidDateString = (value: string) => {
return !isNaN(date.getTime());
};
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
return new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
const getOrdinalSuffix = (day: number): string => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
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}`;
};
+2 -13
View File
@@ -15,10 +15,8 @@ vi.mock("./i18n", () => ({
// Mock date-time functions as they are used internally and we want to isolate recall logic
vi.mock("./date-time", () => ({
isValidDateString: (val: string) => /^\d{4}-\d{2}-\d{2}$/.test(val) || /^\d{2}-\d{2}-\d{4}$/.test(val),
formatDateWithOrdinal: vi.fn(
(date: Date) =>
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`
),
formatDateWithOrdinal: (date: Date) =>
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`,
}));
describe("replaceRecallInfo", () => {
@@ -73,15 +71,6 @@ describe("replaceRecallInfo", () => {
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
test("should pass the selected survey language to date formatting", async () => {
const { formatDateWithOrdinal } = await import("./date-time");
const text = "Registered on: #recall:registrationDate/fallback:N/A#.";
replaceRecallInfo(text, responseData, variables, "fr-FR");
expect(vi.mocked(formatDateWithOrdinal)).toHaveBeenCalledWith(expect.any(Date), "fr-FR");
});
test("should join array values with a comma and space", () => {
const text = "Tags: #recall:tags/fallback:none#.";
const expected = "Tags: beta, user.";
+5 -9
View File
@@ -29,8 +29,7 @@ const extractRecallInfo = (headline: string, id?: string): string | null => {
export const replaceRecallInfo = (
text: string,
responseData: TResponseData,
variables: TResponseVariables,
locale: string = "en-US"
variables: TResponseVariables
): string => {
let modifiedText = text;
@@ -57,7 +56,7 @@ export const replaceRecallInfo = (
// Additional value formatting if it exists
if (value) {
if (isValidDateString(value)) {
value = formatDateWithOrdinal(new Date(value), locale);
value = formatDateWithOrdinal(new Date(value));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", "); // Filters out empty values and joins with a comma
}
@@ -81,8 +80,7 @@ export const parseRecallInformation = (
modifiedQuestion.headline[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.headline, languageCode),
responseData,
variables,
languageCode
variables
);
}
if (
@@ -93,8 +91,7 @@ export const parseRecallInformation = (
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.subheader, languageCode),
responseData,
variables,
languageCode
variables
);
}
if (
@@ -106,8 +103,7 @@ export const parseRecallInformation = (
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.subheader, languageCode),
responseData,
variables,
languageCode
variables
);
}
return modifiedQuestion;
+8 -48
View File
@@ -26,56 +26,16 @@ export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES);
export type TActionClassPageUrlRule = z.infer<typeof ZActionClassPageUrlRule>;
const URL_LIKE_FILTER_RULES = new Set<TActionClassPageUrlRule>(["exactMatch", "startsWith", "notMatch"]);
const DOMAIN_HOSTNAME_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const isValidAbsoluteUrlFilterValue = (value: string): boolean => {
try {
const parsedUrl = new URL(value);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return false;
}
const isIPv6 = parsedUrl.hostname.startsWith("[") && parsedUrl.hostname.endsWith("]");
const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(parsedUrl.hostname);
return (
DOMAIN_HOSTNAME_REGEX.test(parsedUrl.hostname) || parsedUrl.hostname === "localhost" || isIPv6 || isIPv4
);
} catch {
return false;
}
};
export const isValidActionClassUrlFilterValue = (value: string, rule: TActionClassPageUrlRule): boolean => {
if (!URL_LIKE_FILTER_RULES.has(rule)) {
return true;
}
return value.startsWith("/") || isValidAbsoluteUrlFilterValue(value);
};
const ZActionClassUrlFilter = z
.object({
value: z.string().trim().min(1, {
error: "Value must contain at least 1 character",
}),
rule: ZActionClassPageUrlRule,
})
.superRefine((data, ctx) => {
if (!isValidActionClassUrlFilterValue(data.value, data.rule)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Please enter a valid URL (e.g., https://example.com)",
});
}
});
const ZActionClassNoCodeConfigBase = z.object({
type: z.enum(["click", "pageView", "exitIntent", "fiftyPercentScroll", "pageDwell"]),
urlFilters: z.array(ZActionClassUrlFilter),
urlFilters: z.array(
z.object({
value: z.string().trim().min(1, {
error: "Value must contain atleast 1 character",
}),
rule: ZActionClassPageUrlRule,
})
),
urlFiltersConnector: z.enum(["or", "and"]).optional(),
});
+2 -6
View File
@@ -333,10 +333,6 @@ export const ZSegmentFilters: z.ZodType<TBaseFilters> = z
error: "Invalid filters applied",
});
const ZRequiredSegmentFilters = ZSegmentFilters.refine((filters) => filters.length > 0, {
error: "At least one filter is required",
});
export const ZSegment = z.object({
id: z.string(),
title: z.string(),
@@ -354,7 +350,7 @@ export const ZSegmentCreateInput = z.object({
title: z.string(),
description: z.string().optional(),
isPrivate: z.boolean().prefault(true),
filters: ZRequiredSegmentFilters,
filters: ZSegmentFilters,
surveyId: z.string(),
});
@@ -371,7 +367,7 @@ export const ZSegmentUpdateInput = z
title: z.string(),
description: z.string().nullable(),
isPrivate: z.boolean().prefault(true),
filters: ZRequiredSegmentFilters,
filters: ZSegmentFilters,
surveys: z.array(z.string()),
})
.partial();