mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-17 19:49:36 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aba02cf62c | |||
| 5d166cae8b | |||
| 645f0ab0d1 | |||
| 389a7d9e7b | |||
| c4cf468c7e | |||
| cbc3e923e4 | |||
| 8d0847bb9a | |||
| 6c871b5cd5 |
@@ -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.
|
||||
|
||||
+5
-10
@@ -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",
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+10
-3
@@ -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>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+2
-4
@@ -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}
|
||||
|
||||
+10
-9
@@ -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>
|
||||
|
||||
+6
-8
@@ -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>
|
||||
|
||||
+6
-7
@@ -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>
|
||||
|
||||
+6
-5
@@ -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>
|
||||
);
|
||||
|
||||
+6
-7
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}, {});
|
||||
};
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
|
||||
import {
|
||||
diffInDays,
|
||||
formatDateForDisplay,
|
||||
formatDateTimeForDisplay,
|
||||
formatDateWithOrdinal,
|
||||
getFormattedDateTimeString,
|
||||
isValidDateString,
|
||||
} from "./datetime";
|
||||
|
||||
describe("datetime utils", () => {
|
||||
test("diffInDays calculates the difference in days between two dates", () => {
|
||||
@@ -8,13 +15,45 @@ describe("datetime utils", () => {
|
||||
expect(diffInDays(date1, date2)).toBe(5);
|
||||
});
|
||||
|
||||
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
|
||||
test("formatDateWithOrdinal formats a date using the provided locale", () => {
|
||||
// Create a date that's fixed to May 6, 2025 at noon UTC
|
||||
// Using noon ensures the date won't change in most timezones
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
|
||||
|
||||
// Test the function
|
||||
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
|
||||
expect(formatDateWithOrdinal(date)).toBe(
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("formatDateForDisplay uses the provided locale", () => {
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
|
||||
|
||||
expect(formatDateForDisplay(date, "de-DE")).toBe(
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("formatDateTimeForDisplay uses the provided locale", () => {
|
||||
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
|
||||
|
||||
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
|
||||
new Intl.DateTimeFormat("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("isValidDateString validates correct date strings", () => {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
const relevantDigits = day < 30 ? day % 20 : day % 30;
|
||||
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
|
||||
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
};
|
||||
|
||||
// Helper function to calculate difference in days between two dates
|
||||
@@ -10,23 +20,44 @@ export const diffInDays = (date1: Date, date2: Date) => {
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
|
||||
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
|
||||
const day = date.getDate();
|
||||
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
|
||||
const year = date.getFullYear();
|
||||
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
||||
export const formatDateForDisplay = (
|
||||
date: Date,
|
||||
locale: string = DEFAULT_LOCALE,
|
||||
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS
|
||||
): string => {
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
};
|
||||
|
||||
export const formatDateTimeForDisplay = (
|
||||
date: Date,
|
||||
locale: string = DEFAULT_LOCALE,
|
||||
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
|
||||
): string => {
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
};
|
||||
|
||||
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
|
||||
return formatDateForDisplay(date, locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export const isValidDateString = (value: string) => {
|
||||
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
|
||||
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
|
||||
|
||||
if (!regex.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return date;
|
||||
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value)
|
||||
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1")
|
||||
: value;
|
||||
|
||||
const date = new Date(normalizedValue);
|
||||
return !Number.isNaN(date.getTime());
|
||||
};
|
||||
|
||||
export const getFormattedDateTimeString = (date: Date): string => {
|
||||
|
||||
@@ -32,16 +32,17 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
isValidDateString: vi.fn((value) => {
|
||||
try {
|
||||
return !isNaN(new Date(value as string).getTime());
|
||||
} catch {
|
||||
return false;
|
||||
vi.mock("@/lib/utils/date-display", () => ({
|
||||
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => {
|
||||
if (value === "2023-01-01") {
|
||||
return `formatted-${locale}-${format ?? "iso"}`;
|
||||
}
|
||||
}),
|
||||
formatDateWithOrdinal: vi.fn(() => {
|
||||
return "January 1st, 2023";
|
||||
|
||||
if (value === "01-02-2023" && format === "M-d-y") {
|
||||
return `legacy-${locale}-${format}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -477,7 +478,20 @@ describe("recall utility functions", () => {
|
||||
};
|
||||
|
||||
const result = parseRecallInfo(text, responseData);
|
||||
expect(result).toBe("You joined on January 1st, 2023");
|
||||
expect(result).toBe("You joined on formatted-en-US-iso");
|
||||
});
|
||||
|
||||
test("formats legacy date values using the provided locale and stored format", () => {
|
||||
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
|
||||
const responseData: TResponseData = {
|
||||
joinDate: "01-02-2023",
|
||||
};
|
||||
|
||||
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
|
||||
joinDate: "M-d-y",
|
||||
});
|
||||
|
||||
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
|
||||
});
|
||||
|
||||
test("formats array values as comma-separated list", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
|
||||
|
||||
export interface fallbacks {
|
||||
[id: string]: string;
|
||||
@@ -224,7 +224,9 @@ export const parseRecallInfo = (
|
||||
text: string,
|
||||
responseData?: TResponseData,
|
||||
variables?: TResponseVariables,
|
||||
withSlash: boolean = false
|
||||
withSlash: boolean = false,
|
||||
locale: string = "en-US",
|
||||
dateFormats?: TSurveyDateFormatMap
|
||||
) => {
|
||||
let modifiedText = text;
|
||||
const questionIds = responseData ? Object.keys(responseData) : [];
|
||||
@@ -254,12 +256,14 @@ export const parseRecallInfo = (
|
||||
value = responseData[recallItemId];
|
||||
|
||||
// Apply formatting for special value types
|
||||
if (value) {
|
||||
if (isValidDateString(value as string)) {
|
||||
value = formatDateWithOrdinal(new Date(value as string));
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
if (typeof value === "string") {
|
||||
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
|
||||
|
||||
if (formattedDate) {
|
||||
value = formattedDate;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ブロックを削除",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Удалить блок",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "删除区块",
|
||||
|
||||
@@ -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": "刪除區塊",
|
||||
|
||||
+6
-7
@@ -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>
|
||||
|
||||
+6
-4
@@ -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>;
|
||||
}
|
||||
|
||||
+11
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+55
-174
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user