Compare commits

..

5 Commits

Author SHA1 Message Date
Cursor Agent
893384a19d fix: load survey script directly via src instead of blob URL
Fixes FORMBRICKS-TQ

The blob URL approach was being blocked by CSP which doesn't include 'blob:'
in script-src directive. Simplified to load script directly via src attribute:
- Works with existing CSP ('self' is allowed)
- No need for fetch, blob URLs, or eval
- Properly executes and initializes window.formbricksSurveys
- Adds cache-busting in development for fresh script loads
2026-03-18 13:05:54 +00:00
Cursor Agent
1eba663294 chore: remove test files 2026-03-18 12:33:26 +00:00
Cursor Agent
c37e5c0750 chore: add blob URL test results screenshot - all tests passing 2026-03-18 12:32:43 +00:00
Cursor Agent
75e47b4979 fix: use blob URL to execute survey script and initialize window.formbricksSurveys
Fixes FORMBRICKS-TQ

The script content was being assigned to textContent which may not execute
properly in all contexts. Changed to use a Blob URL approach which:
- Creates a blob from the fetched script content
- Loads it via script src attribute (works with CSP without unsafe-eval)
- Properly waits for script execution before proceeding
- Ensures window.formbricksSurveys is initialized correctly
- Cleans up the blob URL after loading to prevent memory leaks
2026-03-18 12:31:17 +00:00
Cursor Agent
7504c47fc1 fix: execute survey script in global scope to initialize window.formbricksSurveys
Fixes FORMBRICKS-TQ

The script content was being assigned to textContent instead of being
executed, preventing the window.formbricksSurveys object from being
initialized. Changed to use indirect eval pattern to execute the script
content in the global scope, ensuring proper initialization.
2026-03-18 12:22:53 +00:00
59 changed files with 444 additions and 772 deletions

View File

@@ -231,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.

View File

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

View File

@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
);
// Save settings to localStorage when they change

View File

@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -35,7 +34,6 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -169,7 +167,6 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -221,7 +218,6 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt",
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
},
};

View File

@@ -1,17 +1,13 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import {
DEFAULT_LOCALE,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
RESPONSES_PER_PAGE,
} from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -27,12 +23,13 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
@@ -89,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={user.locale ?? DEFAULT_LOCALE}
locale={locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}

View File

@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,9 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const parsedDate = new Date(value);
return formattedDate ?? `${t("common.invalid_date")}(${value})`;
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (

View File

@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>

View File

@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>

View File

@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET,
@@ -12,7 +11,7 @@ import {
} from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration, locale] = await Promise.all([
const [surveys, notionIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getUserLocale(session.user.id),
]);
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</PageContentWrapper>
);

View File

@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration, locale] = await Promise.all([
const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>

View File

@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({
userEmail,
userName,
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => {
const $chatwoot = getChatwoot();
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
});
userSetRef.current = true;
}
}, [userId, userEmail, userName, getChatwoot]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready
if (getChatwoot()) {
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = getChatwoot();
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
scriptElement?.remove();
userSetRef.current = false;
customerStatusSetRef.current = false;
};
}, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};

View File

@@ -1,18 +0,0 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});

View File

@@ -18,18 +18,6 @@ describe("Time Utilities", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should format date string with the provided locale", () => {
const date = new Date("2024-03-20T12:30:00");
expect(convertDateString("2024-03-20T12:30:00", "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
@@ -58,20 +46,6 @@ describe("Time Utilities", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
});
test("should format date and time string in the provided locale", () => {
const date = new Date("2024-03-20T15:30:00");
expect(convertDateTimeStringShort("2024-03-20T15:30:00", "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
});
test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe("");
});
@@ -101,18 +75,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
});
test("should format time since in Brazilian Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
});
test("should format time since in European Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
});
});
describe("timeSinceDate", () => {
@@ -121,12 +83,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
});
test("should format time since from Date object in the provided locale", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
});
});
describe("formatDate", () => {
@@ -134,18 +90,6 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024");
});
test("should format date with the provided locale", () => {
const date = new Date(2024, 2, 20);
expect(formatDate(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
});
describe("getTodaysDateFormatted", () => {

View File

@@ -1,11 +1,8 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay, formatDateTimeForDisplay } from "./utils/datetime";
const DEFAULT_LOCALE = "en-US";
export const convertDateString = (dateString: string | null, locale: string = DEFAULT_LOCALE) => {
export const convertDateString = (dateString: string | null) => {
if (dateString === null) return null;
if (!dateString) {
return dateString;
@@ -15,25 +12,41 @@ export const convertDateString = (dateString: string | null, locale: string = DE
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return formatDateForDisplay(date, locale);
return intlFormat(
date,
{
year: "numeric",
month: "short",
day: "numeric",
},
{
locale: "en",
}
);
};
export const convertDateTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertDateTimeString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return formatDateTimeForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return intlFormat(
date,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
export const convertDateTimeStringShort = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertDateTimeStringShort = (dateString: string) => {
if (!dateString) {
return dateString;
}
@@ -48,12 +61,12 @@ export const convertDateTimeStringShort = (dateString: string, locale: string =
minute: "2-digit",
},
{
locale,
locale: "en",
}
);
};
export const convertTimeString = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
@@ -63,12 +76,12 @@ export const convertTimeString = (dateString: string, locale: string = DEFAULT_L
second: "2-digit",
},
{
locale,
locale: "en",
}
);
};
const getLocaleForTimeSince = (locale: TUserLocale | string) => {
const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) {
case "de-DE":
return de;
@@ -98,12 +111,10 @@ const getLocaleForTimeSince = (locale: TUserLocale | string) => {
return zhCN;
case "zh-Hant-TW":
return zhTW;
default:
return enUS;
}
};
export const timeSince = (dateString: string, locale: TUserLocale | string = DEFAULT_LOCALE) => {
export const timeSince = (dateString: string, locale: TUserLocale) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
@@ -111,15 +122,14 @@ export const timeSince = (dateString: string, locale: TUserLocale | string = DEF
});
};
export const timeSinceDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => {
export const timeSinceDate = (date: Date) => {
return formatDistance(date, new Date(), {
addSuffix: true,
locale: getLocaleForTimeSince(locale),
});
};
export const formatDate = (date: Date, locale: TUserLocale | string = DEFAULT_LOCALE) => {
return formatDateForDisplay(date, locale, {
export const formatDate = (date: Date) => {
return intlFormat(date, {
year: "numeric",
month: "long",
day: "numeric",

View File

@@ -1,67 +0,0 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
describe("date display utils", () => {
test("parses ISO stored dates", () => {
const parsedDate = parseStoredDateValue("2025-05-06");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses legacy stored dates using the element format", () => {
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses day-first stored dates when no format is provided", () => {
const parsedDate = parseStoredDateValue("06-05-2025");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("formats stored dates using the selected locale", () => {
const date = new Date(2025, 4, 6);
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("returns null for invalid stored dates", () => {
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
});
test("builds a date format map for survey date elements", () => {
const elements = [
{
id: "dateQuestion",
type: "date",
format: "d-M-y",
},
{
id: "textQuestion",
type: "openText",
},
] as TSurveyElement[];
expect(getSurveyDateFormatMap(elements)).toEqual({
dateQuestion: "d-M-y",
});
});
});

View File

@@ -1,83 +0,0 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
const buildDate = (year: number, month: number, day: number): Date | null => {
if ([year, month, day].some((value) => Number.isNaN(value))) {
return null;
}
const parsedDate = new Date(year, month - 1, day);
if (
parsedDate.getFullYear() !== year ||
parsedDate.getMonth() !== month - 1 ||
parsedDate.getDate() !== day
) {
return null;
}
return parsedDate;
};
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
const parts = value.split("-");
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
return null;
}
const [first, second, third] = parts.map(Number);
switch (format) {
case "M-d-y":
return buildDate(third, first, second);
case "d-M-y":
return buildDate(third, second, first);
case "y-M-d":
return buildDate(first, second, third);
}
};
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
const isoMatch = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (isoMatch) {
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
}
if (format) {
return parseLegacyStoredDateValue(value, format);
}
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
return parseLegacyStoredDateValue(value, "d-M-y");
}
return null;
};
export const formatStoredDateForDisplay = (
value: string,
format: TSurveyDateElement["format"] | undefined,
locale: string = "en-US"
): string | null => {
const parsedDate = parseStoredDateValue(value, format);
if (!parsedDate) {
return null;
}
return formatDateWithOrdinal(parsedDate, locale);
};
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
if (element.type === "date") {
dateFormats[element.id] = element.format;
}
return dateFormats;
}, {});
};

View File

@@ -1,12 +1,5 @@
import { describe, expect, test } from "vitest";
import {
diffInDays,
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
getFormattedDateTimeString,
isValidDateString,
} from "./datetime";
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => {
@@ -15,45 +8,13 @@ describe("datetime utils", () => {
expect(diffInDays(date1, date2)).toBe(5);
});
test("formatDateWithOrdinal formats a date using the provided locale", () => {
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
// Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateWithOrdinal(date)).toBe(
new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("formatDateForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateForDisplay(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("formatDateTimeForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
// Test the function
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
});
test("isValidDateString validates correct date strings", () => {

View File

@@ -1,17 +1,7 @@
const DEFAULT_LOCALE = "en-US";
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
// Helper function to calculate difference in days between two dates
@@ -20,44 +10,23 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const formatDateForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateTimeForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
return formatDateForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
const day = date.getDate();
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
const year = date.getFullYear();
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};
export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
if (!regex.test(value)) {
return false;
}
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value)
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1")
: value;
const date = new Date(normalizedValue);
return !Number.isNaN(date.getTime());
const date = new Date(value);
return date;
};
export const getFormattedDateTimeString = (date: Date): string => {

View File

@@ -32,17 +32,16 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/utils/date-display", () => ({
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => {
if (value === "2023-01-01") {
return `formatted-${locale}-${format ?? "iso"}`;
vi.mock("@/lib/utils/datetime", () => ({
isValidDateString: vi.fn((value) => {
try {
return !isNaN(new Date(value as string).getTime());
} catch {
return false;
}
if (value === "01-02-2023" && format === "M-d-y") {
return `legacy-${locale}-${format}`;
}
return null;
}),
formatDateWithOrdinal: vi.fn(() => {
return "January 1st, 2023";
}),
}));
@@ -478,20 +477,7 @@ describe("recall utility functions", () => {
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on formatted-en-US-iso");
});
test("formats legacy date values using the provided locale and stored format", () => {
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
const responseData: TResponseData = {
joinDate: "01-02-2023",
};
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
joinDate: "M-d-y",
});
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
expect(result).toBe("You joined on January 1st, 2023");
});
test("formats array values as comma-separated list", () => {

View File

@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
[id: string]: string;
@@ -224,9 +224,7 @@ export const parseRecallInfo = (
text: string,
responseData?: TResponseData,
variables?: TResponseVariables,
withSlash: boolean = false,
locale: string = "en-US",
dateFormats?: TSurveyDateFormatMap
withSlash: boolean = false
) => {
let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : [];
@@ -256,14 +254,12 @@ export const parseRecallInfo = (
value = responseData[recallItemId];
// Apply formatting for special value types
if (typeof value === "string") {
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
if (formattedDate) {
value = formattedDate;
if (value) {
if (isValidDateString(value as string)) {
value = formatDateWithOrdinal(new Date(value as string));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
}

View File

@@ -5,9 +5,7 @@ import { useTranslation } from "react-i18next";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -17,7 +15,6 @@ interface ElementSkipProps {
elements: TSurveyElement[];
isFirstElementAnswered?: boolean;
responseData: TResponseData;
locale: TUserLocale;
}
export const ElementSkip = ({
@@ -26,10 +23,8 @@ export const ElementSkip = ({
elements,
isFirstElementAnswered,
responseData,
locale,
}: ElementSkipProps) => {
const { t } = useTranslation();
const dateFormats = getSurveyDateFormatMap(elements);
return (
<div>
{skippedElements && (
@@ -86,11 +81,7 @@ export const ElementSkip = ({
},
"default"
),
responseData,
undefined,
false,
locale,
dateFormats
responseData
)
)}
</p>
@@ -129,11 +120,7 @@ export const ElementSkip = ({
},
"default"
),
responseData,
undefined,
false,
locale,
dateFormats
responseData
)
)}
</p>

View File

@@ -3,12 +3,11 @@ import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
@@ -22,7 +21,6 @@ interface RenderResponseProps {
element: TSurveyElement;
survey: TSurvey;
language: string | null;
locale: TUserLocale;
isExpanded?: boolean;
showId: boolean;
}
@@ -32,7 +30,6 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
element,
survey,
language,
locale,
isExpanded = true,
showId,
}) => {
@@ -66,8 +63,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break;
case TSurveyElementTypeEnum.Date:
if (typeof responseData === "string") {
const formattedDate =
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
const parsedDate = new Date(responseData);
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
}

View File

@@ -6,9 +6,7 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -23,17 +21,14 @@ interface SingleResponseCardBodyProps {
survey: TSurvey;
response: TResponseWithQuotas;
skippedQuestions: string[][];
locale: TUserLocale;
}
export const SingleResponseCardBody = ({
survey,
response,
skippedQuestions,
locale,
}: SingleResponseCardBodyProps) => {
const elements = getElementsFromBlocks(survey.blocks);
const dateFormats = getSurveyDateFormatMap(elements);
const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false;
const { t } = useTranslation();
const formatTextWithSlashes = (text: string) => {
@@ -66,7 +61,6 @@ export const SingleResponseCardBody = ({
status={"welcomeCard"}
isFirstElementAnswered={isFirstElementAnswered}
responseData={response.data}
locale={locale}
/>
)}
<div className="space-y-6">
@@ -104,9 +98,7 @@ export const SingleResponseCardBody = ({
getLocalizedValue(question.headline, "default"),
response.data,
response.variables,
true,
locale,
dateFormats
true
)
)
)}
@@ -117,7 +109,6 @@ export const SingleResponseCardBody = ({
survey={survey}
responseData={response.data[question.id]}
language={response.language}
locale={locale}
showId={true}
/>
</div>
@@ -127,7 +118,6 @@ export const SingleResponseCardBody = ({
skippedElements={skipped}
elements={elements}
responseData={response.data}
locale={locale}
status={
response.finished ||
(skippedQuestions.length > 0 &&

View File

@@ -137,12 +137,7 @@ export const SingleResponseCard = ({
locale={locale}
/>
<SingleResponseCardBody
survey={survey}
response={response}
skippedQuestions={skippedQuestions}
locale={locale}
/>
<SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
<ResponseTagsWrapper
key={response.id}

View File

@@ -13,7 +13,6 @@ import {
} from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
@@ -78,6 +77,14 @@ const formatMoney = (currency: string, unitAmount: number | null, locale: string
}).format(unitAmount / 100);
};
const formatDate = (date: Date, locale: string) =>
date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
});
type TPlanCardData = {
plan: TStandardPlan;
interval: TCloudBillingInterval;
@@ -161,17 +168,7 @@ export const PricingTable = ({
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
const usageCycleLabel = `${formatDateForDisplay(usageCycleStart, locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})} - ${formatDateForDisplay(usageCycleEnd, locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})}`;
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const currentPlanLevel =
@@ -436,15 +433,7 @@ export const PricingTable = ({
<AlertDescription>
{t("environments.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace(
"{{date}}",
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
)}
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>

View File

@@ -2,12 +2,12 @@ import { getServerSession } from "next-auth";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getDisplaysByContactId } from "@/lib/display/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getResponsesByContactId } from "@/lib/response/service";
import { getSurveys } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -53,7 +53,7 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
}
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const locale = user.locale ?? DEFAULT_LOCALE;
const locale = await findMatchingLocale();
return (
<ActivityTimeline

View File

@@ -1,6 +1,5 @@
import { getDisplaysByContactId } from "@/lib/display/service";
import { getResponsesByContactId } from "@/lib/response/service";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
@@ -10,7 +9,6 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const locale = await getLocale();
const [contact, attributesWithKeyInfo] = await Promise.all([
getContact(contactId),
getContactAttributesWithKeyInfo(contactId),
@@ -45,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
return <IdBadge id={attr.value} />;
}
return formatAttributeValue(attr.value, attr.dataType, locale);
return formatAttributeValue(attr.value, attr.dataType);
};
return (

View File

@@ -1,12 +1,12 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import { TFunction } from "i18next";
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { Badge } from "@/modules/ui/components/badge";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
@@ -61,15 +61,7 @@ export const generateAttributeTableColumns = (
header: t("common.created_at"),
cell: ({ row }) => {
const createdAt = row.original.createdAt;
return (
<span>
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
);
return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
},
};

View File

@@ -78,7 +78,7 @@ export const AttributesTable = ({
// Generate columns
const columns = useMemo(() => {
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale);
}, [searchValue, isReadOnly, isExpanded, locale, t]);
}, [searchValue, isReadOnly, isExpanded]);
// Load saved settings from localStorage
useEffect(() => {

View File

@@ -2,7 +2,6 @@
import { ColumnDef } from "@tanstack/react-table";
import { TFunction } from "i18next";
import { TUserLocale } from "@formbricks/types/user";
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
@@ -13,7 +12,6 @@ export const generateContactTableColumns = (
searchValue: string,
data: TContactTableData[],
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TContactTableData>[] => {
const userColumn: ColumnDef<TContactTableData> = {
@@ -77,7 +75,7 @@ export const generateContactTableColumns = (
cell: ({ row }: { row: { original: TContactTableData } }) => {
const attribute = row.original.attributes.find((a) => a.key === attr.key);
if (!attribute) return null;
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType, locale);
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
},
};

View File

@@ -17,7 +17,6 @@ import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@ta
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { Button } from "@/modules/ui/components/button";
@@ -66,15 +65,14 @@ export const ContactsTable = ({
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [rowSelection, setRowSelection] = useState({});
const router = useRouter();
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const { t } = useTranslation();
const [parent] = useAutoAnimate();
// Generate columns
const columns = useMemo(() => {
return generateContactTableColumns(searchValue, data, isReadOnly, locale, t);
}, [searchValue, data, isReadOnly, locale, t]);
return generateContactTableColumns(searchValue, data, isReadOnly, t);
}, [searchValue, data, isReadOnly]);
// Load saved settings from localStorage
useEffect(() => {

View File

@@ -1,5 +1,4 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { formatDateForDisplay } from "@/lib/utils/datetime";
/**
* Formats an attribute value for display based on its data type.
@@ -28,11 +27,12 @@ export const formatAttributeValue = (
if (Number.isNaN(date.getTime())) {
return String(value);
}
return formatDateForDisplay(date, locale, {
// Use Intl.DateTimeFormat for locale-aware date formatting
return new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
});
}).format(date);
} catch {
// If date parsing fails, return the raw value
return String(value);

View File

@@ -2,7 +2,7 @@
import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
@@ -11,8 +11,7 @@ interface SegmentActivityTabProps {
}
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const { activeSurveys, inactiveSurveys } = currentSegment;
@@ -44,13 +43,13 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">
{formatDateTimeForDisplay(currentSegment.createdAt, locale)}
{convertDateTimeStringShort(currentSegment.createdAt?.toString())}
</p>
</div>{" "}
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
<p className="text-xs text-slate-700">
{formatDateTimeForDisplay(currentSegment.updatedAt, locale)}
{convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
</p>
</div>
<div>

View File

@@ -1,16 +1,12 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { TFunction } from "i18next";
import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
export const generateSegmentTableColumns = (
t: TFunction,
locale: string
): ColumnDef<TSegmentWithSurveyNames>[] => {
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
id: "title",
accessorKey: "title",
@@ -37,7 +33,11 @@ export const generateSegmentTableColumns = (
accessorKey: "updatedAt",
header: t("common.updated_at"),
cell: ({ row }) => {
return <span className="text-sm text-slate-900">{timeSinceDate(row.original.updatedAt, locale)}</span>;
return (
<span className="text-sm text-slate-900">
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
</span>
);
},
};
@@ -47,13 +47,7 @@ export const generateSegmentTableColumns = (
header: t("common.created_at"),
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">
{formatDateForDisplay(row.original.createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
);
},
};

View File

@@ -1,12 +1,10 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { EditSegmentModal } from "./edit-segment-modal";
type TSegmentTableDataRowProps = {
@@ -26,8 +24,6 @@ export const SegmentTableDataRow = ({
}: TSegmentTableDataRowProps) => {
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
const { i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
return (
<>
@@ -50,16 +46,14 @@ export const SegmentTableDataRow = ({
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(updatedAt, locale)}</div>
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(updatedAt, {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
</div>
</button>

View File

@@ -22,13 +22,12 @@ export function SegmentTable({
isContactsEnabled,
isReadOnly,
}: SegmentTableUpdatedProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns(t, locale);
}, [locale, t]);
return generateSegmentTableColumns(t);
}, []);
const table = useReactTable({
data: segments,

View File

@@ -75,7 +75,6 @@ export async function PreviewEmailTemplate({
survey,
surveyUrl,
styling,
locale,
t,
}: PreviewEmailTemplateProps): Promise<React.JSX.Element> {
const url = `${surveyUrl}?preview=true`;
@@ -86,20 +85,8 @@ export async function PreviewEmailTemplate({
const questions = getElementsFromBlocks(survey.blocks);
const firstQuestion = questions[0];
const headline = parseRecallInfo(
getLocalizedValue(firstQuestion.headline, defaultLanguageCode),
undefined,
undefined,
false,
locale
);
const subheader = parseRecallInfo(
getLocalizedValue(firstQuestion.subheader, defaultLanguageCode),
undefined,
undefined,
false,
locale
);
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
switch (firstQuestion.type) {

View File

@@ -4,7 +4,7 @@ import { Webhook } from "@prisma/client";
import { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { convertDateTimeStringShort } from "@/lib/time";
import { Label } from "@/modules/ui/components/label";
interface ActivityTabProps {
@@ -37,8 +37,7 @@ const convertTriggerIdToName = (triggerId: string, t: TFunction): string => {
};
export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
return (
<div className="grid grid-cols-3 pb-2">
<div className="col-span-2 space-y-4 pr-6">
@@ -82,11 +81,15 @@ export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(webhook.createdAt, locale)}</p>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(webhook.createdAt?.toString())}
</p>
</div>
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(webhook.updatedAt, locale)}</p>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
</p>
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { Webhook } from "@prisma/client";
import { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
@@ -54,10 +55,16 @@ const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
}
};
export const WebhookRowData = ({ webhook, surveys }: { webhook: Webhook; surveys: TSurvey[] }) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
export const WebhookRowData = ({
webhook,
surveys,
locale,
}: {
webhook: Webhook;
surveys: TSurvey[];
locale: TUserLocale;
}) => {
const { t } = useTranslation();
return (
<div className="mt-2 grid h-auto grid-cols-12 content-center rounded-lg py-2 hover:bg-slate-100">
<div className="col-span-3 flex items-center truncate pl-6 text-sm">
@@ -84,7 +91,7 @@ export const WebhookRowData = ({ webhook, surveys }: { webhook: Webhook; surveys
{renderSelectedTriggersText(webhook, t)}
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(webhook.updatedAt.toString(), locale)}
{timeSince(webhook.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>
</div>

View File

@@ -1,4 +1,5 @@
import { getSurveys } from "@/lib/survey/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { AddWebhookButton } from "@/modules/integrations/webhooks/components/add-webhook-button";
@@ -22,6 +23,7 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
]);
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const locale = await findMatchingLocale();
return (
<PageContentWrapper>
@@ -30,7 +32,7 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} locale={locale} />
))}
</WebhookTable>
</PageContentWrapper>

View File

@@ -1,7 +1,7 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUserLocale } from "@/lib/user/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
@@ -12,13 +12,11 @@ import { ApiKeyList } from "./components/api-key-list";
export const APIKeysPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const locale = await findMatchingLocale();
const { currentUserMembership, organization, session } = await getEnvironmentAuth(params.environmentId);
const { currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const [projects, locale] = await Promise.all([
getProjectsByOrganizationId(organization.id),
getUserLocale(session.user.id),
]);
const projects = await getProjectsByOrganizationId(organization.id);
const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
@@ -39,7 +37,7 @@ export const APIKeysPage = async (props: { params: Promise<{ environmentId: stri
description={t("environments.settings.api_keys.api_keys_description")}>
<ApiKeyList
organizationId={organization.id}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
isReadOnly={!canAccessApiKeys}
projects={projects}
/>

View File

@@ -39,8 +39,7 @@ export const MembersInfo = ({
isUserManagementDisabledFromUi,
}: MembersInfoProps) => {
const allMembers = [...members, ...invites];
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const getMembershipBadge = (member: TMember | TInvite) => {
if (isInvitee(member)) {
@@ -49,7 +48,7 @@ export const MembersInfo = ({
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
date: formatDateWithOrdinal(member.expiresAt, locale),
date: formatDateWithOrdinal(member.expiresAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>

View File

@@ -4,9 +4,9 @@ import Link from "next/link";
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getActionClasses } from "@/lib/actionClass/service";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironments } from "@/lib/environment/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
@@ -21,15 +21,17 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
const t = await getTranslate();
const { environmentId } = await params;
const { environment, isReadOnly, session } = await getEnvironmentAuth(environmentId);
const { environment, isReadOnly } = await getEnvironmentAuth(environmentId);
const [environments, actionClasses, locale] = await Promise.all([
const [environments, actionClasses] = await Promise.all([
getEnvironments(environment.projectId),
getActionClasses(environmentId),
getUserLocale(session.user.id),
]);
const otherEnvironment = environments.filter((env) => env.id !== environmentId)[0];
const otherEnvActionClasses = otherEnvironment ? await getActionClasses(otherEnvironment.id) : [];
const [otherEnvActionClasses, locale] = await Promise.all([
otherEnvironment ? getActionClasses(otherEnvironment.id) : Promise.resolve([]),
findMatchingLocale(),
]);
return (
<PageContentWrapper>
@@ -87,7 +89,7 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
environmentId={environmentId}
actionClasses={actionClasses}
isReadOnly={isReadOnly}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>

View File

@@ -5,7 +5,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ACTION_TYPE_ICON_LOOKUP } from "@/modules/projects/settings/(setup)/app-connection/utils";
@@ -32,8 +32,7 @@ export const ActionActivityTab = ({
environment,
isReadOnly,
}: ActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const [activeSurveys, setActiveSurveys] = useState<string[] | undefined>();
const [inactiveSurveys, setInactiveSurveys] = useState<string[] | undefined>();
const [loading, setLoading] = useState(true);
@@ -137,12 +136,16 @@ export const ActionActivityTab = ({
</div>
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(actionClass.createdAt, locale)}</p>
<Label className="text-xs font-normal text-slate-500">Created on</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(actionClass.createdAt?.toString())}
</p>
</div>{" "}
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(actionClass.updatedAt, locale)}</p>
<Label className="text-xs font-normal text-slate-500">Last updated</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(actionClass.updatedAt?.toString())}
</p>
</div>
<div>
<Label className="block text-xs font-normal text-slate-500">Type</Label>

View File

@@ -11,6 +11,8 @@ import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
interface IDateElementFormProps {
localSurvey: TSurvey;
@@ -25,6 +27,21 @@ interface IDateElementFormProps {
isExternalUrlsAllowed?: boolean;
}
const dateOptions = [
{
value: "M-d-y",
label: "MM-DD-YYYY",
},
{
value: "d-M-y",
label: "DD-MM-YYYY",
},
{
value: "y-M-d",
label: "YYYY-MM-DD",
},
];
export const DateElementForm = ({
element,
elementIdx,
@@ -98,6 +115,19 @@ export const DateElementForm = ({
)}
</div>
<div className="mt-3">
<Label htmlFor="elementType">{t("environments.surveys.edit.date_format")}</Label>
<div className="mt-2 flex items-center">
<OptionsSwitch
options={dateOptions}
currentOption={element.format}
handleOptionChange={(value: string) =>
updateElement(elementIdx, { format: value as "M-d-y" | "d-M-y" | "y-M-d" })
}
/>
</div>
</div>
<ValidationRulesEditor
elementType={element.type}
validation={element.validation}

View File

@@ -5,8 +5,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { timeSince } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { convertDateString, timeSince } from "@/lib/time";
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
@@ -83,7 +82,7 @@ export const SurveyCard = ({
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatDateForDisplay(survey.createdAt, locale)}
{convertDateString(survey.createdAt.toString())}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.updatedAt.toString(), locale)}

View File

@@ -163,9 +163,6 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
}
} else {
setIsMultiLanguageActivated(true);
if (!open) {
setOpen(true);
}
}
};

View File

@@ -18,7 +18,6 @@ interface SegmentDetailProps {
onSegmentLoad: (surveyId: string, segmentId: string) => Promise<TSurvey>;
surveyId: string;
currentSegment: TSegment;
locale: string;
}
const SegmentDetail = ({
@@ -29,7 +28,6 @@ const SegmentDetail = ({
onSegmentLoad,
surveyId,
currentSegment,
locale,
}: SegmentDetailProps) => {
const [isLoading, setIsLoading] = useState(false);
const handleLoadNewSegment = async (segmentId: string) => {
@@ -108,11 +106,11 @@ const SegmentDetail = ({
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(segment.updatedAt, locale)}</div>
<div className="ph-no-capture text-slate-900">{timeSinceDate(segment.updatedAt)}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{formatDate(segment.createdAt, locale)}</div>
<div className="ph-no-capture text-slate-900">{formatDate(segment.createdAt)}</div>
</div>
</button>
);
@@ -142,8 +140,7 @@ export const LoadSegmentModal = ({
const handleResetState = () => {
setOpen(false);
};
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const segmentsArray = segments?.filter((segment) => !segment.isPrivate);
return (
@@ -185,7 +182,6 @@ export const LoadSegmentModal = ({
onSegmentLoad={onSegmentLoad}
surveyId={surveyId}
currentSegment={currentSegment}
locale={locale}
/>
))}
</div>

View File

@@ -5,7 +5,6 @@ import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
interface PendingDowngradeBannerProps {
@@ -32,7 +31,7 @@ export const PendingDowngradeBanner = ({
: false;
const scheduledDowngradeDate = new Date(lastChecked.getTime() + threeDaysInMillis);
const formattedDate = formatDateForDisplay(scheduledDowngradeDate, locale, {
const formattedDate = scheduledDowngradeDate.toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",

View File

@@ -40,21 +40,29 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
isLoadingScript = true;
try {
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
const response = await fetch(
scriptUrl,
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
);
if (!response.ok) {
throw new Error("Failed to load the surveys package");
}
// Load the script directly via src to ensure proper execution
// This approach works with CSP and doesn't require blob URLs or eval
await new Promise<void>((resolve, reject) => {
const scriptElement = document.createElement("script");
scriptElement.src = scriptUrl;
scriptElement.type = "text/javascript";
const scriptContent = await response.text();
const scriptElement = document.createElement("script");
// Add cache-busting in development to ensure fresh script loads
if (process.env.NODE_ENV === "development") {
scriptElement.src += `?t=${Date.now()}`;
}
scriptElement.textContent = scriptContent;
scriptElement.onload = () => {
resolve();
};
scriptElement.onerror = () => {
reject(new Error("Failed to load the surveys package"));
};
document.head.appendChild(scriptElement);
});
document.head.appendChild(scriptElement);
setIsScriptLoaded(true);
hasLoadedRef.current = true;
} catch (error) {

View File

@@ -269,6 +269,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click();
await page.getByText("Multiple languages").click();
await page.getByRole("combobox").click();
await page.getByLabel("English (en)").click();
await page.getByRole("button", { name: "Confirm" }).click();

View File

@@ -64,7 +64,7 @@ packages/surveys/
```bash
# packages/surveys/.env
LINGO_API_KEY=<YOUR_API_KEY>
LINGODOTDEV_API_KEY=<YOUR_API_KEY>
```
4. **Generate Translations**

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "preact/hooks";
import { useEffect } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
@@ -66,29 +66,26 @@ export function EndingCard({
</div>
);
const processAndRedirect = useCallback(
(urlString: string) => {
try {
const url = replaceRecallInfo(urlString, responseData, variablesData, languageCode);
if (url && new URL(url)) {
if (onOpenExternalURL) {
onOpenExternalURL(url);
} else {
window.top?.location.replace(url);
}
const processAndRedirect = (urlString: string) => {
try {
const url = replaceRecallInfo(urlString, responseData, variablesData);
if (url && new URL(url)) {
if (onOpenExternalURL) {
onOpenExternalURL(url);
} else {
window.top?.location.replace(url);
}
} catch (error) {
console.error("Invalid URL after recall processing:", error);
}
},
[languageCode, onOpenExternalURL, responseData, variablesData]
);
} catch (error) {
console.error("Invalid URL after recall processing:", error);
}
};
const handleSubmit = useCallback(() => {
const handleSubmit = () => {
if (!isRedirectDisabled && endingCard.type === "endScreen" && endingCard.buttonLink) {
processAndRedirect(endingCard.buttonLink);
}
}, [endingCard, isRedirectDisabled, processAndRedirect]);
};
useEffect(() => {
if (isCurrent) {
@@ -117,15 +114,7 @@ export function EndingCard({
return () => {
document.removeEventListener("keydown", handleEnter);
};
}, [
endingCard,
handleSubmit,
isCurrent,
isRedirectDisabled,
isResponseSendingFinished,
processAndRedirect,
survey.type,
]);
}, [isCurrent, isResponseSendingFinished, isRedirectDisabled, endingCard, survey.type]);
return (
<ScrollableContainer fullSizeCards={fullSizeCards}>
@@ -141,8 +130,7 @@ export function EndingCard({
headline={replaceRecallInfo(
getLocalizedValue(endingCard.headline, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
elementId="EndingCard"
/>
@@ -150,8 +138,7 @@ export function EndingCard({
subheader={replaceRecallInfo(
getLocalizedValue(endingCard.subheader, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
elementId="EndingCard"
/>
@@ -161,8 +148,7 @@ export function EndingCard({
buttonLabel={replaceRecallInfo(
getLocalizedValue(endingCard.buttonLabel, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
isLastQuestion={false}
focus={isCurrent ? autoFocusEnabled : false}

View File

@@ -149,20 +149,14 @@ export function WelcomeCard({
) : null}
<Headline
headline={replaceRecallInfo(
getLocalizedValue(headline, languageCode),
responseData,
variablesData,
languageCode
)}
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData, variablesData)}
elementId="welcomeCard"
/>
<Subheader
subheader={replaceRecallInfo(
getLocalizedValue(subheader, languageCode),
responseData,
variablesData,
languageCode
variablesData
)}
elementId="welcomeCard"
/>

View File

@@ -1,6 +1,18 @@
import { describe, expect, test } from "vitest";
import { formatDateWithOrdinal, getMonthName, getOrdinalDate, isValidDateString } from "./date-time";
// Manually define getOrdinalSuffix for testing as it's not exported
// Or, if preferred, we can test it implicitly via formatDateWithOrdinal and getOrdinalDate
// For direct testing, let's replicate its logic or assume it's tested via the others.
// For this exercise, let's test what's exported and what's critical directly if possible.
// The user snippet included getOrdinalSuffix, so let's assume we can test it.
const getOrdinalSuffix = (day: number): string => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
describe("getMonthName", () => {
test("should return correct month name for en-US", () => {
expect(getMonthName(0)).toBe("January");
@@ -76,30 +88,96 @@ describe("isValidDateString", () => {
});
});
describe("formatDateWithOrdinal", () => {
const getExpectedLocaleDate = (date: Date, locale: string) =>
new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
test("formats survey dates with locale-native en-US output", () => {
const date = new Date(2024, 0, 1);
expect(formatDateWithOrdinal(date, "en-US")).toBe(getExpectedLocaleDate(date, "en-US"));
describe("getOrdinalSuffix (helper)", () => {
test('should return "st" for 1, 21, 31', () => {
expect(getOrdinalSuffix(1)).toBe("st");
expect(getOrdinalSuffix(21)).toBe("st");
expect(getOrdinalSuffix(31)).toBe("st");
});
test("formats survey dates with locale-native fr-FR output", () => {
const date = new Date(2024, 0, 1);
expect(formatDateWithOrdinal(date, "fr-FR")).toBe(getExpectedLocaleDate(date, "fr-FR"));
test('should return "nd" for 2, 22', () => {
expect(getOrdinalSuffix(2)).toBe("nd");
expect(getOrdinalSuffix(22)).toBe("nd");
expect(getOrdinalSuffix(32)).toBe("nd"); // Test for day >= 30 leading to relevantDigits = 2
});
test("formats survey dates with locale-native de-DE output", () => {
const date = new Date(2024, 2, 20);
test('should return "rd" for 3, 23', () => {
expect(getOrdinalSuffix(3)).toBe("rd");
expect(getOrdinalSuffix(23)).toBe("rd");
expect(getOrdinalSuffix(33)).toBe("rd"); // Test for day >= 30 leading to relevantDigits = 3
});
expect(formatDateWithOrdinal(date, "de-DE")).toBe(getExpectedLocaleDate(date, "de-DE"));
test('should return "th" for 4-20, 24-30, and 11, 12, 13 variants', () => {
expect(getOrdinalSuffix(4)).toBe("th");
expect(getOrdinalSuffix(11)).toBe("th");
expect(getOrdinalSuffix(12)).toBe("th");
expect(getOrdinalSuffix(13)).toBe("th");
expect(getOrdinalSuffix(19)).toBe("th");
expect(getOrdinalSuffix(20)).toBe("th");
expect(getOrdinalSuffix(24)).toBe("th");
expect(getOrdinalSuffix(29)).toBe("th"); // Added for explicit boundary coverage
expect(getOrdinalSuffix(30)).toBe("th");
});
});
describe("formatDateWithOrdinal", () => {
test("should format date correctly for en-US", () => {
// Test with a few specific dates
// Monday, January 1st, 2024
const date1 = new Date(2024, 0, 1);
expect(formatDateWithOrdinal(date1)).toBe("Monday, January 1st, 2024");
// Wednesday, February 22nd, 2023
const date2 = new Date(2023, 1, 22);
expect(formatDateWithOrdinal(date2)).toBe("Wednesday, February 22nd, 2023");
// Sunday, March 13th, 2022
const date3 = new Date(2022, 2, 13);
expect(formatDateWithOrdinal(date3)).toBe("Sunday, March 13th, 2022");
});
test("should format date correctly for a different locale (fr-FR)", () => {
const date1 = new Date(2024, 0, 1);
// The exact output depends on Intl and Node version, it might include periods or different capitalization.
// For consistency, we'll check for key parts.
// A more robust test might involve mocking Intl.DateTimeFormat if very specific output is needed across environments.
const formattedDate1 = formatDateWithOrdinal(date1, "fr-FR");
expect(formattedDate1).toContain("lundi"); // Day of week
expect(formattedDate1).toContain("janvier"); // Month
expect(formattedDate1).toContain("1st"); // Given English-specific getOrdinalSuffix, this will be '1st'
expect(formattedDate1).toContain("2024"); // Year
// mardi 14 février 2023
const date2 = new Date(2023, 1, 14); // 14th
const formattedDate2 = formatDateWithOrdinal(date2, "fr-FR");
expect(formattedDate2).toContain("mardi");
expect(formattedDate2).toContain("février");
// French ordinals for other numbers usually don't have a special suffix like 'th' visible in the number itself
// The getOrdinalSuffix in the original code is very English-centric.
// For 'fr-FR', getOrdinalSuffix(14) -> 'th'. So it becomes '14th'. This part of the test might need adjustment
// based on how getOrdinalSuffix is supposed to behave with locales.
// Given the current getOrdinalSuffix, it will append 'th'.
expect(formattedDate2).toContain("14th");
expect(formattedDate2).toContain("2023");
});
test("should handle the 1st with French locale (specific check for 1er)", () => {
const date = new Date(2024, 0, 1); // January 1st
// The original getOrdinalSuffix is English-specific. It will produce '1st'.
// A truly internationalized getOrdinalSuffix would be needed for '1er'.
// The current formatDateWithOrdinal will use the English 'st', 'nd', 'rd', 'th'.
// This test reflects the current implementation's behavior.
expect(formatDateWithOrdinal(date, "fr-FR")).toBe("lundi, janvier 1st, 2024");
});
test("should handle other dates with French locale", () => {
const date = new Date(2024, 0, 2); // January 2nd
expect(formatDateWithOrdinal(date, "fr-FR")).toBe("mardi, janvier 2nd, 2024");
const date3 = new Date(2024, 0, 3); // January 3rd
expect(formatDateWithOrdinal(date3, "fr-FR")).toBe("mercredi, janvier 3rd, 2024");
const date4 = new Date(2024, 0, 4); // January 4th
expect(formatDateWithOrdinal(date4, "fr-FR")).toBe("jeudi, janvier 4th, 2024");
});
});

View File

@@ -37,11 +37,16 @@ export const isValidDateString = (value: string) => {
return !isNaN(date.getTime());
};
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
return new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
const getOrdinalSuffix = (day: number): string => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
const day = date.getDate();
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
const year = date.getFullYear();
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};

View File

@@ -15,10 +15,8 @@ vi.mock("./i18n", () => ({
// Mock date-time functions as they are used internally and we want to isolate recall logic
vi.mock("./date-time", () => ({
isValidDateString: (val: string) => /^\d{4}-\d{2}-\d{2}$/.test(val) || /^\d{2}-\d{2}-\d{4}$/.test(val),
formatDateWithOrdinal: vi.fn(
(date: Date) =>
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`
),
formatDateWithOrdinal: (date: Date) =>
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`,
}));
describe("replaceRecallInfo", () => {
@@ -73,15 +71,6 @@ describe("replaceRecallInfo", () => {
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
test("should pass the selected survey language to date formatting", async () => {
const { formatDateWithOrdinal } = await import("./date-time");
const text = "Registered on: #recall:registrationDate/fallback:N/A#.";
replaceRecallInfo(text, responseData, variables, "fr-FR");
expect(vi.mocked(formatDateWithOrdinal)).toHaveBeenCalledWith(expect.any(Date), "fr-FR");
});
test("should join array values with a comma and space", () => {
const text = "Tags: #recall:tags/fallback:none#.";
const expected = "Tags: beta, user.";

View File

@@ -29,8 +29,7 @@ const extractRecallInfo = (headline: string, id?: string): string | null => {
export const replaceRecallInfo = (
text: string,
responseData: TResponseData,
variables: TResponseVariables,
locale: string = "en-US"
variables: TResponseVariables
): string => {
let modifiedText = text;
@@ -57,7 +56,7 @@ export const replaceRecallInfo = (
// Additional value formatting if it exists
if (value) {
if (isValidDateString(value)) {
value = formatDateWithOrdinal(new Date(value), locale);
value = formatDateWithOrdinal(new Date(value));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", "); // Filters out empty values and joins with a comma
}
@@ -81,8 +80,7 @@ export const parseRecallInformation = (
modifiedQuestion.headline[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.headline, languageCode),
responseData,
variables,
languageCode
variables
);
}
if (
@@ -93,8 +91,7 @@ export const parseRecallInformation = (
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.subheader, languageCode),
responseData,
variables,
languageCode
variables
);
}
if (
@@ -106,8 +103,7 @@ export const parseRecallInformation = (
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.subheader, languageCode),
responseData,
variables,
languageCode
variables
);
}
return modifiedQuestion;