Compare commits

...

8 Commits

Author SHA1 Message Date
pandeymangg aba02cf62c Trigger Checks 2026-03-24 12:20:28 +05:30
pandeymangg 5d166cae8b fixes merge conflicts 2026-03-23 17:05:53 +05:30
Matti Nannt 645f0ab0d1 fix: resolve remaining dependabot alerts (#7561) 2026-03-23 09:59:01 +00:00
Johannes 389a7d9e7b feat: enhance segment activity summary and settings in segment modal (#7553)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-23 08:39:10 +00:00
Tiago c4cf468c7e fix: localize survey and app date rendering (#7473)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 07:23:07 +00:00
Johannes cbc3e923e4 fix: segment targeting "isNotIn" didnt work (#7550) 2026-03-23 05:22:19 +00:00
Aryan 8d0847bb9a Requested Changes 2026-03-17 21:45:53 +05:30
Aryan 6c871b5cd5 feat: enhance welcome card to support video uploads and display 2026-03-17 21:45:53 +05:30
92 changed files with 1412 additions and 995 deletions
+8
View File
@@ -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>;
},
};
@@ -7,7 +7,6 @@ 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 +22,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 +84,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
locale={user.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,14 @@ 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_with_value", {
value,
})
);
};
return (
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
<div className="px-4 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
@@ -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>
+2 -2
View File
@@ -147,6 +147,7 @@ checksums:
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/copy_to_environment: c482d26b8fd4962af6542bbf49e49a32
common/count_attributes: 48805e836a9b50f9635ad00fed953058
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
@@ -228,7 +229,7 @@ checksums:
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf
common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313
common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e
common/invite: 181884cea804cbde665f160811ee7ad0
@@ -1344,7 +1345,6 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
+28 -55
View File
@@ -1,62 +1,13 @@
import { describe, expect, test } from "vitest";
import {
convertDateString,
convertDateTimeString,
convertDateTimeStringShort,
convertDatesInObject,
convertTimeString,
formatDate,
getTodaysDateFormatted,
getTodaysDateTimeFormatted,
timeSince,
timeSinceDate,
} from "./time";
describe("Time Utilities", () => {
describe("convertDateString", () => {
test("should format date string correctly", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
test("should return null for null input", () => {
expect(convertDateString(null as any)).toBe(null);
});
test("should handle invalid date strings", () => {
expect(convertDateString("not-a-date")).toBe("Invalid Date");
});
});
describe("convertDateTimeString", () => {
test("should format date and time string correctly", () => {
expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeString("")).toBe("");
});
});
describe("convertDateTimeStringShort", () => {
test("should format date and time string in short format", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe("");
});
});
describe("convertTimeString", () => {
test("should format time string correctly", () => {
expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM");
});
});
describe("timeSince", () => {
test("should format time since in English", () => {
const now = new Date();
@@ -75,6 +26,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 +46,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,13 +59,17 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024");
});
});
describe("getTodaysDateFormatted", () => {
test("should format today's date with specified separator", () => {
const today = new Date();
const expected = today.toISOString().split("T")[0].split("-").join(".");
expect(getTodaysDateFormatted(".")).toBe(expected);
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)
);
});
});
+27 -120
View File
@@ -1,120 +1,33 @@
import { formatDistance, intlFormat } from "date-fns";
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
export const convertDateString = (dateString: string | null) => {
if (dateString === null) return null;
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return intlFormat(
date,
{
year: "numeric",
month: "short",
day: "numeric",
},
{
locale: "en",
}
);
const DEFAULT_LOCALE: TUserLocale = "en-US";
const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"de-DE": de,
"en-US": enUS,
"es-ES": es,
"fr-FR": fr,
"hu-HU": hu,
"ja-JP": ja,
"nl-NL": nl,
"pt-BR": ptBR,
"pt-PT": pt,
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
export const convertDateTimeString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale);
export const convertDateTimeStringShort = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
/** Maps locale strings to date-fns locales and falls back to English for unsupported inputs. */
const getLocaleForTimeSince = (locale: string): Locale =>
isUserLocale(locale) ? TIME_SINCE_LOCALES[locale] : enUS;
export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
{
hour: "numeric",
minute: "2-digit",
second: "2-digit",
},
{
locale: "en",
}
);
};
const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) {
case "de-DE":
return de;
case "en-US":
return enUS;
case "es-ES":
return es;
case "fr-FR":
return fr;
case "hu-HU":
return hu;
case "ja-JP":
return ja;
case "nl-NL":
return nl;
case "pt-BR":
return ptBR;
case "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
}
};
export const timeSince = (dateString: string, locale: TUserLocale) => {
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
@@ -122,27 +35,21 @@ export const timeSince = (dateString: string, locale: TUserLocale) => {
});
};
export const timeSinceDate = (date: Date) => {
export const timeSinceDate = (date: Date, locale: 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: string = DEFAULT_LOCALE) => {
return formatDateForDisplay(date, locale, {
year: "numeric",
month: "long",
day: "numeric",
});
};
export const getTodaysDateFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
return formattedDate;
};
export const getTodaysDateTimeFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
+67
View 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",
});
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
const ISO_STORED_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const buildDate = (year: number, month: number, day: number): Date | null => {
if ([year, month, day].some((value) => Number.isNaN(value))) {
return null;
}
const parsedDate = new Date(year, month - 1, day);
if (
parsedDate.getFullYear() !== year ||
parsedDate.getMonth() !== month - 1 ||
parsedDate.getDate() !== day
) {
return null;
}
return parsedDate;
};
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
const parts = value.split("-");
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
return null;
}
const [first, second, third] = parts.map(Number);
switch (format) {
case "M-d-y":
return buildDate(third, first, second);
case "d-M-y":
return buildDate(third, second, first);
case "y-M-d":
return buildDate(first, second, third);
}
};
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
const isoMatch = ISO_STORED_DATE_PATTERN.exec(value);
if (isoMatch) {
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
}
if (format) {
return parseLegacyStoredDateValue(value, format);
}
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
return parseLegacyStoredDateValue(value, "d-M-y");
}
return null;
};
export const formatStoredDateForDisplay = (
value: string,
format: TSurveyDateElement["format"] | undefined,
locale: string = "en-US"
): string | null => {
const parsedDate = parseStoredDateValue(value, format);
if (!parsedDate) {
return null;
}
return formatDateWithOrdinal(parsedDate, locale);
};
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
if (element.type === "date") {
dateFormats[element.id] = element.format;
}
return dateFormats;
}, {});
};
+43 -4
View File
@@ -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", () => {
+44 -13
View File
@@ -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 => {
+24 -10
View File
@@ -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", () => {
+11 -7
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 { 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(", ");
}
}
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Kopieren",
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"copy_to_environment": "In {{environment}} kopieren",
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inaktive Umfragen",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date": "Ungültiges Datum",
"invalid_date_with_value": "Ungültiges Datum: {value}",
"invalid_file_name": "Ungültiger Dateiname, bitte benennen Sie Ihre Datei um und versuchen Sie es erneut",
"invalid_file_type": "Ungültiger Dateityp",
"invite": "Einladen",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Copy",
"copy_code": "Copy code",
"copy_link": "Copy Link",
"copy_to_environment": "Copy to {{environment}}",
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inactive surveys",
"integration": "integration",
"integrations": "Integrations",
"invalid_date": "Invalid date",
"invalid_date_with_value": "Invalid date: {value}",
"invalid_file_name": "Invalid file name, please rename your file and try again",
"invalid_file_type": "Invalid file type",
"invite": "Invite",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Custom hostname",
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar enlace",
"copy_to_environment": "Copiar a {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Encuestas inactivas",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date": "Fecha no válida",
"invalid_date_with_value": "Fecha no válida: {value}",
"invalid_file_name": "Nombre de archivo no válido, por favor renombre su archivo e inténtelo de nuevo",
"invalid_file_type": "Tipo de archivo no válido",
"invite": "Invitar",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Nombre de host personalizado",
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Copier",
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"copy_to_environment": "Copier vers {{environment}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Sondages inactifs",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date": "Date invalide",
"invalid_date_with_value": "Date invalide: {value}",
"invalid_file_name": "Nom de fichier invalide, veuillez renommer votre fichier et réessayer",
"invalid_file_type": "Type de fichier invalide",
"invite": "Inviter",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Nom d'hôte personnalisé",
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
"delete_anyways": "Supprimer quand même",
"delete_block": "Supprimer le bloc",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Másolás",
"copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása",
"copy_to_environment": "Másolás ide: {{environment}}",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inaktív kérdőívek",
"integration": "integráció",
"integrations": "Integrációk",
"invalid_date": "Érvénytelen dátum",
"invalid_date_with_value": "Érvénytelen dátum: {value}",
"invalid_file_name": "Érvénytelen fájlnév, nevezze át a fájlt, és próbálja újra",
"invalid_file_type": "Érvénytelen fájltípus",
"invite": "Meghívás",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Egyéni gépnév",
"customize_survey_logo": "A kérdőív logójának személyre szabása",
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
"date_format": "Dátumformátum",
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
"delete_anyways": "Törlés mindenképp",
"delete_block": "Blokk törlése",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"copy_to_environment": "{{environment}} にコピー",
"count_attributes": "{count, plural, other {{count} 個の属性}}",
"count_contacts": "{count, plural, other {{count} 件の連絡先}}",
"count_members": "{count, plural, other {{count} 名のメンバー}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "非アクティブなフォーム",
"integration": "連携",
"integrations": "連携",
"invalid_date": "無効な日付です",
"invalid_date_with_value": "無効な日付です: {value}",
"invalid_file_name": "ファイル名が無効です。ファイル名を変更して再試行してください",
"invalid_file_type": "無効なファイルタイプです",
"invite": "招待",
@@ -1421,7 +1422,6 @@
"custom_hostname": "カスタムホスト名",
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
"delete_anyways": "削除する",
"delete_block": "ブロックを削除",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Kopiëren",
"copy_code": "Kopieer code",
"copy_link": "Kopieer link",
"copy_to_environment": "Kopiëren naar {{environment}}",
"count_attributes": "{count, plural, one {{count} attribuut} other {{count} attributen}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacten}}",
"count_members": "{count, plural, one {{count} lid} other {{count} leden}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inactieve enquêtes",
"integration": "integratie",
"integrations": "Integraties",
"invalid_date": "Ongeldige datum",
"invalid_date_with_value": "Ongeldige datum: {value}",
"invalid_file_name": "Ongeldige bestandsnaam. Hernoem uw bestand en probeer het opnieuw",
"invalid_file_type": "Ongeldig bestandstype",
"invite": "Uitnodiging",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Aangepaste hostnaam",
"customize_survey_logo": "Pas het enquêtelogo aan",
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
"date_format": "Datumformaat",
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
"delete_anyways": "Toch verwijderen",
"delete_block": "Blok verwijderen",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"copy_to_environment": "Copiar para {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contato} other {{count} contatos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Pesquisas inativas",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
"invalid_date_with_value": "Data inválida: {value}",
"invalid_file_name": "Nome de arquivo inválido, por favor renomeie seu arquivo e tente novamente",
"invalid_file_type": "Tipo de arquivo inválido",
"invite": "convidar",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Hostname personalizado",
"customize_survey_logo": "Personalizar o logo da pesquisa",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
"delete_anyways": "Excluir mesmo assim",
"delete_block": "Excluir bloco",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"copy_to_environment": "Copiar para {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inquéritos inativos",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
"invalid_date_with_value": "Data inválida: {value}",
"invalid_file_name": "Nome de ficheiro inválido, por favor renomeie o seu ficheiro e tente novamente",
"invalid_file_type": "Tipo de ficheiro inválido",
"invite": "Convidar",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Nome do host personalizado",
"customize_survey_logo": "Personalizar o logótipo do inquérito",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
"delete_anyways": "Eliminar mesmo assim",
"delete_block": "Eliminar bloco",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Copiază",
"copy_code": "Copiază codul",
"copy_link": "Copiază legătura",
"copy_to_environment": "Copiază în {{environment}}",
"count_attributes": "{count, plural, one {{count} atribut} few {{count} atribute} other {{count} de atribute}}",
"count_contacts": "{count, plural, one {{count} contact} few {{count} contacte} other {{count} de contacte}}",
"count_members": "{count, plural, one {{count} membru} few {{count} membri} other {{count} de membri}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Sondaje inactive",
"integration": "integrare",
"integrations": "Integrări",
"invalid_date": "Dată invalidă",
"invalid_date_with_value": "Dată invalidă: {value}",
"invalid_file_name": "Nume de fișier invalid, vă rugăm să redenumiți fișierul și să încercați din nou",
"invalid_file_type": "Tip de fișier nevalid",
"invite": "Invită",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Gazdă personalizată",
"customize_survey_logo": "Personalizează logo-ul chestionarului",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
"delete_anyways": "Șterge oricum",
"delete_block": "Șterge blocul",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Копировать",
"copy_code": "Скопировать код",
"copy_link": "Скопировать ссылку",
"copy_to_environment": "Копировать в {{environment}}",
"count_attributes": "{count, plural, one {{count} атрибут} few {{count} атрибута} many {{count} атрибутов} other {{count} атрибута}}",
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контакта}}",
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Неактивные опросы",
"integration": "интеграция",
"integrations": "Интеграции",
"invalid_date": "Неверная дата",
"invalid_date_with_value": "Неверная дата: {value}",
"invalid_file_name": "Недопустимое имя файла, переименуйте файл и попробуйте снова",
"invalid_file_type": "Недопустимый тип файла",
"invite": "Пригласить",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Пользовательский хостнейм",
"customize_survey_logo": "Настроить логотип опроса",
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
"date_format": "Формат даты",
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
"delete_anyways": "Удалить в любом случае",
"delete_block": "Удалить блок",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "Kopiera",
"copy_code": "Kopiera kod",
"copy_link": "Kopiera länk",
"copy_to_environment": "Kopiera till {{environment}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attribut}}",
"count_contacts": "{count, plural, one {{count} kontakt} other {{count} kontakter}}",
"count_members": "{count, plural, one {{count} medlem} other {{count} medlemmar}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inaktiva enkäter",
"integration": "integration",
"integrations": "Integrationer",
"invalid_date": "Ogiltigt datum",
"invalid_date_with_value": "Ogiltigt datum: {value}",
"invalid_file_name": "Ogiltigt filnamn, vänligen byt namn på din fil och försök igen",
"invalid_file_type": "Ogiltig filtyp",
"invite": "Bjud in",
@@ -1421,7 +1422,6 @@
"custom_hostname": "Anpassat värdnamn",
"customize_survey_logo": "Anpassa undersökningens logotyp",
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
"date_format": "Datumformat",
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
"delete_anyways": "Ta bort ändå",
"delete_block": "Ta bort block",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"copy_to_environment": "复制到{{environment}}",
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人} }",
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "不 活跃 调查",
"integration": "集成",
"integrations": "集成",
"invalid_date": "无效 日期",
"invalid_date_with_value": "无效 日期: {value}",
"invalid_file_name": "文件名无效,请重命名文件后重试",
"invalid_file_type": "无效 的 文件 类型",
"invite": "邀请",
@@ -1421,7 +1422,6 @@
"custom_hostname": "自 定 义 主 机 名",
"customize_survey_logo": "自定义调查 logo",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
"delete_anyways": "仍然删除",
"delete_block": "删除区块",
+2 -2
View File
@@ -174,6 +174,7 @@
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"copy_to_environment": "複製到{{environment}}",
"count_attributes": "{count, plural, other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 位聯絡人}}",
"count_members": "{count, plural, other {{count} 位成員}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "停用中的問卷",
"integration": "整合",
"integrations": "整合",
"invalid_date": "無效日期",
"invalid_date_with_value": "無效日期: {value}",
"invalid_file_name": "檔案名稱無效,請重新命名檔案後再試一次",
"invalid_file_type": "無效的檔案類型",
"invite": "邀請",
@@ -1421,7 +1422,6 @@
"custom_hostname": "自訂主機名稱",
"customize_survey_logo": "自訂問卷標誌",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
"delete_anyways": "仍要刪除",
"delete_block": "刪除區塊",
@@ -1,6 +1,6 @@
import { Languages } from "lucide-react";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
@@ -18,11 +18,7 @@ interface LanguageDropdownProps {
locale: TUserLocale;
}
export const LanguageDropdown = ({
survey,
setLanguage,
locale,
}: LanguageDropdownProps) => {
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
const { t } = useTranslation();
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
@@ -33,7 +29,10 @@ export const LanguageDropdown = ({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" title={t("common.select_language")} aria-label={t("common.select_language")}>
<Button
variant="secondary"
title={t("common.select_language")}
aria-label={t("common.select_language")}>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
@@ -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,7 +10,8 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const [contact, attributesWithKeyInfo] = await Promise.all([
const [locale, contact, attributesWithKeyInfo] = await Promise.all([
getLocale(),
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);
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import {
Dialog,
@@ -15,23 +15,63 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { SegmentActivityTab } from "./segment-activity-tab";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface EditSegmentModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
}
const SegmentSettingsTab = ({
activitySummary,
contactAttributeKeys,
currentSegment,
environmentId,
isContactsEnabled,
isReadOnly,
segments,
setOpen,
}: Pick<
EditSegmentModalProps,
| "activitySummary"
| "contactAttributeKeys"
| "currentSegment"
| "environmentId"
| "isContactsEnabled"
| "isReadOnly"
| "segments"
| "setOpen"
>) => {
if (!isContactsEnabled) {
return null;
}
return (
<SegmentSettings
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
};
export const EditSegmentModal = ({
environmentId,
open,
setOpen,
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
@@ -40,31 +80,25 @@ export const EditSegmentModal = ({
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const SettingsTab = () => {
if (isContactsEnabled) {
return (
<SegmentSettings
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
}
return null;
};
const tabs = [
{
title: t("common.activity"),
children: <SegmentActivityTab currentSegment={currentSegment} />,
children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
},
{
title: t("common.settings"),
children: <SettingsTab />,
children: (
<SegmentSettingsTab
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
currentSegment={currentSegment}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
segments={segments}
setOpen={setOpen}
/>
),
},
];
@@ -1,19 +1,22 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { convertDateTimeStringShort } from "@/lib/time";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface SegmentActivityTabProps {
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
}
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
const { t } = useTranslation();
export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { activeSurveys, inactiveSurveys } = currentSegment;
const { activeSurveys, inactiveSurveys } = activitySummary;
return (
<div className="grid grid-cols-3 pb-2">
@@ -22,20 +25,20 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<Label className="text-slate-500">{t("common.active_surveys")}</Label>
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{activeSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{activeSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
<div>
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{inactiveSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{inactiveSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
</div>
@@ -43,13 +46,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>
@@ -0,0 +1,340 @@
import { describe, expect, test } from "vitest";
import { TBaseFilters, TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
buildSegmentActivitySummary,
buildSegmentActivitySummaryFromSegments,
doesSegmentReferenceSegment,
getReferencingSegments,
} from "./segment-activity-utils";
const createSurvey = (overrides: Partial<TSurvey>): TSurvey =>
({
id: "survey_1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: "env_1",
status: "inProgress",
welcomeCard: {
enabled: false,
headline: {},
html: {},
fileUrl: "",
buttonLabel: {},
timeToFinish: false,
},
questions: [],
hiddenFields: { enabled: false, fieldIds: [] },
endings: [],
autoClose: null,
displayOption: "displayOnce",
displayPercentage: null,
recontactDays: null,
displayLimit: null,
delay: 0,
autoComplete: null,
triggers: [],
styling: null,
surveyClosedMessage: null,
segment: null,
segmentId: null,
projectOverwrites: null,
singleUse: null,
pin: null,
redirectUrl: null,
displayStatus: null,
displayCount: null,
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
recaptcha: null,
variables: [],
blocks: undefined,
followUps: [],
verifyEmailTemplateId: null,
...overrides,
}) as TSurvey;
const createSegment = (overrides: Partial<TSegment>): TSegment =>
({
id: "segment_1",
title: "Segment 1",
description: null,
isPrivate: false,
environmentId: "env_1",
createdAt: new Date(),
updatedAt: new Date(),
surveys: [],
filters: [],
...overrides,
}) as TSegment;
const createSegmentWithSurveyNames = (overrides: Partial<TSegmentWithSurveyRefs>): TSegmentWithSurveyRefs =>
({
...createSegment(overrides),
activeSurveys: [],
inactiveSurveys: [],
...overrides,
}) as TSegmentWithSurveyRefs;
describe("segment activity utils", () => {
test("doesSegmentReferenceSegment returns true for nested segment filters", () => {
const filters: TBaseFilters = [
{
id: "group_1",
connector: null,
resource: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsNotIn",
},
},
},
],
},
];
expect(doesSegmentReferenceSegment(filters, "segment_target")).toBe(true);
expect(doesSegmentReferenceSegment(filters, "segment_other")).toBe(false);
});
test("getReferencingSegments excludes the current segment and returns only matching segments", () => {
const segments = [
createSegment({ id: "segment_target" }),
createSegment({
id: "segment_ref",
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsIn",
},
},
},
],
}),
createSegment({
id: "segment_other",
filters: [
{
id: "filter_2",
connector: null,
resource: {
id: "attribute_filter_1",
root: {
type: "attribute",
contactAttributeKey: "plan",
},
value: "enterprise",
qualifier: {
operator: "equals",
},
},
},
],
}),
] as TSegmentWithSurveyRefs[];
expect(getReferencingSegments(segments, "segment_target").map((segment) => segment.id)).toEqual([
"segment_ref",
]);
});
test("buildSegmentActivitySummary returns direct surveys grouped by status", () => {
const directSurveys = [
createSurvey({
id: "survey_direct",
name: "Direct Survey",
status: "inProgress",
}),
createSurvey({
id: "survey_draft",
name: "Draft Survey",
status: "draft",
}),
];
expect(buildSegmentActivitySummary(directSurveys, [])).toEqual({
activeSurveys: ["Direct Survey"],
inactiveSurveys: ["Draft Survey"],
});
});
test("buildSegmentActivitySummary includes indirect surveys when there is no direct match", () => {
const indirectSurveyGroups = [
{
segmentId: "segment_ref",
segmentTitle: "Referenced Segment",
surveys: [
createSurvey({
id: "survey_draft",
name: "Draft Survey",
status: "draft",
}),
],
},
];
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
activeSurveys: [],
inactiveSurveys: ["Draft Survey"],
});
});
test("buildSegmentActivitySummary prefers direct surveys over indirect duplicates", () => {
const directSurveys = [
createSurvey({
id: "survey_shared",
name: "Shared Survey",
status: "inProgress",
}),
];
const indirectSurveyGroups = [
{
segmentId: "segment_ref",
segmentTitle: "Referenced Segment",
surveys: [
createSurvey({
id: "survey_shared",
name: "Shared Survey",
status: "inProgress",
}),
],
},
];
expect(buildSegmentActivitySummary(directSurveys, indirectSurveyGroups)).toEqual({
activeSurveys: ["Shared Survey"],
inactiveSurveys: [],
});
});
test("buildSegmentActivitySummary deduplicates indirect surveys referenced by multiple segments", () => {
const indirectSurveyGroups = [
{
segmentId: "segment_ref_1",
segmentTitle: "Referenced Segment 1",
surveys: [
createSurvey({
id: "survey_indirect",
name: "Indirect Survey",
status: "paused",
}),
],
},
{
segmentId: "segment_ref_2",
segmentTitle: "Referenced Segment 2",
surveys: [
createSurvey({
id: "survey_indirect",
name: "Indirect Survey",
status: "paused",
}),
],
},
];
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
activeSurveys: [],
inactiveSurveys: ["Indirect Survey"],
});
});
test("buildSegmentActivitySummaryFromSegments merges direct and indirect surveys from segment table data", () => {
const currentSegment = createSegmentWithSurveyNames({
id: "segment_target",
activeSurveys: [{ id: "survey_direct", name: "Direct Survey" }],
inactiveSurveys: [{ id: "survey_paused", name: "Paused Survey" }],
});
const segments = [
currentSegment,
createSegmentWithSurveyNames({
id: "segment_ref",
title: "Referenced Segment",
activeSurveys: [{ id: "survey_indirect", name: "Indirect Survey" }],
inactiveSurveys: [{ id: "survey_paused", name: "Paused Survey" }],
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsIn",
},
},
},
],
}),
];
expect(buildSegmentActivitySummaryFromSegments(currentSegment, segments)).toEqual({
activeSurveys: ["Direct Survey", "Indirect Survey"],
inactiveSurveys: ["Paused Survey"],
});
});
test("buildSegmentActivitySummaryFromSegments includes indirect usage from private survey segments", () => {
const currentSegment = createSegmentWithSurveyNames({
id: "segment_target",
});
const privateReferencingSegment = createSegmentWithSurveyNames({
id: "segment_private_ref",
title: "Private Survey Segment",
isPrivate: true,
activeSurveys: [{ id: "survey_private", name: "Indirect Private Survey" }],
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsNotIn",
},
},
},
],
});
expect(
buildSegmentActivitySummaryFromSegments(currentSegment, [currentSegment, privateReferencingSegment])
).toEqual({
activeSurveys: ["Indirect Private Survey"],
inactiveSurveys: [],
});
});
});
@@ -0,0 +1,99 @@
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
type TSurveySummary = Pick<TSurvey, "id" | "name" | "status">;
type TReferencingSegmentSurveyGroup = {
segmentId: string;
segmentTitle: string;
surveys: TSurveySummary[];
};
export type TSegmentActivitySummary = {
activeSurveys: string[];
inactiveSurveys: string[];
};
export const doesSegmentReferenceSegment = (filters: TBaseFilters, targetSegmentId: string): boolean => {
for (const filter of filters) {
const { resource } = filter;
if (Array.isArray(resource)) {
if (doesSegmentReferenceSegment(resource, targetSegmentId)) {
return true;
}
continue;
}
if (resource.root.type === "segment" && resource.root.segmentId === targetSegmentId) {
return true;
}
}
return false;
};
export const getReferencingSegments = (
segments: TSegmentWithSurveyRefs[],
targetSegmentId: string
): TSegmentWithSurveyRefs[] =>
segments.filter(
(segment) =>
segment.id !== targetSegmentId && doesSegmentReferenceSegment(segment.filters, targetSegmentId)
);
export const buildSegmentActivitySummary = (
directSurveys: TSurveySummary[],
indirectSurveyGroups: TReferencingSegmentSurveyGroup[]
): TSegmentActivitySummary => {
const surveyMap = new Map<string, TSurveySummary>();
for (const survey of directSurveys) {
surveyMap.set(survey.id, survey);
}
for (const segment of indirectSurveyGroups) {
for (const survey of segment.surveys) {
if (!surveyMap.has(survey.id)) {
surveyMap.set(survey.id, survey);
}
}
}
const surveys = Array.from(surveyMap.values());
return {
activeSurveys: surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name),
inactiveSurveys: surveys
.filter((survey) => survey.status === "draft" || survey.status === "paused")
.map((survey) => survey.name),
};
};
export const buildSegmentActivitySummaryFromSegments = (
currentSegment: TSegmentWithSurveyRefs,
segments: TSegmentWithSurveyRefs[]
): TSegmentActivitySummary => {
const activeSurveyMap = new Map(currentSegment.activeSurveys.map((s) => [s.id, s.name]));
const inactiveSurveyMap = new Map(currentSegment.inactiveSurveys.map((s) => [s.id, s.name]));
const allDirectIds = new Set([...activeSurveyMap.keys(), ...inactiveSurveyMap.keys()]);
const referencingSegments = getReferencingSegments(segments, currentSegment.id);
for (const segment of referencingSegments) {
for (const survey of segment.activeSurveys) {
if (!allDirectIds.has(survey.id) && !activeSurveyMap.has(survey.id)) {
activeSurveyMap.set(survey.id, survey.name);
}
}
for (const survey of segment.inactiveSurveys) {
if (!allDirectIds.has(survey.id) && !inactiveSurveyMap.has(survey.id)) {
inactiveSurveyMap.set(survey.id, survey.name);
}
}
}
return {
activeSurveys: Array.from(activeSurveyMap.values()),
inactiveSurveys: Array.from(inactiveSurveyMap.values()),
};
};
@@ -6,7 +6,7 @@ import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import type { TBaseFilter, TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -16,18 +16,21 @@ import { Button } from "@/modules/ui/components/button";
import { ConfirmDeleteSegmentModal } from "@/modules/ui/components/confirm-delete-segment-modal";
import { Input } from "@/modules/ui/components/input";
import { AddFilterModal } from "./add-filter-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
import { SegmentEditor } from "./segment-editor";
interface TSegmentSettingsTabProps {
activitySummary: TSegmentActivitySummary;
environmentId: string;
setOpen: (open: boolean) => void;
initialSegment: TSegmentWithSurveyNames;
initialSegment: TSegmentWithSurveyRefs;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isReadOnly: boolean;
}
export function SegmentSettings({
activitySummary,
environmentId,
initialSegment,
setOpen,
@@ -38,7 +41,7 @@ export function SegmentSettings({
const router = useRouter();
const { t } = useTranslation();
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegmentWithSurveyNames>(initialSegment);
const [segment, setSegment] = useState<TSegmentWithSurveyRefs>(initialSegment);
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
const [isDeletingSegment, setIsDeletingSegment] = useState(false);
@@ -260,9 +263,9 @@ export function SegmentSettings({
{isDeleteSegmentModalOpen ? (
<ConfirmDeleteSegmentModal
activitySummary={activitySummary}
onDelete={handleDeleteSegment}
open={isDeleteSegmentModalOpen}
segment={initialSegment}
setOpen={setIsDeleteSegmentModalOpen}
/>
) : null}
@@ -1,13 +1,17 @@
"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 { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
export const generateSegmentTableColumns = (
t: TFunction,
locale: string
): ColumnDef<TSegmentWithSurveyRefs>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "title",
accessorKey: "title",
header: t("common.title"),
@@ -28,26 +32,28 @@ export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWit
},
};
const updatedAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
const updatedAtColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "updatedAt",
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>;
},
};
const createdAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
const createdAtColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "createdAt",
accessorKey: "createdAt",
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,46 +0,0 @@
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
import { getSurveysBySegmentId } from "@/lib/survey/service";
import { SegmentTableDataRow } from "./segment-table-data-row";
type TSegmentTableDataRowProps = {
currentSegment: TSegment;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
};
export const SegmentTableDataRowContainer = async ({
currentSegment,
segments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: TSegmentTableDataRowProps) => {
const surveys = await getSurveysBySegmentId(currentSegment.id);
const activeSurveys = surveys?.length
? surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
: [];
const inactiveSurveys = surveys?.length
? surveys.filter((survey) => ["draft", "paused"].includes(survey.status)).map((survey) => survey.name)
: [];
const filteredSegments = segments.filter((segment) => segment.id !== currentSegment.id);
return (
<SegmentTableDataRow
currentSegment={{
...currentSegment,
activeSurveys,
inactiveSurveys,
}}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
/>
);
};
@@ -1,14 +1,18 @@
"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 { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { EditSegmentModal } from "./edit-segment-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
type TSegmentTableDataRowProps = {
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
@@ -17,13 +21,16 @@ type TSegmentTableDataRowProps = {
export const SegmentTableDataRow = ({
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
isReadOnly,
}: TSegmentTableDataRowProps) => {
const { i18n } = useTranslation();
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
return (
<>
@@ -46,14 +53,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>
@@ -62,6 +71,7 @@ export const SegmentTableDataRow = ({
open={isEditSegmentModalOpen}
setOpen={setIsEditSegmentModalOpen}
currentSegment={currentSegment}
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}
@@ -4,13 +4,15 @@ import { Header, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal";
import { buildSegmentActivitySummaryFromSegments } from "./segment-activity-utils";
import { generateSegmentTableColumns } from "./segment-table-columns";
interface SegmentTableUpdatedProps {
segments: TSegmentWithSurveyNames[];
segments: TSegmentWithSurveyRefs[];
allSegments: TSegmentWithSurveyRefs[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
@@ -18,16 +20,18 @@ interface SegmentTableUpdatedProps {
export function SegmentTable({
segments,
allSegments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: SegmentTableUpdatedProps) {
const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyRefs | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns(t);
}, []);
return generateSegmentTableColumns(t, locale);
}, [locale, t]);
const table = useReactTable({
data: segments,
@@ -35,7 +39,7 @@ export function SegmentTable({
getCoreRowModel: getCoreRowModel(),
});
const getHeader = (header: Header<TSegmentWithSurveyNames, unknown>) => {
const getHeader = (header: Header<TSegmentWithSurveyRefs, unknown>) => {
if (header.isPlaceholder) {
return null;
}
@@ -136,6 +140,7 @@ export function SegmentTable({
open={!!editingSegment}
setOpen={(open) => !open && setEditingSegment(null)}
currentSegment={editingSegment}
activitySummary={buildSegmentActivitySummaryFromSegments(editingSegment, allSegments)}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TBaseFilters, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { getSegment } from "../segments";
import { segmentFilterToPrismaQuery } from "./prisma-query";
@@ -270,7 +270,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -336,7 +336,7 @@ describe("segmentFilterToPrismaQuery", () => {
// Mock getSegment to return null for the non-existent segment
vi.mocked(getSegment).mockResolvedValueOnce(mockSegment);
vi.mocked(getSegment).mockResolvedValueOnce(null as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValueOnce(null as unknown as TSegmentWithSurveyRefs);
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
@@ -426,7 +426,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -490,7 +490,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle circular references in segment filters", async () => {
// Mock getSegment to simulate a circular reference
const circularSegment: TSegmentWithSurveyNames = {
const circularSegment: TSegmentWithSurveyRefs = {
id: mockSegmentId, // Same ID creates the circular reference
filters: [
{
@@ -550,7 +550,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle missing segments in segment filters", async () => {
const nestedSegmentId = "segment-missing-123";
vi.mocked(getSegment).mockResolvedValue(null as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValue(null as unknown as TSegmentWithSurveyRefs);
const filters: TBaseFilters = [
{
@@ -599,7 +599,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the nested segment
const mockNestedSegment: TSegmentWithSurveyNames = {
const mockNestedSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -890,7 +890,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Set up the mocks
const mockCircularSegment: TSegmentWithSurveyNames = {
const mockCircularSegment: TSegmentWithSurveyRefs = {
id: circularSegmentId,
filters: circularFilters,
environmentId: mockEnvironmentId,
@@ -904,7 +904,7 @@ describe("segmentFilterToPrismaQuery", () => {
inactiveSurveys: [],
};
const mockSecondSegment: TSegmentWithSurveyNames = {
const mockSecondSegment: TSegmentWithSurveyRefs = {
id: secondSegmentId,
filters: secondFilters,
environmentId: mockEnvironmentId,
@@ -922,7 +922,7 @@ describe("segmentFilterToPrismaQuery", () => {
vi.mocked(getSegment)
.mockResolvedValueOnce(mockCircularSegment) // First call for circularSegmentId
.mockResolvedValueOnce(mockSecondSegment) // Third call for secondSegmentId
.mockResolvedValueOnce(null as unknown as TSegmentWithSurveyNames); // Fourth call for non-existent-segment
.mockResolvedValueOnce(null as unknown as TSegmentWithSurveyRefs); // Fourth call for non-existent-segment
// Complex filters with mixed error conditions
const filters: TBaseFilters = [
@@ -361,7 +361,7 @@ const buildSegmentFilterWhereClause = async (
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
const { root, qualifier } = filter;
const { segmentId } = root;
if (segmentPath.has(segmentId)) {
@@ -382,7 +382,22 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath, environmentId, deviceType);
const nestedWhereClause = await processFilters(segment.filters, newPath, environmentId, deviceType);
const hasNestedConditions = Object.keys(nestedWhereClause).length > 0;
if (qualifier.operator === "userIsIn") {
return nestedWhereClause;
}
if (qualifier.operator === "userIsNotIn") {
if (!hasNestedConditions) {
return { id: "__SEGMENT_FILTER_NO_MATCH__" };
}
return { NOT: nestedWhereClause };
}
return {};
};
/**
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { TBaseFilters, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
@@ -77,7 +77,7 @@ describe("checkForRecursiveSegmentFilter", () => {
],
};
vi.mocked(getSegment).mockResolvedValue(referencedSegment as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValue(referencedSegment as unknown as TSegmentWithSurveyRefs);
// Act & Assert
// The function should complete without throwing an error
@@ -8,7 +8,7 @@ import {
TEvaluateSegmentUserData,
TSegmentCreateInput,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
TSegmentWithSurveyRefs,
} from "@formbricks/types/segment";
import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate";
@@ -79,10 +79,10 @@ const mockSegmentPrisma = {
surveys: [{ id: surveyId, name: "Test Survey", status: "inProgress" }],
};
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
...mockSegmentPrisma,
surveys: [surveyId],
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -287,7 +287,7 @@ describe("Segment Service Tests", () => {
...mockSegment,
id: clonedSegmentId,
title: "Copy of Test Segment (1)",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -327,7 +327,7 @@ describe("Segment Service Tests", () => {
const clonedSegment2 = {
...clonedSegment,
title: "Copy of Test Segment (2)",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -415,7 +415,7 @@ describe("Segment Service Tests", () => {
title: surveyId,
isPrivate: true,
filters: [],
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -487,7 +487,7 @@ describe("Segment Service Tests", () => {
const updatedSegment = {
...mockSegment,
title: "Updated Segment",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
const updateData: TSegmentUpdateInput = { title: "Updated Segment" };
@@ -531,7 +531,7 @@ describe("Segment Service Tests", () => {
...updatedSegment,
surveys: [newSurveyId],
activeSurveys: [],
inactiveSurveys: ["New Survey"],
inactiveSurveys: [{ id: newSurveyId, name: "New Survey" }],
};
vi.mocked(prisma.segment.update).mockResolvedValue(updatedSegmentPrismaWithSurvey);
@@ -25,7 +25,7 @@ import {
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
TSegmentWithSurveyRefs,
ZRelativeDateValue,
ZSegmentCreateInput,
ZSegmentFilters,
@@ -66,14 +66,14 @@ export const selectSegment = {
},
} satisfies Prisma.SegmentSelect;
export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyNames => {
export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyRefs => {
const activeSurveys = segment.surveys
.filter((survey) => survey.status === "inProgress")
.map((survey) => survey.name);
.map((survey) => ({ id: survey.id, name: survey.name }));
const inactiveSurveys = segment.surveys
.filter((survey) => survey.status !== "inProgress")
.map((survey) => survey.name);
.map((survey) => ({ id: survey.id, name: survey.name }));
return {
...segment,
@@ -83,7 +83,7 @@ export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurv
};
};
export const getSegment = reactCache(async (segmentId: string): Promise<TSegmentWithSurveyNames> => {
export const getSegment = reactCache(async (segmentId: string): Promise<TSegmentWithSurveyRefs> => {
validateInputs([segmentId, ZId]);
try {
const segment = await prisma.segment.findUnique({
@@ -107,7 +107,7 @@ export const getSegment = reactCache(async (segmentId: string): Promise<TSegment
}
});
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyNames[]> => {
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyRefs[]> => {
validateInputs([environmentId, ZId]);
try {
const segments = await prisma.segment.findMany({
@@ -47,6 +47,7 @@ export const SegmentsPage = async ({
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
<SegmentTable
allSegments={segments}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
@@ -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) {
@@ -5,6 +5,7 @@ import { WebhookIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { type TUserLocale } from "@formbricks/types/user";
import { WebhookOverviewTab } from "@/modules/integrations/webhooks/components/webhook-overview-tab";
import { WebhookSettingsTab } from "@/modules/integrations/webhooks/components/webhook-settings-tab";
import {
@@ -25,13 +26,14 @@ interface WebhookModalProps {
}
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{
title: t("common.overview"),
children: <WebhookOverviewTab webhook={webhook} surveys={surveys} />,
children: <WebhookOverviewTab webhook={webhook} surveys={surveys} locale={locale} />,
},
{
title: t("common.settings"),
@@ -4,12 +4,14 @@ 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 { type TUserLocale } from "@formbricks/types/user";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { Label } from "@/modules/ui/components/label";
interface ActivityTabProps {
webhook: Webhook;
surveys: TSurvey[];
locale: TUserLocale;
}
const getSurveyNamesForWebhook = (webhook: Webhook, allSurveys: TSurvey[]): string[] => {
@@ -36,7 +38,7 @@ const convertTriggerIdToName = (triggerId: string, t: TFunction): string => {
}
};
export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
export const WebhookOverviewTab = ({ webhook, surveys, locale }: ActivityTabProps) => {
const { t } = useTranslation();
return (
<div className="grid grid-cols-3 pb-2">
@@ -81,15 +83,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";
@@ -67,16 +66,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">
@@ -103,7 +96,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,29 +137,25 @@ 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>
<Label className="block text-xs font-normal text-slate-500">{t("common.type")}</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
</div>
</div>
<div className="">
<Label className="text-xs font-normal text-slate-500">Environment</Label>
<Label className="text-xs font-normal text-slate-500">{t("common.environment")}</Label>
<div className="items-center-center flex gap-2">
<p className="text-xs text-slate-700">
{environment.type === "development" ? "Development" : "Production"}
{environment.type === "development" ? t("common.development") : t("common.production")}
</p>
<Button
onClick={() => {
@@ -166,7 +163,10 @@ export const ActionActivityTab = ({
}}
className="m-0 p-0 text-xs font-medium text-black underline underline-offset-4 focus:ring-0 focus:ring-offset-0"
variant="ghost">
{environment.type === "development" ? "Copy to Production" : "Copy to Development"}
{t("common.copy_to_environment", {
environment:
environment.type === "development" ? t("common.production") : t("common.development"),
})}
</Button>
</div>
</div>
@@ -1,7 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ActionDetailModal } from "./ActionDetailModal";
@@ -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}
@@ -128,14 +128,21 @@ export const EditWelcomeCard = ({
id="welcome-card-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
if (url?.length) {
updateSurvey({ fileUrl: url[0] });
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
if (url?.length && url[0]) {
const update =
fileType === "video"
? { videoUrl: url[0], fileUrl: undefined }
: { fileUrl: url[0], videoUrl: undefined };
updateSurvey(update);
} else {
updateSurvey({ fileUrl: undefined });
updateSurvey({ fileUrl: undefined, videoUrl: undefined });
}
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}
videoUrl={localSurvey?.welcomeCard?.videoUrl}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
</div>
@@ -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)}
@@ -2,7 +2,7 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentActivitySummary } from "@/modules/ee/contacts/segments/components/segment-activity-utils";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -15,16 +15,16 @@ import {
} from "@/modules/ui/components/dialog";
interface ConfirmDeleteSegmentModalProps {
activitySummary: TSegmentActivitySummary;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
segment: TSegmentWithSurveyNames;
onDelete: () => Promise<void>;
}
export const ConfirmDeleteSegmentModal = ({
activitySummary,
onDelete,
open,
segment,
setOpen,
}: ConfirmDeleteSegmentModalProps) => {
const { t } = useTranslation();
@@ -32,9 +32,9 @@ export const ConfirmDeleteSegmentModal = ({
await onDelete();
};
const segmentHasSurveys = useMemo(() => {
return segment.activeSurveys.length > 0 || segment.inactiveSurveys.length > 0;
}, [segment.activeSurveys.length, segment.inactiveSurveys.length]);
const allSurveys = useMemo(() => {
return [...activitySummary.activeSurveys, ...activitySummary.inactiveSurveys];
}, [activitySummary.activeSurveys, activitySummary.inactiveSurveys]);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -46,16 +46,13 @@ export const ConfirmDeleteSegmentModal = ({
</DialogDescription>
</DialogHeader>
{segmentHasSurveys && (
{allSurveys.length > 0 && (
<DialogBody>
<div className="space-y-2">
<p>{t("environments.segments.cannot_delete_segment_used_in_surveys")}</p>
<ol className="my-2 ml-4 list-decimal">
{segment.activeSurveys.map((survey) => (
<li key={survey}>{survey}</li>
))}
{segment.inactiveSurveys.map((survey) => (
<li key={survey}>{survey}</li>
{allSurveys.map((surveyName) => (
<li key={surveyName}>{surveyName}</li>
))}
</ol>
</div>
@@ -69,7 +66,7 @@ export const ConfirmDeleteSegmentModal = ({
<Button variant="secondary" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={segmentHasSurveys}>
<Button variant="destructive" onClick={handleDelete} disabled={allSurveys.length > 0}>
{t("common.delete")}
</Button>
</DialogFooter>
@@ -10,25 +10,38 @@ interface EnvironmentNoticeProps {
}
export const EnvironmentNotice = async ({ environmentId, subPageUrl }: EnvironmentNoticeProps) => {
const t = await getTranslate();
const environment = await getEnvironment(environmentId);
const [t, environment] = await Promise.all([getTranslate(), getEnvironment(environmentId)]);
if (!environment) {
throw new Error("Environment not found");
}
const environments = await getEnvironments(environment.projectId);
const otherEnvironmentId = environments.filter((e) => e.id !== environment.id)[0].id;
const otherEnvironment = environments.find(
(candidateEnvironment) => candidateEnvironment.id !== environment.id
);
if (!otherEnvironment) {
throw new Error("Other environment not found");
}
const currentEnvironmentLabel = t(
environment.type === "production" ? "common.production" : "common.development"
);
const targetEnvironmentLabel = t(
otherEnvironment.type === "production" ? "common.production" : "common.development"
);
return (
<div>
<Alert variant="info" size="small" className="max-w-4xl">
<AlertTitle>{t("common.environment_notice", { environment: environment.type })}</AlertTitle>
<AlertTitle>{t("common.environment_notice", { environment: currentEnvironmentLabel })}</AlertTitle>
<AlertButton>
<Link
href={`${WEBAPP_URL}/environments/${otherEnvironmentId}${subPageUrl}`}
href={`${WEBAPP_URL}/environments/${otherEnvironment.id}${subPageUrl}`}
className="ml-1 cursor-pointer underline">
{t("common.switch_to", {
environment: environment.type === "production" ? "Development" : "Production",
environment: targetEnvironmentLabel,
})}
</Link>
</AlertButton>
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { ArrowUpFromLineIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
@@ -6,6 +6,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { type TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { formatDate, timeSinceDate } from "@/lib/time";
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
@@ -18,6 +19,7 @@ interface SegmentDetailProps {
onSegmentLoad: (surveyId: string, segmentId: string) => Promise<TSurvey>;
surveyId: string;
currentSegment: TSegment;
locale: TUserLocale;
}
const SegmentDetail = ({
@@ -28,6 +30,7 @@ const SegmentDetail = ({
onSegmentLoad,
surveyId,
currentSegment,
locale,
}: SegmentDetailProps) => {
const [isLoading, setIsLoading] = useState(false);
const handleLoadNewSegment = async (segmentId: string) => {
@@ -106,11 +109,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 +143,8 @@ export const LoadSegmentModal = ({
const handleResetState = () => {
setOpen(false);
};
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const segmentsArray = segments?.filter((segment) => !segment.isPrivate);
return (
@@ -182,6 +186,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",
@@ -310,7 +310,11 @@ export const PreviewSurvey = ({
setIsFullScreenPreview(true);
}
}}
aria-label={isFullScreenPreview ? t("environments.surveys.edit.shrink_preview") : t("environments.surveys.edit.expand_preview")}></button>
aria-label={
isFullScreenPreview
? t("environments.surveys.edit.shrink_preview")
: t("environments.surveys.edit.expand_preview")
}></button>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>
+1 -1
View File
@@ -95,7 +95,7 @@
"lodash": "4.17.23",
"lucide-react": "0.577.0",
"markdown-it": "14.1.1",
"next": "16.1.6",
"next": "16.1.7",
"next-auth": "4.24.13",
"next-safe-action": "8.1.8",
"node-fetch": "3.3.2",
+6 -2
View File
@@ -83,11 +83,15 @@
"pnpm": {
"overrides": {
"@hono/node-server": "1.19.10",
"@tootallnate/once": "3.0.1",
"schema-utils@3.3.0>ajv": "6.14.0",
"axios": "1.13.5",
"effect": "3.20.0",
"flatted": "3.4.2",
"hono": "4.12.4",
"hono": "4.12.7",
"@microsoft/api-extractor>minimatch": "10.2.4",
"node-forge": ">=1.3.2",
"qs": "6.14.2",
"rollup": "4.59.0",
"socket.io-parser": "4.2.6",
"tar": ">=7.5.11",
@@ -97,7 +101,7 @@
"diff": ">=8.0.3"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316) - awaiting Prisma update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
+1 -1
View File
@@ -30,7 +30,7 @@
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"build": "tsc && vite build",
"build": "rimraf dist && tsc && vite build",
"test": "vitest run"
},
"author": "Formbricks <hola@formbricks.com>",
@@ -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}
@@ -765,6 +765,7 @@ export function Survey({
headline={localSurvey.welcomeCard.headline}
subheader={localSurvey.welcomeCard.subheader}
fileUrl={localSurvey.welcomeCard.fileUrl}
videoUrl={localSurvey.welcomeCard.videoUrl}
buttonLabel={localSurvey.welcomeCard.buttonLabel}
onSubmit={onSubmit}
survey={localSurvey}
@@ -8,6 +8,7 @@ import { ScrollableContainer } from "@/components/wrappers/scrollable-container"
import { getLocalizedValue } from "@/lib/i18n";
import { replaceRecallInfo } from "@/lib/recall";
import { calculateElementIdx, getElementsFromSurveyBlocks } from "@/lib/utils";
import { ElementMedia } from "./element-media";
import { Headline } from "./headline";
import { Subheader } from "./subheader";
@@ -15,6 +16,7 @@ interface WelcomeCardProps {
headline?: TI18nString;
subheader?: TI18nString;
fileUrl?: string;
videoUrl?: string;
buttonLabel?: TI18nString;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
survey: TJsEnvironmentStateSurvey;
@@ -69,6 +71,7 @@ export function WelcomeCard({
headline,
subheader,
fileUrl,
videoUrl,
buttonLabel,
onSubmit,
languageCode,
@@ -144,19 +147,25 @@ export function WelcomeCard({
return (
<ScrollableContainer fullSizeCards={fullSizeCards}>
<div>
{fileUrl ? (
<img src={fileUrl} className="mb-8 max-h-96 w-1/4 object-contain" alt={t("common.company_logo")} />
{fileUrl || videoUrl ? (
<ElementMedia imgUrl={fileUrl} videoUrl={videoUrl} altText={t("common.company_logo")} />
) : 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"
/>
+20 -141
View File
@@ -1,64 +1,5 @@
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");
expect(getMonthName(6)).toBe("July");
expect(getMonthName(11)).toBe("December");
});
test("should return correct month name for a different locale (es-ES)", () => {
expect(getMonthName(0, "es-ES")).toBe("enero");
expect(getMonthName(6, "es-ES")).toBe("julio");
expect(getMonthName(11, "es-ES")).toBe("diciembre");
});
test("should throw an error for invalid month index", () => {
expect(() => getMonthName(-1)).toThrow("Month index must be between 0 and 11");
expect(() => getMonthName(12)).toThrow("Month index must be between 0 and 11");
});
});
describe("getOrdinalDate", () => {
test('should return date with "st" for 1, 21, 31 (but not 11)', () => {
expect(getOrdinalDate(1)).toBe("1st");
expect(getOrdinalDate(21)).toBe("21st");
expect(getOrdinalDate(31)).toBe("31st");
});
test('should return date with "nd" for 2, 22 (but not 12)', () => {
expect(getOrdinalDate(2)).toBe("2nd");
expect(getOrdinalDate(22)).toBe("22nd");
});
test('should return date with "rd" for 3, 23 (but not 13)', () => {
expect(getOrdinalDate(3)).toBe("3rd");
expect(getOrdinalDate(23)).toBe("23rd");
});
test('should return date with "th" for 11, 12, 13 and others', () => {
expect(getOrdinalDate(4)).toBe("4th");
expect(getOrdinalDate(11)).toBe("11th");
expect(getOrdinalDate(12)).toBe("12th");
expect(getOrdinalDate(13)).toBe("13th");
expect(getOrdinalDate(15)).toBe("15th");
expect(getOrdinalDate(20)).toBe("20th");
expect(getOrdinalDate(24)).toBe("24th");
});
});
import { formatDateWithOrdinal, isValidDateString } from "./date-time";
describe("isValidDateString", () => {
test("should return true for valid YYYY-MM-DD format", () => {
@@ -88,96 +29,34 @@ 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");
// Sunday, March 13th, 2022
const date3 = new Date(2022, 2, 13);
expect(formatDateWithOrdinal(date3)).toBe("Sunday, March 13th, 2022");
test("formats a known en-US date with the expected output", () => {
expect(formatDateWithOrdinal(new Date(2024, 0, 1), "en-US")).toBe("Monday, January 1, 2024");
});
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 en-US 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, "en-US")).toBe(getExpectedLocaleDate(date, "en-US"));
});
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 fr-FR output", () => {
const date = new Date(2024, 0, 1);
expect(formatDateWithOrdinal(date, "fr-FR")).toBe(getExpectedLocaleDate(date, "fr-FR"));
});
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");
test("formats survey dates with locale-native de-DE output", () => {
const date = new Date(2024, 2, 20);
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"));
});
});
+6 -35
View File
@@ -1,27 +1,3 @@
// Helper function to get the month name
export const getMonthName = (monthIndex: number, locale: string = "en-US") => {
if (monthIndex < 0 || monthIndex > 11) {
throw new Error("Month index must be between 0 and 11");
}
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2000, monthIndex, 1));
};
// Helper function to format the date with an ordinal suffix
export const getOrdinalDate = (date: number) => {
const j = date % 10,
k = date % 100;
if (j === 1 && k !== 11) {
return date + "st";
}
if (j === 2 && k !== 12) {
return date + "nd";
}
if (j === 3 && k !== 13) {
return date + "rd";
}
return date + "th";
};
export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
@@ -37,16 +13,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);
};
+13 -2
View File
@@ -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.";
+7 -17
View File
@@ -1,5 +1,4 @@
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal, isValidDateString } from "@/lib/date-time";
import { getLocalizedValue } from "@/lib/i18n";
@@ -29,7 +28,8 @@ const extractRecallInfo = (headline: string, id?: string): string | null => {
export const replaceRecallInfo = (
text: string,
responseData: TResponseData,
variables: TResponseVariables
variables: TResponseVariables,
languageCode: string = "en-US"
): string => {
let modifiedText = text;
@@ -56,7 +56,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), languageCode);
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", "); // Filters out empty values and joins with a comma
}
@@ -80,7 +80,8 @@ export const parseRecallInformation = (
modifiedQuestion.headline[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.headline, languageCode),
responseData,
variables
variables,
languageCode
);
}
if (
@@ -91,19 +92,8 @@ export const parseRecallInformation = (
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.subheader, languageCode),
responseData,
variables
);
}
if (
(question.type === TSurveyElementTypeEnum.CTA || question.type === TSurveyElementTypeEnum.Consent) &&
question.subheader &&
question.subheader[languageCode].includes("recall:") &&
modifiedQuestion.subheader
) {
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.subheader, languageCode),
responseData,
variables
variables,
languageCode
);
}
return modifiedQuestion;
+7 -3
View File
@@ -361,9 +361,13 @@ export const ZSegmentCreateInput = z.object({
export type TSegmentCreateInput = z.infer<typeof ZSegmentCreateInput>;
export type TSegment = z.infer<typeof ZSegment>;
export type TSegmentWithSurveyNames = TSegment & {
activeSurveys: string[];
inactiveSurveys: string[];
export interface TSegmentSurveyReference {
id: string;
name: string;
}
export type TSegmentWithSurveyRefs = TSegment & {
activeSurveys: TSegmentSurveyReference[];
inactiveSurveys: TSegmentSurveyReference[];
};
export const ZSegmentUpdateInput = z
+55 -174
View File
@@ -6,11 +6,15 @@ settings:
overrides:
'@hono/node-server': 1.19.10
'@tootallnate/once': 3.0.1
schema-utils@3.3.0>ajv: 6.14.0
axios: 1.13.5
effect: 3.20.0
flatted: 3.4.2
hono: 4.12.4
hono: 4.12.7
'@microsoft/api-extractor>minimatch': 10.2.4
node-forge: '>=1.3.2'
qs: 6.14.2
rollup: 4.59.0
socket.io-parser: 4.2.6
tar: '>=7.5.11'
@@ -259,7 +263,7 @@ importers:
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@sentry/nextjs':
specifier: 10.43.0
version: 10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4)
version: 10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4)
'@t3-oss/env-nextjs':
specifier: 0.13.10
version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)
@@ -339,14 +343,14 @@ importers:
specifier: 14.1.1
version: 14.1.1
next:
specifier: 16.1.6
version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 16.1.7
version: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-auth:
specifier: 4.24.13
version: 4.24.13(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 4.24.13(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action:
specifier: 8.1.8
version: 8.1.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 8.1.8(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -1993,7 +1997,7 @@ packages:
resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: 4.12.4
hono: 4.12.7
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
@@ -2344,46 +2348,24 @@ packages:
'@neoconfetti/react@1.0.0':
resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==}
'@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/env@16.1.7':
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==}
'@next/eslint-plugin-next@15.5.12':
resolution: {integrity: sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==}
'@next/swc-darwin-arm64@16.1.6':
resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-arm64@16.1.7':
resolution: {integrity: sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.1.6':
resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-darwin-x64@16.1.7':
resolution: {integrity: sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.6':
resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-gnu@16.1.7':
resolution: {integrity: sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==}
engines: {node: '>= 10'}
@@ -2391,13 +2373,6 @@ packages:
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-arm64-musl@16.1.7':
resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==}
engines: {node: '>= 10'}
@@ -2405,13 +2380,6 @@ packages:
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-gnu@16.1.7':
resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==}
engines: {node: '>= 10'}
@@ -2419,13 +2387,6 @@ packages:
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-musl@16.1.7':
resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==}
engines: {node: '>= 10'}
@@ -2433,24 +2394,12 @@ packages:
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-arm64-msvc@16.1.7':
resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.6':
resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.7':
resolution: {integrity: sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==}
engines: {node: '>= 10'}
@@ -5246,9 +5195,9 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tootallnate/once@1.1.2':
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
'@tootallnate/once@3.0.1':
resolution: {integrity: sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==}
engines: {node: '>= 10'}
'@trivago/prettier-plugin-sort-imports@6.0.2':
resolution: {integrity: sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==}
@@ -6012,6 +5961,9 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
@@ -6329,9 +6281,6 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001762:
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
caniuse-lite@1.0.30001776:
resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==}
@@ -6847,8 +6796,8 @@ packages:
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
effect@3.18.4:
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
effect@3.20.0:
resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==}
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
@@ -7660,8 +7609,8 @@ packages:
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
hono@4.12.4:
resolution: {integrity: sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==}
hono@4.12.7:
resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
engines: {node: '>=16.9.0'}
hosted-git-info@2.8.9:
@@ -8675,27 +8624,6 @@ packages:
react: '>= 18.2.0'
react-dom: '>= 18.2.0'
next@16.1.6:
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.51.1
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
babel-plugin-react-compiler:
optional: true
sass:
optional: true
next@16.1.7:
resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==}
engines: {node: '>=20.9.0'}
@@ -9392,8 +9320,8 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
qs@6.14.2:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
quansync@0.2.11:
@@ -12711,7 +12639,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
ajv: 6.14.0
debug: 4.4.3
espree: 9.6.1
globals: 13.24.0
@@ -12826,9 +12754,9 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
'@hono/node-server@1.19.10(hono@4.12.4)':
'@hono/node-server@1.19.10(hono@4.12.7)':
dependencies:
hono: 4.12.4
hono: 4.12.7
optional: true
'@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))':
@@ -13272,59 +13200,33 @@ snapshots:
'@neoconfetti/react@1.0.0': {}
'@next/env@16.1.6': {}
'@next/env@16.1.7': {}
'@next/eslint-plugin-next@15.5.12':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.1.6':
optional: true
'@next/swc-darwin-arm64@16.1.7':
optional: true
'@next/swc-darwin-x64@16.1.6':
optional: true
'@next/swc-darwin-x64@16.1.7':
optional: true
'@next/swc-linux-arm64-gnu@16.1.6':
optional: true
'@next/swc-linux-arm64-gnu@16.1.7':
optional: true
'@next/swc-linux-arm64-musl@16.1.6':
optional: true
'@next/swc-linux-arm64-musl@16.1.7':
optional: true
'@next/swc-linux-x64-gnu@16.1.6':
optional: true
'@next/swc-linux-x64-gnu@16.1.7':
optional: true
'@next/swc-linux-x64-musl@16.1.6':
optional: true
'@next/swc-linux-x64-musl@16.1.7':
optional: true
'@next/swc-win32-arm64-msvc@16.1.6':
optional: true
'@next/swc-win32-arm64-msvc@16.1.7':
optional: true
'@next/swc-win32-x64-msvc@16.1.6':
optional: true
'@next/swc-win32-x64-msvc@16.1.7':
optional: true
@@ -14613,7 +14515,7 @@ snapshots:
dependencies:
c12: 3.1.0(magicast@0.3.5)
deepmerge-ts: 7.1.5
effect: 3.18.4
effect: 3.20.0
empathic: 2.0.0
transitivePeerDependencies:
- magicast
@@ -14622,7 +14524,7 @@ snapshots:
dependencies:
c12: 3.1.0(magicast@0.3.5)
deepmerge-ts: 7.1.5
effect: 3.18.4
effect: 3.20.0
empathic: 2.0.0
transitivePeerDependencies:
- magicast
@@ -14641,13 +14543,13 @@ snapshots:
'@electric-sql/pglite': 0.3.15
'@electric-sql/pglite-socket': 0.0.20(@electric-sql/pglite@0.3.15)
'@electric-sql/pglite-tools': 0.2.20(@electric-sql/pglite@0.3.15)
'@hono/node-server': 1.19.10(hono@4.12.4)
'@hono/node-server': 1.19.10(hono@4.12.7)
'@mrleebo/prisma-ast': 0.13.1
'@prisma/get-platform': 7.2.0
'@prisma/query-plan-executor': 7.2.0
foreground-child: 3.3.1
get-port-please: 3.2.0
hono: 4.12.4
hono: 4.12.7
http-status-codes: 2.3.0
pathe: 2.0.3
proper-lockfile: 4.1.2
@@ -15685,7 +15587,7 @@ snapshots:
'@sentry/core@10.43.0': {}
'@sentry/nextjs@10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4)':
'@sentry/nextjs@10.43.0(@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.40.0
@@ -15698,7 +15600,7 @@ snapshots:
'@sentry/react': 10.43.0(react@19.2.4)
'@sentry/vercel-edge': 10.43.0
'@sentry/webpack-plugin': 5.1.1(encoding@0.1.13)(webpack@5.105.4)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rollup: 4.59.0
stacktrace-parser: 0.1.11
transitivePeerDependencies:
@@ -16681,7 +16583,7 @@ snapshots:
dependencies:
'@testing-library/dom': 8.20.1
'@tootallnate/once@1.1.2':
'@tootallnate/once@3.0.1':
optional: true
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)':
@@ -17557,9 +17459,9 @@ snapshots:
optionalDependencies:
ajv: 8.18.0
ajv-keywords@3.5.2(ajv@6.12.6):
ajv-keywords@3.5.2(ajv@6.14.0):
dependencies:
ajv: 6.12.6
ajv: 6.14.0
ajv-keywords@5.1.0(ajv@8.18.0):
dependencies:
@@ -17573,6 +17475,13 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -17937,8 +17846,6 @@ snapshots:
camelcase@5.3.1: {}
caniuse-lite@1.0.30001762: {}
caniuse-lite@1.0.30001776: {}
chai@5.3.3:
@@ -18421,7 +18328,7 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
effect@3.18.4:
effect@3.20.0:
dependencies:
'@standard-schema/spec': 1.1.0
fast-check: 3.23.2
@@ -18957,7 +18864,7 @@ snapshots:
'@humanwhocodes/module-importer': 1.0.1
'@nodelib/fs.walk': 1.2.8
'@ungap/structured-clone': 1.3.0
ajv: 6.12.6
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
@@ -19412,7 +19319,7 @@ snapshots:
extend: 3.0.2
gaxios: 6.7.1(encoding@0.1.13)
google-auth-library: 9.15.1(encoding@0.1.13)
qs: 6.14.1
qs: 6.14.2
url-template: 2.0.8
uuid: 9.0.1
transitivePeerDependencies:
@@ -19424,7 +19331,7 @@ snapshots:
extend: 3.0.2
gaxios: 7.1.4
google-auth-library: 10.6.1
qs: 6.14.1
qs: 6.14.2
url-template: 2.0.8
transitivePeerDependencies:
- supports-color
@@ -19502,7 +19409,7 @@ snapshots:
help-me@5.0.0: {}
hono@4.12.4:
hono@4.12.7:
optional: true
hosted-git-info@2.8.9: {}
@@ -19546,7 +19453,7 @@ snapshots:
http-proxy-agent@4.0.1:
dependencies:
'@tootallnate/once': 1.1.2
'@tootallnate/once': 3.0.1
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
@@ -20510,13 +20417,13 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.13(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next-auth@4.24.13(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
@@ -20527,39 +20434,13 @@ snapshots:
optionalDependencies:
nodemailer: 8.0.2
next-safe-action@8.1.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next-safe-action@8.1.8(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
deepmerge-ts: 7.1.5
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.11
caniuse-lite: 1.0.30001762
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
'@next/swc-linux-arm64-gnu': 16.1.6
'@next/swc-linux-arm64-musl': 16.1.6
'@next/swc-linux-x64-gnu': 16.1.6
'@next/swc-linux-x64-musl': 16.1.6
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.58.2
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.7
@@ -21290,7 +21171,7 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
qs@6.14.1:
qs@6.14.2:
dependencies:
side-channel: 1.1.0
@@ -21764,8 +21645,8 @@ snapshots:
schema-utils@3.3.0:
dependencies:
'@types/json-schema': 7.0.15
ajv: 6.12.6
ajv-keywords: 3.5.2(ajv@6.12.6)
ajv: 6.14.0
ajv-keywords: 3.5.2(ajv@6.14.0)
schema-utils@4.3.3:
dependencies: