Compare commits

..

1 Commits

Author SHA1 Message Date
Balázs Úr
89bedc589a chore: change LINGO_API_KEY environment variable name 2026-03-18 13:57:12 +01:00
54 changed files with 422 additions and 756 deletions

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.

View File

@@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -50,8 +49,7 @@ export const EnterpriseLicenseStatus = ({
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -99,7 +97,14 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
{t("common.updated_at")}{" "}
{new Date(lastChecked).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
</div>
@@ -127,7 +132,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",

View File

@@ -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

View File

@@ -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>;
},
};

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

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;
};

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;
});
});

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", () => {

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",

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",
});
});
});

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;
}, {});
};

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", () => {

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 => {

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", () => {

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(", ");
}
}

View File

@@ -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>

View File

@@ -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>;
}

View File

@@ -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 &&

View File

@@ -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}

View File

@@ -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"}>

View File

@@ -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

View File

@@ -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 (

View File

@@ -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>;
},
};

View File

@@ -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(() => {

View File

@@ -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} />;
},
};

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>
);
},
};

View File

@@ -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>

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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}

View File

@@ -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"
/>

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");
});
});

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}`;
};

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.";

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;