mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-21 09:21:13 -05:00
Compare commits
5 Commits
codex/simp
...
feat/date-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89a83c199 | ||
|
|
82236be8bb | ||
|
|
759a0e31ef | ||
|
|
4359963eba | ||
|
|
58db9422ad |
@@ -52,6 +52,14 @@ 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.
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -49,7 +50,8 @@ export const EnterpriseLicenseStatus = ({
|
||||
gracePeriodEnd,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const router = useRouter();
|
||||
const [isRechecking, setIsRechecking] = useState(false);
|
||||
|
||||
@@ -97,14 +99,7 @@ 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")}{" "}
|
||||
{new Date(lastChecked).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
||||
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
||||
@@ -96,8 +96,8 @@ export const ResponseTable = ({
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
||||
);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
|
||||
@@ -8,10 +8,11 @@ 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 { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { formatDateTimeForDisplay } 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";
|
||||
@@ -34,6 +35,7 @@ const getElementColumnsData = (
|
||||
element: TSurveyElement,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||
@@ -167,6 +169,7 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -218,6 +221,7 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -259,11 +263,14 @@ 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, t));
|
||||
const elementColumns = elements.flatMap((element) =>
|
||||
getElementColumnsData(element, survey, isExpanded, locale, t)
|
||||
);
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.createdAt);
|
||||
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
||||
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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 { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
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";
|
||||
@@ -23,13 +27,12 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(organization.id),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
@@ -86,7 +89,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={locale}
|
||||
locale={user.locale ?? DEFAULT_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 { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
@@ -32,13 +32,9 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
return formattedDate ?? `${t("common.invalid_date")}(${value})`;
|
||||
};
|
||||
|
||||
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, WEBAPP_URL } from "@/lib/constants";
|
||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -18,11 +18,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, integrations] = await Promise.all([
|
||||
const [surveys, integrations, locale] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||
@@ -33,9 +34,6 @@ 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("./");
|
||||
}
|
||||
@@ -52,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -3,13 +3,14 @@ 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 { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -21,19 +22,17 @@ 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 } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, integrations] = await Promise.all([
|
||||
const [surveys, integrations, locale] = 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("./");
|
||||
}
|
||||
@@ -49,7 +48,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getNotionDatabases } from "@/lib/notion/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -28,18 +29,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
NOTION_REDIRECT_URI
|
||||
);
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, notionIntegration] = await Promise.all([
|
||||
const [surveys, notionIntegration, locale] = 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("./");
|
||||
@@ -56,7 +57,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
notionIntegration={notionIntegration as TIntegrationNotion}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
databasesArray={databasesArray}
|
||||
locale={locale}
|
||||
locale={locale ?? DEFAULT_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 { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -17,15 +17,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, slackIntegration] = await Promise.all([
|
||||
const [surveys, slackIntegration, locale] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "slack"),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -41,7 +40,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -18,6 +18,18 @@ 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("");
|
||||
});
|
||||
@@ -46,6 +58,20 @@ 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("");
|
||||
});
|
||||
@@ -75,6 +101,18 @@ 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", () => {
|
||||
@@ -83,6 +121,12 @@ 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", () => {
|
||||
@@ -90,6 +134,18 @@ 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", () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
|
||||
export const convertDateString = (dateString: string | null, locale: string = DEFAULT_LOCALE) => {
|
||||
if (dateString === null) return null;
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
@@ -12,41 +15,25 @@ export const convertDateString = (dateString: string | null) => {
|
||||
if (isNaN(date.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
return formatDateForDisplay(date, locale);
|
||||
};
|
||||
|
||||
export const convertDateTimeString = (dateString: string) => {
|
||||
export const convertDateTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => {
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
return formatDateTimeForDisplay(date, locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export const convertDateTimeStringShort = (dateString: string) => {
|
||||
export const convertDateTimeStringShort = (dateString: string, locale: string = DEFAULT_LOCALE) => {
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
@@ -61,12 +48,12 @@ export const convertDateTimeStringShort = (dateString: string) => {
|
||||
minute: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
locale,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const convertTimeString = (dateString: string) => {
|
||||
export const convertTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => {
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
@@ -76,12 +63,12 @@ export const convertTimeString = (dateString: string) => {
|
||||
second: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
locale,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
const getLocaleForTimeSince = (locale: TUserLocale | string) => {
|
||||
switch (locale) {
|
||||
case "de-DE":
|
||||
return de;
|
||||
@@ -111,10 +98,12 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return zhCN;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
};
|
||||
|
||||
export const timeSince = (dateString: string, locale: TUserLocale) => {
|
||||
export const timeSince = (dateString: string, locale: TUserLocale | string = DEFAULT_LOCALE) => {
|
||||
const date = new Date(dateString);
|
||||
return formatDistance(date, new Date(), {
|
||||
addSuffix: true,
|
||||
@@ -122,14 +111,15 @@ export const timeSince = (dateString: string, locale: TUserLocale) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const timeSinceDate = (date: Date) => {
|
||||
export const timeSinceDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => {
|
||||
return formatDistance(date, new Date(), {
|
||||
addSuffix: true,
|
||||
locale: getLocaleForTimeSince(locale),
|
||||
});
|
||||
};
|
||||
|
||||
export const formatDate = (date: Date) => {
|
||||
return intlFormat(date, {
|
||||
export const formatDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => {
|
||||
return formatDateForDisplay(date, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
||||
67
apps/web/lib/utils/date-display.test.ts
Normal file
67
apps/web/lib/utils/date-display.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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
apps/web/lib/utils/date-display.ts
Normal file
83
apps/web/lib/utils/date-display.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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;
|
||||
}, {});
|
||||
};
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
|
||||
import {
|
||||
diffInDays,
|
||||
formatDateForDisplay,
|
||||
formatDateTimeForDisplay,
|
||||
formatDateWithOrdinal,
|
||||
getFormattedDateTimeString,
|
||||
isValidDateString,
|
||||
} from "./datetime";
|
||||
|
||||
describe("datetime utils", () => {
|
||||
test("diffInDays calculates the difference in days between two dates", () => {
|
||||
@@ -8,13 +15,45 @@ describe("datetime utils", () => {
|
||||
expect(diffInDays(date1, date2)).toBe(5);
|
||||
});
|
||||
|
||||
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
|
||||
test("formatDateWithOrdinal formats a date using the provided locale", () => {
|
||||
// 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));
|
||||
|
||||
// Test the function
|
||||
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
|
||||
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("isValidDateString validates correct date strings", () => {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
const relevantDigits = day < 30 ? day % 20 : day % 30;
|
||||
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
|
||||
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",
|
||||
};
|
||||
|
||||
// Helper function to calculate difference in days between two dates
|
||||
@@ -10,23 +20,44 @@ export const diffInDays = (date1: Date, date2: Date) => {
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
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 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 isValidDateString = (value: string) => {
|
||||
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
|
||||
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
|
||||
|
||||
if (!regex.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return date;
|
||||
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());
|
||||
};
|
||||
|
||||
export const getFormattedDateTimeString = (date: Date): string => {
|
||||
|
||||
@@ -32,16 +32,17 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
isValidDateString: vi.fn((value) => {
|
||||
try {
|
||||
return !isNaN(new Date(value as string).getTime());
|
||||
} catch {
|
||||
return false;
|
||||
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"}`;
|
||||
}
|
||||
}),
|
||||
formatDateWithOrdinal: vi.fn(() => {
|
||||
return "January 1st, 2023";
|
||||
|
||||
if (value === "01-02-2023" && format === "M-d-y") {
|
||||
return `legacy-${locale}-${format}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -477,7 +478,20 @@ describe("recall utility functions", () => {
|
||||
};
|
||||
|
||||
const result = parseRecallInfo(text, responseData);
|
||||
expect(result).toBe("You joined on January 1st, 2023");
|
||||
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");
|
||||
});
|
||||
|
||||
test("formats array values as comma-separated list", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
|
||||
|
||||
export interface fallbacks {
|
||||
[id: string]: string;
|
||||
@@ -224,7 +224,9 @@ export const parseRecallInfo = (
|
||||
text: string,
|
||||
responseData?: TResponseData,
|
||||
variables?: TResponseVariables,
|
||||
withSlash: boolean = false
|
||||
withSlash: boolean = false,
|
||||
locale: string = "en-US",
|
||||
dateFormats?: TSurveyDateFormatMap
|
||||
) => {
|
||||
let modifiedText = text;
|
||||
const questionIds = responseData ? Object.keys(responseData) : [];
|
||||
@@ -254,12 +256,14 @@ export const parseRecallInfo = (
|
||||
value = responseData[recallItemId];
|
||||
|
||||
// Apply formatting for special value types
|
||||
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(", ");
|
||||
if (typeof value === "string") {
|
||||
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
|
||||
|
||||
if (formattedDate) {
|
||||
value = formattedDate;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ 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";
|
||||
|
||||
@@ -15,6 +17,7 @@ interface ElementSkipProps {
|
||||
elements: TSurveyElement[];
|
||||
isFirstElementAnswered?: boolean;
|
||||
responseData: TResponseData;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ElementSkip = ({
|
||||
@@ -23,8 +26,10 @@ export const ElementSkip = ({
|
||||
elements,
|
||||
isFirstElementAnswered,
|
||||
responseData,
|
||||
locale,
|
||||
}: ElementSkipProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dateFormats = getSurveyDateFormatMap(elements);
|
||||
return (
|
||||
<div>
|
||||
{skippedElements && (
|
||||
@@ -81,7 +86,11 @@ export const ElementSkip = ({
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
responseData,
|
||||
undefined,
|
||||
false,
|
||||
locale,
|
||||
dateFormats
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
@@ -120,7 +129,11 @@ export const ElementSkip = ({
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
responseData,
|
||||
undefined,
|
||||
false,
|
||||
locale,
|
||||
dateFormats
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -3,11 +3,12 @@ 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 { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
|
||||
@@ -21,6 +22,7 @@ interface RenderResponseProps {
|
||||
element: TSurveyElement;
|
||||
survey: TSurvey;
|
||||
language: string | null;
|
||||
locale: TUserLocale;
|
||||
isExpanded?: boolean;
|
||||
showId: boolean;
|
||||
}
|
||||
@@ -30,6 +32,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
element,
|
||||
survey,
|
||||
language,
|
||||
locale,
|
||||
isExpanded = true,
|
||||
showId,
|
||||
}) => {
|
||||
@@ -63,9 +66,8 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
break;
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
if (typeof responseData === "string") {
|
||||
const parsedDate = new Date(responseData);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
|
||||
const formattedDate =
|
||||
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
|
||||
|
||||
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ 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";
|
||||
@@ -21,14 +23,17 @@ 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) => {
|
||||
@@ -61,6 +66,7 @@ export const SingleResponseCardBody = ({
|
||||
status={"welcomeCard"}
|
||||
isFirstElementAnswered={isFirstElementAnswered}
|
||||
responseData={response.data}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
@@ -98,7 +104,9 @@ export const SingleResponseCardBody = ({
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
response.data,
|
||||
response.variables,
|
||||
true
|
||||
true,
|
||||
locale,
|
||||
dateFormats
|
||||
)
|
||||
)
|
||||
)}
|
||||
@@ -109,6 +117,7 @@ export const SingleResponseCardBody = ({
|
||||
survey={survey}
|
||||
responseData={response.data[question.id]}
|
||||
language={response.language}
|
||||
locale={locale}
|
||||
showId={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -118,6 +127,7 @@ export const SingleResponseCardBody = ({
|
||||
skippedElements={skipped}
|
||||
elements={elements}
|
||||
responseData={response.data}
|
||||
locale={locale}
|
||||
status={
|
||||
response.finished ||
|
||||
(skippedQuestions.length > 0 &&
|
||||
|
||||
@@ -137,7 +137,12 @@ export const SingleResponseCard = ({
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
|
||||
<SingleResponseCardBody
|
||||
survey={survey}
|
||||
response={response}
|
||||
skippedQuestions={skippedQuestions}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<ResponseTagsWrapper
|
||||
key={response.id}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -77,14 +78,6 @@ 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;
|
||||
@@ -168,7 +161,17 @@ export const PricingTable = ({
|
||||
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
|
||||
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
|
||||
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
|
||||
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
|
||||
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 responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
|
||||
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
|
||||
const currentPlanLevel =
|
||||
@@ -433,7 +436,15 @@ export const PricingTable = ({
|
||||
<AlertDescription>
|
||||
{t("environments.settings.billing.pending_plan_change_description")
|
||||
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
|
||||
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
|
||||
.replace(
|
||||
"{{date}}",
|
||||
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
)}
|
||||
</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 = await findMatchingLocale();
|
||||
const locale = user.locale ?? DEFAULT_LOCALE;
|
||||
|
||||
return (
|
||||
<ActivityTimeline
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -9,6 +10,7 @@ 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),
|
||||
@@ -43,7 +45,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
return <IdBadge id={attr.value} />;
|
||||
}
|
||||
|
||||
return formatAttributeValue(attr.value, attr.dataType);
|
||||
return formatAttributeValue(attr.value, attr.dataType, locale);
|
||||
};
|
||||
|
||||
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,7 +61,15 @@ export const generateAttributeTableColumns = (
|
||||
header: t("common.created_at"),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
|
||||
return (
|
||||
<span>
|
||||
{formatDateForDisplay(createdAt, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const AttributesTable = ({
|
||||
// Generate columns
|
||||
const columns = useMemo(() => {
|
||||
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale);
|
||||
}, [searchValue, isReadOnly, isExpanded]);
|
||||
}, [searchValue, isReadOnly, isExpanded, locale, t]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -12,6 +13,7 @@ export const generateContactTableColumns = (
|
||||
searchValue: string,
|
||||
data: TContactTableData[],
|
||||
isReadOnly: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TContactTableData>[] => {
|
||||
const userColumn: ColumnDef<TContactTableData> = {
|
||||
@@ -75,7 +77,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);
|
||||
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType, locale);
|
||||
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
@@ -65,14 +66,15 @@ export const ContactsTable = ({
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Generate columns
|
||||
const columns = useMemo(() => {
|
||||
return generateContactTableColumns(searchValue, data, isReadOnly, t);
|
||||
}, [searchValue, data, isReadOnly]);
|
||||
return generateContactTableColumns(searchValue, data, isReadOnly, locale, t);
|
||||
}, [searchValue, data, isReadOnly, locale, t]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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.
|
||||
@@ -27,12 +28,11 @@ export const formatAttributeValue = (
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
// Use Intl.DateTimeFormat for locale-aware date formatting
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
return formatDateForDisplay(date, 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 { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
@@ -11,7 +11,8 @@ interface SegmentActivityTabProps {
|
||||
}
|
||||
|
||||
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
|
||||
const { activeSurveys, inactiveSurveys } = currentSegment;
|
||||
|
||||
@@ -43,13 +44,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">
|
||||
{convertDateTimeStringShort(currentSegment.createdAt?.toString())}
|
||||
{formatDateTimeForDisplay(currentSegment.createdAt, locale)}
|
||||
</p>
|
||||
</div>{" "}
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
|
||||
{formatDateTimeForDisplay(currentSegment.updatedAt, locale)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"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): ColumnDef<TSegmentWithSurveyNames>[] => {
|
||||
export const generateSegmentTableColumns = (
|
||||
t: TFunction,
|
||||
locale: string
|
||||
): ColumnDef<TSegmentWithSurveyNames>[] => {
|
||||
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
@@ -33,11 +37,7 @@ export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWit
|
||||
accessorKey: "updatedAt",
|
||||
header: t("common.updated_at"),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
|
||||
</span>
|
||||
);
|
||||
return <span className="text-sm text-slate-900">{timeSinceDate(row.original.updatedAt, locale)}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,7 +47,13 @@ export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWit
|
||||
header: t("common.created_at"),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDateForDisplay(row.original.createdAt, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"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 = {
|
||||
@@ -24,6 +26,8 @@ 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 (
|
||||
<>
|
||||
@@ -46,14 +50,16 @@ 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">
|
||||
{formatDistanceToNow(updatedAt, {
|
||||
addSuffix: true,
|
||||
}).replace("about", "")}
|
||||
</div>
|
||||
<div className="ph-no-capture text-slate-900">{timeSinceDate(updatedAt, locale)}</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">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
{formatDateForDisplay(createdAt, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -22,12 +22,13 @@ export function SegmentTable({
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: SegmentTableUpdatedProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateSegmentTableColumns(t);
|
||||
}, []);
|
||||
return generateSegmentTableColumns(t, locale);
|
||||
}, [locale, t]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: segments,
|
||||
|
||||
@@ -75,6 +75,7 @@ export async function PreviewEmailTemplate({
|
||||
survey,
|
||||
surveyUrl,
|
||||
styling,
|
||||
locale,
|
||||
t,
|
||||
}: PreviewEmailTemplateProps): Promise<React.JSX.Element> {
|
||||
const url = `${surveyUrl}?preview=true`;
|
||||
@@ -85,8 +86,20 @@ export async function PreviewEmailTemplate({
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const firstQuestion = questions[0];
|
||||
|
||||
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
|
||||
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
|
||||
const headline = parseRecallInfo(
|
||||
getLocalizedValue(firstQuestion.headline, defaultLanguageCode),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
locale
|
||||
);
|
||||
const subheader = parseRecallInfo(
|
||||
getLocalizedValue(firstQuestion.subheader, defaultLanguageCode),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
locale
|
||||
);
|
||||
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
|
||||
|
||||
switch (firstQuestion.type) {
|
||||
|
||||
@@ -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 { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface ActivityTabProps {
|
||||
@@ -37,7 +37,8 @@ const convertTriggerIdToName = (triggerId: string, t: TFunction): string => {
|
||||
};
|
||||
|
||||
export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
<div className="col-span-2 space-y-4 pr-6">
|
||||
@@ -81,15 +82,11 @@ 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">
|
||||
{convertDateTimeStringShort(webhook.createdAt?.toString())}
|
||||
</p>
|
||||
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(webhook.createdAt, locale)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
|
||||
</p>
|
||||
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(webhook.updatedAt, locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
|
||||
@@ -55,16 +54,10 @@ const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const WebhookRowData = ({
|
||||
webhook,
|
||||
surveys,
|
||||
locale,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
surveys: TSurvey[];
|
||||
locale: TUserLocale;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
export const WebhookRowData = ({ webhook, surveys }: { webhook: Webhook; surveys: TSurvey[] }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
|
||||
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">
|
||||
@@ -91,7 +84,7 @@ export const WebhookRowData = ({
|
||||
{renderSelectedTriggersText(webhook, t)}
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(webhook.createdAt.toString(), locale)}
|
||||
{timeSince(webhook.updatedAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -23,7 +22,6 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
|
||||
]);
|
||||
|
||||
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -32,7 +30,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} locale={locale} />
|
||||
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
|
||||
))}
|
||||
</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 { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
|
||||
@@ -12,11 +12,13 @@ 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 } = await getEnvironmentAuth(params.environmentId);
|
||||
const { currentUserMembership, organization, session } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const projects = await getProjectsByOrganizationId(organization.id);
|
||||
const [projects, locale] = await Promise.all([
|
||||
getProjectsByOrganizationId(organization.id),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
|
||||
|
||||
@@ -37,7 +39,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}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
isReadOnly={!canAccessApiKeys}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
@@ -39,7 +39,8 @@ export const MembersInfo = ({
|
||||
isUserManagementDisabledFromUi,
|
||||
}: MembersInfoProps) => {
|
||||
const allMembers = [...members, ...invites];
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
|
||||
const getMembershipBadge = (member: TMember | TInvite) => {
|
||||
if (isInvitee(member)) {
|
||||
@@ -48,7 +49,7 @@ export const MembersInfo = ({
|
||||
) : (
|
||||
<TooltipRenderer
|
||||
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
|
||||
date: formatDateWithOrdinal(member.expiresAt),
|
||||
date: formatDateWithOrdinal(member.expiresAt, locale),
|
||||
})}`}>
|
||||
<Badge type="warning" text="Pending" size="tiny" />
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -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 { WEBAPP_URL } from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
@@ -21,17 +21,15 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
|
||||
const t = await getTranslate();
|
||||
const { environmentId } = await params;
|
||||
|
||||
const { environment, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
const { environment, isReadOnly, session } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const [environments, actionClasses] = await Promise.all([
|
||||
const [environments, actionClasses, locale] = await Promise.all([
|
||||
getEnvironments(environment.projectId),
|
||||
getActionClasses(environmentId),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
const otherEnvironment = environments.filter((env) => env.id !== environmentId)[0];
|
||||
const [otherEnvActionClasses, locale] = await Promise.all([
|
||||
otherEnvironment ? getActionClasses(otherEnvironment.id) : Promise.resolve([]),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
const otherEnvActionClasses = otherEnvironment ? await getActionClasses(otherEnvironment.id) : [];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -89,7 +87,7 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
|
||||
environmentId={environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
locale={locale ?? DEFAULT_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 { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
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,7 +32,8 @@ export const ActionActivityTab = ({
|
||||
environment,
|
||||
isReadOnly,
|
||||
}: ActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const [activeSurveys, setActiveSurveys] = useState<string[] | undefined>();
|
||||
const [inactiveSurveys, setInactiveSurveys] = useState<string[] | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -136,16 +137,12 @@ 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">Created on</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(actionClass.createdAt?.toString())}
|
||||
</p>
|
||||
<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>
|
||||
</div>{" "}
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">Last updated</Label>
|
||||
<p className="text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(actionClass.updatedAt?.toString())}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-xs font-normal text-slate-500">Type</Label>
|
||||
|
||||
@@ -11,8 +11,6 @@ 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;
|
||||
@@ -27,21 +25,6 @@ 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,
|
||||
@@ -115,19 +98,6 @@ 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}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { convertDateString, timeSince } from "@/lib/time";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
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";
|
||||
@@ -82,7 +83,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">
|
||||
{convertDateString(survey.createdAt.toString())}
|
||||
{formatDateForDisplay(survey.createdAt, locale)}
|
||||
</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)}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface SegmentDetailProps {
|
||||
onSegmentLoad: (surveyId: string, segmentId: string) => Promise<TSurvey>;
|
||||
surveyId: string;
|
||||
currentSegment: TSegment;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
const SegmentDetail = ({
|
||||
@@ -28,6 +29,7 @@ const SegmentDetail = ({
|
||||
onSegmentLoad,
|
||||
surveyId,
|
||||
currentSegment,
|
||||
locale,
|
||||
}: SegmentDetailProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleLoadNewSegment = async (segmentId: string) => {
|
||||
@@ -106,11 +108,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)}</div>
|
||||
<div className="ph-no-capture text-slate-900">{timeSinceDate(segment.updatedAt, locale)}</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)}</div>
|
||||
<div className="ph-no-capture text-slate-900">{formatDate(segment.createdAt, locale)}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -140,7 +142,8 @@ export const LoadSegmentModal = ({
|
||||
const handleResetState = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const segmentsArray = segments?.filter((segment) => !segment.isPrivate);
|
||||
|
||||
return (
|
||||
@@ -182,6 +185,7 @@ export const LoadSegmentModal = ({
|
||||
onSegmentLoad={onSegmentLoad}
|
||||
surveyId={surveyId}
|
||||
currentSegment={currentSegment}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 {
|
||||
@@ -31,7 +32,7 @@ export const PendingDowngradeBanner = ({
|
||||
: false;
|
||||
|
||||
const scheduledDowngradeDate = new Date(lastChecked.getTime() + threeDaysInMillis);
|
||||
const formattedDate = scheduledDowngradeDate.toLocaleDateString(locale, {
|
||||
const formattedDate = formatDateForDisplay(scheduledDowngradeDate, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useCallback, 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,26 +66,29 @@ export function EndingCard({
|
||||
</div>
|
||||
);
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid URL after recall processing:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid URL after recall processing:", error);
|
||||
}
|
||||
};
|
||||
},
|
||||
[languageCode, onOpenExternalURL, responseData, variablesData]
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!isRedirectDisabled && endingCard.type === "endScreen" && endingCard.buttonLink) {
|
||||
processAndRedirect(endingCard.buttonLink);
|
||||
}
|
||||
};
|
||||
}, [endingCard, isRedirectDisabled, processAndRedirect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrent) {
|
||||
@@ -114,7 +117,15 @@ export function EndingCard({
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
};
|
||||
}, [isCurrent, isResponseSendingFinished, isRedirectDisabled, endingCard, survey.type]);
|
||||
}, [
|
||||
endingCard,
|
||||
handleSubmit,
|
||||
isCurrent,
|
||||
isRedirectDisabled,
|
||||
isResponseSendingFinished,
|
||||
processAndRedirect,
|
||||
survey.type,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ScrollableContainer fullSizeCards={fullSizeCards}>
|
||||
@@ -130,7 +141,8 @@ export function EndingCard({
|
||||
headline={replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.headline, languageCode),
|
||||
responseData,
|
||||
variablesData
|
||||
variablesData,
|
||||
languageCode
|
||||
)}
|
||||
elementId="EndingCard"
|
||||
/>
|
||||
@@ -138,7 +150,8 @@ export function EndingCard({
|
||||
subheader={replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.subheader, languageCode),
|
||||
responseData,
|
||||
variablesData
|
||||
variablesData,
|
||||
languageCode
|
||||
)}
|
||||
elementId="EndingCard"
|
||||
/>
|
||||
@@ -148,7 +161,8 @@ export function EndingCard({
|
||||
buttonLabel={replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.buttonLabel, languageCode),
|
||||
responseData,
|
||||
variablesData
|
||||
variablesData,
|
||||
languageCode
|
||||
)}
|
||||
isLastQuestion={false}
|
||||
focus={isCurrent ? autoFocusEnabled : false}
|
||||
|
||||
@@ -149,14 +149,20 @@ export function WelcomeCard({
|
||||
) : null}
|
||||
|
||||
<Headline
|
||||
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData, variablesData)}
|
||||
headline={replaceRecallInfo(
|
||||
getLocalizedValue(headline, languageCode),
|
||||
responseData,
|
||||
variablesData,
|
||||
languageCode
|
||||
)}
|
||||
elementId="welcomeCard"
|
||||
/>
|
||||
<Subheader
|
||||
subheader={replaceRecallInfo(
|
||||
getLocalizedValue(subheader, languageCode),
|
||||
responseData,
|
||||
variablesData
|
||||
variablesData,
|
||||
languageCode
|
||||
)}
|
||||
elementId="welcomeCard"
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
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");
|
||||
@@ -88,96 +76,30 @@ describe("isValidDateString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('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('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
|
||||
});
|
||||
|
||||
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");
|
||||
const getExpectedLocaleDate = (date: Date, locale: string) =>
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
|
||||
// Wednesday, February 22nd, 2023
|
||||
const date2 = new Date(2023, 1, 22);
|
||||
expect(formatDateWithOrdinal(date2)).toBe("Wednesday, February 22nd, 2023");
|
||||
test("formats survey dates with locale-native en-US output", () => {
|
||||
const date = new Date(2024, 0, 1);
|
||||
|
||||
// Sunday, March 13th, 2022
|
||||
const date3 = new Date(2022, 2, 13);
|
||||
expect(formatDateWithOrdinal(date3)).toBe("Sunday, March 13th, 2022");
|
||||
expect(formatDateWithOrdinal(date, "en-US")).toBe(getExpectedLocaleDate(date, "en-US"));
|
||||
});
|
||||
|
||||
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
|
||||
test("formats survey dates with locale-native fr-FR output", () => {
|
||||
const date = new Date(2024, 0, 1);
|
||||
|
||||
// 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");
|
||||
expect(formatDateWithOrdinal(date, "fr-FR")).toBe(getExpectedLocaleDate(date, "fr-FR"));
|
||||
});
|
||||
|
||||
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("formats survey dates with locale-native de-DE output", () => {
|
||||
const date = new Date(2024, 2, 20);
|
||||
|
||||
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");
|
||||
expect(formatDateWithOrdinal(date, "de-DE")).toBe(getExpectedLocaleDate(date, "de-DE"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,16 +37,11 @@ export const isValidDateString = (value: string) => {
|
||||
return !isNaN(date.getTime());
|
||||
};
|
||||
|
||||
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}`;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
@@ -15,8 +15,10 @@ 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: (date: Date) =>
|
||||
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`,
|
||||
formatDateWithOrdinal: vi.fn(
|
||||
(date: Date) =>
|
||||
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`
|
||||
),
|
||||
}));
|
||||
|
||||
describe("replaceRecallInfo", () => {
|
||||
@@ -71,6 +73,15 @@ 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.";
|
||||
|
||||
@@ -29,7 +29,8 @@ const extractRecallInfo = (headline: string, id?: string): string | null => {
|
||||
export const replaceRecallInfo = (
|
||||
text: string,
|
||||
responseData: TResponseData,
|
||||
variables: TResponseVariables
|
||||
variables: TResponseVariables,
|
||||
locale: string = "en-US"
|
||||
): string => {
|
||||
let modifiedText = text;
|
||||
|
||||
@@ -56,7 +57,7 @@ export const replaceRecallInfo = (
|
||||
// Additional value formatting if it exists
|
||||
if (value) {
|
||||
if (isValidDateString(value)) {
|
||||
value = formatDateWithOrdinal(new Date(value));
|
||||
value = formatDateWithOrdinal(new Date(value), locale);
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", "); // Filters out empty values and joins with a comma
|
||||
}
|
||||
@@ -80,7 +81,8 @@ export const parseRecallInformation = (
|
||||
modifiedQuestion.headline[languageCode] = replaceRecallInfo(
|
||||
getLocalizedValue(modifiedQuestion.headline, languageCode),
|
||||
responseData,
|
||||
variables
|
||||
variables,
|
||||
languageCode
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -91,7 +93,8 @@ export const parseRecallInformation = (
|
||||
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
|
||||
getLocalizedValue(modifiedQuestion.subheader, languageCode),
|
||||
responseData,
|
||||
variables
|
||||
variables,
|
||||
languageCode
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -103,7 +106,8 @@ export const parseRecallInformation = (
|
||||
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
|
||||
getLocalizedValue(modifiedQuestion.subheader, languageCode),
|
||||
responseData,
|
||||
variables
|
||||
variables,
|
||||
languageCode
|
||||
);
|
||||
}
|
||||
return modifiedQuestion;
|
||||
|
||||
Reference in New Issue
Block a user