diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 77bd6ab8f5a1..68df6a957679 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -156,7 +156,15 @@ jobs: # @formbricks/database is already built by the "Build App" step above, # so run the migration runner directly instead of db:migrate:dev # (which would rebuild + re-generate the package unnecessarily). - pnpm --filter=@formbricks/database db:migrate:ci + # db:migrate:ci was added in #8627; PR branches cut before it lack the + # script (this workflow runs from the base ref against the PR-head code), + # so fall back to db:migrate:dev, which every older branch already has. + if node -e "process.exit(require('./packages/database/package.json').scripts['db:migrate:ci'] ? 0 : 1)"; then + pnpm --filter=@formbricks/database db:migrate:ci + else + echo "::warning::db:migrate:ci absent on this ref (predates #8627); falling back to db:migrate:dev" + pnpm --filter=@formbricks/database db:migrate:dev + fi - name: Run Rate Limiter Load Tests run: | diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index aa0f2d99c4f2..3e41b78f65dc 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -132,7 +132,16 @@ jobs: # @formbricks/database is already built by the "Build workspace package # dependencies" step above, so run the migration runner directly instead # of db:migrate:dev (which would rebuild + re-generate the package). - run: pnpm --filter=@formbricks/database db:migrate:ci + # db:migrate:ci was added in #8627; PR branches cut before it lack the + # script (this workflow runs from the base ref against the PR-head code), + # so fall back to db:migrate:dev, which every older branch already has. + run: | + if node -e "process.exit(require('./packages/database/package.json').scripts['db:migrate:ci'] ? 0 : 1)"; then + pnpm --filter=@formbricks/database db:migrate:ci + else + echo "::warning::db:migrate:ci absent on this ref (predates #8627); falling back to db:migrate:dev" + pnpm --filter=@formbricks/database db:migrate:dev + fi shell: bash # Shared with apps/web/integration/global-setup.ts (the local harness applies the same file) so diff --git a/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.test.ts b/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.test.ts index 9222d5335a5b..e8d404748d5a 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.test.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.test.ts @@ -13,6 +13,9 @@ vi.mock("@formbricks/database", () => ({ workspace: { findUnique: vi.fn(), }, + segment: { + findMany: vi.fn(), + }, }, })); @@ -334,6 +337,119 @@ describe("getWorkspaceStateData", () => { expect(codes).toEqual(["en-US", "fil-PH", "ak-GH", "en", "tl", "ak", "tw"]); }); + // Survey-interaction segment gate: per-survey `interactionRefresh`. + const interactionSurvey = (id: string, operator: string, targetSurveyId: string) => ({ + ...mockWorkspaceData.surveys[0], + id, + segment: { + id: `seg-${id}`, + filters: [ + { + id: "f1", + connector: null, + resource: { + id: "r1", + root: { type: "surveyInteraction" }, + qualifier: { operator }, + value: { + surveyScope: "specific", + surveyIds: [targetSurveyId], + within: { amount: 1, unit: "months" }, + }, + }, + }, + ], + }, + }); + + test("attaches per-survey interactionRefresh for an interaction filter", async () => { + // survey-B references survey-A via "have seen" → A gets onDisplay only; B (referenced by nobody) all-false. + vi.mocked(prisma.workspace.findUnique).mockResolvedValue({ + ...mockWorkspaceData, + surveys: [ + { ...mockWorkspaceData.surveys[0], id: "survey-A", segment: null }, + interactionSurvey("survey-B", "haveSeen", "survey-A"), + ], + } as never); + vi.mocked(prisma.segment.findMany).mockResolvedValue([] as never); + + const result = await getWorkspaceStateData(workspaceId); + + const byId = Object.fromEntries(result.surveys.map((s) => [s.id, s])); + expect((byId["survey-A"] as { interactionRefresh?: unknown }).interactionRefresh).toEqual({ + onDisplay: true, + onResponse: false, + onFinished: false, + }); + expect((byId["survey-B"] as { interactionRefresh?: unknown }).interactionRefresh).toEqual({ + onDisplay: false, + onResponse: false, + onFinished: false, + }); + }); + + test("resolves an interaction filter hidden inside a nested userIsIn segment", async () => { + // survey-A's segment only references segment "seg-nested" (userIsIn); that segment holds the + // "have completed survey-A" interaction leaf, so A must still be flagged onFinished. + vi.mocked(prisma.workspace.findUnique).mockResolvedValue({ + ...mockWorkspaceData, + surveys: [ + { + ...mockWorkspaceData.surveys[0], + id: "survey-A", + segment: { + id: "seg-a", + filters: [ + { + id: "f1", + connector: null, + resource: { + id: "r1", + root: { type: "segment", segmentId: "seg-nested" }, + qualifier: { operator: "userIsIn" }, + value: "seg-nested", + }, + }, + ], + }, + }, + ], + } as never); + vi.mocked(prisma.segment.findMany).mockResolvedValue([ + { + id: "seg-nested", + filters: [ + { + id: "f2", + connector: null, + resource: { + id: "r2", + root: { type: "surveyInteraction" }, + qualifier: { operator: "haveCompleted" }, + value: { + surveyScope: "specific", + surveyIds: ["survey-A"], + within: { amount: 1, unit: "months" }, + }, + }, + }, + ], + }, + ] as never); + + const result = await getWorkspaceStateData(workspaceId); + + expect((result.surveys[0] as { interactionRefresh?: unknown }).interactionRefresh).toEqual({ + onDisplay: false, + onResponse: false, + onFinished: true, + }); + expect(prisma.segment.findMany).toHaveBeenCalledWith({ + where: { workspaceId }, + select: { id: true, filters: true }, + }); + }); + test("distinguishes Simplified and Traditional Chinese (script preserved, not a bare 'zh')", async () => { vi.mocked(prisma.workspace.findUnique).mockResolvedValue({ ...mockWorkspaceData, diff --git a/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.ts b/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.ts index b3ae5d552423..dee463fd9fd3 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.ts @@ -9,6 +9,7 @@ import { TJsWorkspaceStateSurvey, TJsWorkspaceStateWorkspaceSetting, } from "@formbricks/types/js"; +import { type TBaseFilters, buildSurveyInteractionRefreshMap } from "@formbricks/types/segment"; import { toLegacyLanguageCodes } from "@/lib/i18n/utils"; import { validateInputs } from "@/lib/utils/validate"; import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; @@ -202,6 +203,36 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise ({ + id: survey.id, + segmentFilters: (Array.isArray(survey.segment?.filters) + ? (survey.segment.filters as unknown as TBaseFilters) + : null) as TBaseFilters | null, + })); + + // Nested `userIsIn` / `userIsNotIn` filters can hide an interaction filter inside a referenced + // segment, and those affect membership at runtime too — so resolve them. Load the workspace's + // segments once (a small, indexed query behind the 60s workspace-state cache) only when some + // survey actually has filters; the common no-targeting case pays nothing. + let segmentFiltersById: Map | null = null; + if (surveysWithFilters.some((survey) => (survey.segmentFilters?.length ?? 0) > 0)) { + const workspaceSegments = await prisma.segment.findMany({ + where: { workspaceId }, + select: { id: true, filters: true }, + }); + segmentFiltersById = new Map( + workspaceSegments.map((segment) => [segment.id, segment.filters as unknown as TBaseFilters]) + ); + } + + const { refreshBySurveyId, hasAny } = buildSurveyInteractionRefreshMap(surveysWithFilters, (segmentId) => + segmentFiltersById?.get(segmentId) + ); + const transformedSurveys = workspaceData.surveys.map((survey) => { const realHasFilters = Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0; @@ -242,10 +273,15 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise ({ vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({ getSegments: vi.fn(), + getExistingWorkspaceSurveyIds: vi.fn(), })); vi.mock("@/modules/ee/license-check/lib/utils", () => ({ @@ -204,6 +205,21 @@ const mockSegments = (ids: string[]): void => { ); }; +const surveyInteractionNode = (id: string, surveyScope: "any" | "specific", surveyIds: string[]) => ({ + id, + connector: null, + resource: { + id: `${id}_res`, + root: { type: "surveyInteraction" }, + qualifier: { operator: "haveSeen" }, + value: { surveyScope, surveyIds, within: { amount: 1, unit: "months" } }, + }, +}); + +const mockSurveyRefs = (ids: string[]): void => { + vi.mocked(getExistingWorkspaceSurveyIds).mockResolvedValueOnce(new Set(ids)); +}; + describe("assertV3SurveyTargetingFilterReferences", () => { test("performs no workspace lookup when filters need none", async () => { const validDeviceOnly = [deviceFilterNode("f1", "phone")] as unknown as TFilterTree; @@ -356,4 +372,38 @@ describe("assertV3SurveyTargetingFilterReferences", () => { ], }); }); + + test("skips the survey lookup for an any-scope survey interaction filter", async () => { + const filters = [surveyInteractionNode("f1", "any", [])] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).resolves.toBeUndefined(); + expect(getExistingWorkspaceSurveyIds).not.toHaveBeenCalled(); + }); + + test("resolves a specific-scope survey interaction filter against workspace surveys", async () => { + mockSurveyRefs(["survey_known"]); + const filters = [surveyInteractionNode("f1", "specific", ["survey_known"])] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).resolves.toBeUndefined(); + // Validated by the exact referenced ids (unbounded), not the picker's recent-window list. + expect(getExistingWorkspaceSurveyIds).toHaveBeenCalledWith("ws_1", ["survey_known"]); + }); + + test("rejects a survey interaction filter referencing an unknown or foreign survey", async () => { + mockSurveyRefs(["survey_known"]); + const filters = [ + surveyInteractionNode("f1", "specific", ["survey_known", "survey_foreign"]), + ] as unknown as TFilterTree; + + await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).rejects.toMatchObject({ + invalidParams: [ + { + name: "targeting.filters.0.resource.value.surveyIds.1", + reason: "Survey 'survey_foreign' was not found in this workspace", + code: "invalid_reference", + identifier: "survey_foreign", + }, + ], + }); + }); }); diff --git a/apps/web/app/api/v3/surveys/targeting.ts b/apps/web/app/api/v3/surveys/targeting.ts index 68d4ede2b43c..e58f77320c1c 100644 --- a/apps/web/app/api/v3/surveys/targeting.ts +++ b/apps/web/app/api/v3/surveys/targeting.ts @@ -7,11 +7,12 @@ import type { TSegmentDeviceFilter, TSegmentPersonFilter, TSegmentSegmentFilter, + TSegmentSurveyInteractionFilter, } from "@formbricks/types/segment"; import type { InvalidParam } from "@/app/api/v3/lib/response"; import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; -import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; +import { getExistingWorkspaceSurveyIds, getSegments } from "@/modules/ee/contacts/segments/lib/segments"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { V3SurveyReferenceValidationError } from "./reference-validation"; import type { TV3SurveyTargeting } from "./schemas"; @@ -115,7 +116,7 @@ const V3_TARGETING_DEVICE_VALUES = ["phone", "desktop"] as const; // whose identifier can never match) and `invalidDeviceValue` (a device filter whose value isn't a // known device). type TV3TargetingFilterReference = { - kind: "attributeKey" | "segment" | "unsupportedPersonIdentifier" | "invalidDeviceValue"; + kind: "attributeKey" | "segment" | "survey" | "unsupportedPersonIdentifier" | "invalidDeviceValue"; value: string; path: string; }; @@ -201,6 +202,19 @@ function collectV3TargetingFilterReferences( ]; return deviceIssues.filter((issue): issue is TV3TargetingFilterReference => issue !== null); } + case "surveyInteraction": { + // Only "specific" scope references surveys; "any" scope matches all surveys and needs no + // reference check. Each referenced survey id must resolve within the workspace. + const { value } = resource as TSegmentSurveyInteractionFilter; + if (value.surveyScope !== "specific") { + return []; + } + return value.surveyIds.map((surveyId, surveyIndex) => ({ + kind: "survey" as const, + value: surveyId, + path: `${resourcePath}.value.surveyIds.${surveyIndex}`, + })); + } default: return []; } @@ -274,6 +288,25 @@ export async function assertV3SurveyTargetingFilterReferences( } } + const surveyRefs = references.filter((ref) => ref.kind === "survey"); + if (surveyRefs.length > 0) { + // Scope to the workspace's own surveys so a survey-interaction filter cannot reference another + // workspace's survey. Look up by the exact referenced ids (not the picker's bounded recent list) + // so a valid survey in a large workspace isn't wrongly rejected for being outside that window. + const knownSurveyIds = await getExistingWorkspaceSurveyIds( + workspaceId, + surveyRefs.map((ref) => ref.value) + ); + for (const reference of surveyRefs.filter((ref) => !knownSurveyIds.has(ref.value))) { + invalidParams.push({ + name: reference.path, + reason: `Survey '${reference.value}' was not found in this workspace`, + code: "invalid_reference", + identifier: reference.value, + }); + } + } + if (invalidParams.length > 0) { throw new V3SurveyReferenceValidationError(invalidParams); } diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index e1b97a726c7c..0e15bb4fccf5 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1917,6 +1917,7 @@ checksums: workspace/contacts/upload_contacts_success: cd5d6b6d587586dd4f944868c92835bc workspace/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4 workspace/general/cannot_delete_only_workspace: 853f32a75d92b06eaccc0d43d767c183 + workspace/general/cooldown_period_updated_successfully: 55ff7f3874ea30a1961107c495407696 workspace/general/custom_scripts: a6a06a2e20764d76d3e22e5e17d98dbb workspace/general/custom_scripts_card_description: 1585c47126e4b68f9f79f232631c67a1 workspace/general/custom_scripts_description: 1c477e711fc08850b2ab70d98ffe18d6 @@ -1931,11 +1932,10 @@ checksums: workspace/general/delete_workspace_settings_description: 411ef100f167fc8fca64e833b6c0d030 workspace/general/error_saving_workspace_information: e7b8022785619ef34de1fb1630b3c476 workspace/general/only_owners_or_managers_can_delete_workspaces: 58da180cd2610210302d85a9896d80bd - workspace/general/recontact_waiting_time: 6873c18d51830e2cadef67cce6a2c95c - workspace/general/recontact_waiting_time_settings_description: ebd64fddbea9387b12c027a18358db7e + workspace/general/recontact_cooldown_period: 6873c18d51830e2cadef67cce6a2c95c + workspace/general/recontact_cooldown_period_settings_description: ebd64fddbea9387b12c027a18358db7e workspace/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9 workspace/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6 - workspace/general/waiting_period_updated_successfully: fe21f3034e63ed079dc0f9b9b1dfdbdf workspace/general/whats_your_workspace_called: 3dc82d99f2ac9789c5c92b64a4addc0a workspace/general/workspace_deleted_successfully: 5eb380fdb741034a479644b5e7547099 workspace/general/workspace_name_settings_description: b2754fbb4b117ee7e78271b68255cc05 @@ -2210,6 +2210,7 @@ checksums: workspace/look/unlock_remove_branding_with_lite_license: 4ad1f6e457297dec10705ce7760580b2 workspace/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905 workspace/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a + workspace/segments/any_survey: 7d0016d308c65b27dcabcaf725bb20a5 workspace/segments/cannot_delete_segment_used_in_surveys: c103d594711c8a08970dc01787bcc93e workspace/segments/clone_and_edit_segment: dce49d8027b5efda6daebaf86533cf7f workspace/segments/create_group: 4566e056e5217dc02a383105892fe18c @@ -2231,9 +2232,15 @@ checksums: workspace/segments/no_attributes_yet: 57beecc917dcd598ccdd0ccfb364a960 workspace/segments/no_filters_yet: d885a68516840e15dd27f1c17d9a8975 workspace/segments/no_segments_yet: 6307a4163a5bd553bb2aba074d24be9c + workspace/segments/number: bce5f14f4f0f190d9e23897b28b1a2e8 workspace/segments/operator_contains: 06dd606c0a8f81f9a03b414e9ae89440 workspace/segments/operator_does_not_contain: 854da2bdf10613ce62fb454bab16c58b workspace/segments/operator_ends_with: 2bd866369766c6a2ef74bb9fa74b1d7e + workspace/segments/operator_have_completed: 9945aa28b4a7724c396a52e3925afd37 + workspace/segments/operator_have_not_completed: eea52b46f7a99f32a1f6856c9ad2c1ad + workspace/segments/operator_have_not_seen: d09cfdd531572edb24f339f841dcce14 + workspace/segments/operator_have_seen: 330e5259fa18df4f39adf0c11313f5fa + workspace/segments/operator_have_started_responding_to: 16bb01bd0389c199c73cb78ee5e0da2d workspace/segments/operator_is_after: f9d9296eb9a5a7d168cc4e65a4095a87 workspace/segments/operator_is_before: 2462480cf4e8d2832b64004fbd463e55 workspace/segments/operator_is_between: 41ff45044d8a017a8a74f72be57916b8 @@ -2249,6 +2256,11 @@ checksums: workspace/segments/operator_title_equals: 73439e2839b8049e68079b1b6f2e3c41 workspace/segments/operator_title_greater_equal: 556b342cee0ac7055171e41be80f49e4 workspace/segments/operator_title_greater_than: e06dabbbf3a9c527502c997101edab40 + workspace/segments/operator_title_have_completed: a48f4fd17e844420e8d01b787dd8e3d1 + workspace/segments/operator_title_have_not_completed: 1e8e5e53a37cbc36b881a3ceb50786c6 + workspace/segments/operator_title_have_not_seen: 16aa1bd390805c2fad597b7b721ed691 + workspace/segments/operator_title_have_seen: 98f9d5d4fb8965690dc60b420a0cc633 + workspace/segments/operator_title_have_started_responding_to: c7d449a391b0afb2528e5c64045ab55e workspace/segments/operator_title_is_after: bd4cf644e442fca330cb483528485e5f workspace/segments/operator_title_is_before: a47ce3825c5c7cea7ed7eb8d5505a2d5 workspace/segments/operator_title_is_between: 5721c877c60f0005dc4ce78d4c0d3fdc @@ -2265,6 +2277,7 @@ checksums: workspace/segments/operator_title_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3 workspace/segments/operator_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b workspace/segments/operator_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3 + workspace/segments/period: 0b667ede258e73d2d0a9d2db187b2d84 workspace/segments/person_and_attributes: 507023d577326a6326dd9603dcdc589d workspace/segments/phone: b9537ee90fc5b0116942e0af29d926cc workspace/segments/please_remove_the_segment_from_these_surveys_in_order_to_delete_it: 1858a8ae40bed3a8c06c3bb518e0b8aa @@ -2280,8 +2293,17 @@ checksums: workspace/segments/segment_updated_successfully: 2eca3ff5c645bdccd16c306d663114a1 workspace/segments/segment_used_in_other_surveys_make_changes_here: 64a5cb0e02c4868af68db022717a4f95 workspace/segments/segments_help_you_target_users_with_same_characteristics_easily: b18b75bda13fe408a467dd625611ea06 + workspace/segments/select_at_least_one_survey: b69861b2304733014a087565ac79ee2d + workspace/segments/specific_surveys: 623afb0cc476469640e419c054ee1fe3 + workspace/segments/survey_interaction: 5a4fb7c1572fca37ad976cdf561f5e07 workspace/segments/target_audience: ca47151c4f0ddb348e52ec43ce15eb03 workspace/segments/this_action_resets_all_filters_in_this_survey: a7b342c25f0d3d6060bfdff38ade0682 + workspace/segments/time_unit_day: cd54048cc134f397cad259e69dda0989 + workspace/segments/time_unit_days: c95fe8aedde21a0b5653dbd0b3c58b48 + workspace/segments/time_unit_month: 001abb365f085fae1e0b02d207e7b8ca + workspace/segments/time_unit_months: da74749fbe80394fa0f72973d7b0964a + workspace/segments/time_unit_week: a220b7dd32511c8c2ae1bb2b8960a228 + workspace/segments/time_unit_weeks: 545de30df4f44d3f6d1d344af6a10815 workspace/segments/title_is_required: e9b1d9309edf85645ab15cc90ae0a70f workspace/segments/unknown_filter_type: 67d05a804c1ad54434c0a90f47bb0304 workspace/segments/unlock_segments_description: 5cb5aff50997e6265807a15ed1456be1 @@ -2292,6 +2314,7 @@ checksums: workspace/segments/value_must_be_positive: d17ad009f7845a6fbeddeb2aef532e10 workspace/segments/view_filters: 791cd4bacb11e3eb0ffccee131270561 workspace/segments/where: 23aecda7d27f26121b057ec7f7327069 + workspace/segments/within_last: 479a8346b02dad41c41d771d351fce73 workspace/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d workspace/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89 workspace/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133 @@ -2936,6 +2959,8 @@ checksums: workspace/surveys/edit/continue_to_settings: b9853a7eedb3ae295088268fe5a44824 workspace/surveys/edit/convert_to_multiple_choice: e5396019ae897f6ec4c4295394c115e3 workspace/surveys/edit/convert_to_single_choice: 8ecabfcb9276f29e6ac962ffcbc1ba64 + workspace/surveys/edit/cooldown_period_across_surveys: 6873c18d51830e2cadef67cce6a2c95c + workspace/surveys/edit/cooldown_period_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e workspace/surveys/edit/country: 73581fc33a1e83e6a56db73558e7b5c6 workspace/surveys/edit/create_group: 4566e056e5217dc02a383105892fe18c workspace/surveys/edit/create_your_own_survey: e3ddd53e0cfa409ca8dccfb3d77933e7 @@ -3056,8 +3081,8 @@ checksums: workspace/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef workspace/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62 workspace/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6 - workspace/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489 - workspace/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859 + workspace/surveys/edit/ignore_global_cooldown_period: e08db543ace4935625e0961cc6e60489 + workspace/surveys/edit/ignore_global_cooldown_period_description: 37d173a4d537622de40677389238d859 workspace/surveys/edit/image: 048ba7a239de0fbd883ade8558415830 workspace/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031 workspace/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884 @@ -3136,8 +3161,8 @@ checksums: workspace/surveys/edit/options: 39ba06e709561761d922ca1254155d5f workspace/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355 workspace/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f - workspace/surveys/edit/overwrite_global_waiting_time: bf39ba91e35742e1ff3a281a18e9159c - workspace/surveys/edit/overwrite_global_waiting_time_description: c5ffe497e889e955f983bc82dbe2da3f + workspace/surveys/edit/overwrite_global_cooldown_period: bf39ba91e35742e1ff3a281a18e9159c + workspace/surveys/edit/overwrite_global_cooldown_period_description: c5ffe497e889e955f983bc82dbe2da3f workspace/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342 workspace/surveys/edit/overwrite_survey_logo: a89cb566dfcc1559446abd8b830c84ed workspace/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48 @@ -3210,8 +3235,8 @@ checksums: workspace/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5 workspace/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a workspace/surveys/edit/reset_to_theme_styles_main_text: d86fb2213d3b2efbd0361526dc6cb27b - workspace/surveys/edit/respect_global_waiting_time: 971a6f60986862995f7cc1a5611ed892 - workspace/surveys/edit/respect_global_waiting_time_description: b39daa9c8035e18fcfc3f8c93f2f84f6 + workspace/surveys/edit/respect_global_cooldown_period: 971a6f60986862995f7cc1a5611ed892 + workspace/surveys/edit/respect_global_cooldown_period_description: b39daa9c8035e18fcfc3f8c93f2f84f6 workspace/surveys/edit/response_limit_can_t_be_set_to_0: 4588a7ec31020300d3613a79763dc725 workspace/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9 workspace/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802 @@ -3359,8 +3384,6 @@ checksums: workspace/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919 workspace/surveys/edit/wait_n_days_before_showing_this_survey_again: 1fbe83d8aaf59846d779e4e23d7d168b workspace/surveys/edit/wait_n_seconds_before_showing_the_survey: 8ff15e96a2f4ef23117bcd6da1cabdae - workspace/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c - workspace/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e workspace/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051 workspace/surveys/edit/when: a40ad3eed1b75e76226290eeb9bb20cd workspace/surveys/edit/wide: c6777426a822d7d1b7d2010ce824ad7d @@ -3592,7 +3615,7 @@ checksums: workspace/surveys/summary/in_app/display_criteria.time_based_always: b0ae6a873ce2eeb0aea2e6d4cb04c540 workspace/surveys/summary/in_app/display_criteria.time_based_day: 47648cd60fc313bc3f05b70357a1d675 workspace/surveys/summary/in_app/display_criteria.time_based_days: be91060aa52f40103be636c55c8c50e2 - workspace/surveys/summary/in_app/display_criteria.time_based_description: a5fbe92dc4db6eb45ee4422540b40919 + workspace/surveys/summary/in_app/display_criteria.time_based_description: 5f4d44371d3638e9eaf515336bb88713 workspace/surveys/summary/in_app/display_criteria.trigger_description: b34d3e1555ff39f2dc3dea1f1854636f workspace/surveys/summary/in_app/documentation_title: 061e1a335603979f383424874b6d1ba1 workspace/surveys/summary/in_app/html_embed: d67c1b1d78f6259498e0c64f69fd8cb6 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 41dac2bece10..d751f62f101d 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks-Logo", "general": { "cannot_delete_only_workspace": "Dies ist dein einziger Workspace und kann nicht gelöscht werden. Erstelle zuerst einen neuen Workspace.", + "cooldown_period_updated_successfully": "Abkühlzeit erfolgreich aktualisiert", "custom_scripts": "Benutzerdefinierte Skripte", "custom_scripts_card_description": "Füge Tracking-Skripte und Pixel zu allen Link-Umfragen in diesem Workspace hinzu.", "custom_scripts_description": "Skripte werden in den aller Link-Umfrage-Seiten eingefügt.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Lösche den Workspace mit allen Umfragen, Antworten, Personen, Aktionen und Attributen. Dies kann nicht rückgängig gemacht werden.", "error_saving_workspace_information": "Fehler beim Speichern der Workspace-Informationen", "only_owners_or_managers_can_delete_workspaces": "Nur Inhaber oder Manager können Workspaces löschen", - "recontact_waiting_time": "Abkühlungsphase (workspace-übergreifend)", - "recontact_waiting_time_settings_description": "Lege fest, wie häufig Nutzer über alle Website- & App-Umfragen in diesem Workspace hinweg befragt werden können.", + "recontact_cooldown_period": "Abkühlphase (umfragenübergreifend)", + "recontact_cooldown_period_settings_description": "Lege fest, wie häufig Nutzer über alle Website- und App-Umfragen in diesem Workspace befragt werden können.", "this_action_cannot_be_undone": "Diese Aktion kann nicht rückgängig gemacht werden.", "wait_x_days_before_showing_next_survey": "Warte X Tage, bevor die nächste Umfrage gezeigt wird:", - "waiting_period_updated_successfully": "Wartezeit erfolgreich aktualisiert", "whats_your_workspace_called": "Wie heißt dein Workspace?", "workspace_deleted_successfully": "Workspace erfolgreich gelöscht", "workspace_name_settings_description": "Ändere den Namen deines Workspaces.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Filter darunter hinzufügen", "add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen", + "any_survey": "beliebige Umfrage", "cannot_delete_segment_used_in_surveys": "Du kannst dieses Segment nicht löschen, da es noch in diesen Umfragen verwendet wird:", "clone_and_edit_segment": "Segment klonen & bearbeiten", "create_group": "Gruppe erstellen", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Noch keine Attribute!", "no_filters_yet": "Es gibt noch keine Filter!", "no_segments_yet": "Du hast derzeit keine gespeicherten Segmente.", + "number": "Anzahl", "operator_contains": "enthält", "operator_does_not_contain": "enthält nicht", "operator_ends_with": "endet mit", + "operator_have_completed": "haben abgeschlossen", + "operator_have_not_completed": "haben nicht abgeschlossen", + "operator_have_not_seen": "haben nicht gesehen", + "operator_have_seen": "haben gesehen", + "operator_have_started_responding_to": "haben begonnen zu antworten auf", "operator_is_after": "ist nach", "operator_is_before": "ist vor", "operator_is_between": "ist zwischen", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Ist gleich", "operator_title_greater_equal": "Größer oder gleich", "operator_title_greater_than": "Größer als", + "operator_title_have_completed": "Haben abgeschlossen", + "operator_title_have_not_completed": "Haben nicht abgeschlossen", + "operator_title_have_not_seen": "Haben nicht gesehen", + "operator_title_have_seen": "Haben gesehen", + "operator_title_have_started_responding_to": "Haben begonnen zu antworten auf", "operator_title_is_after": "Ist nach", "operator_title_is_before": "Ist vor", "operator_title_is_between": "Ist zwischen", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Nutzer ist nicht in", "operator_user_is_in": "Nutzer ist in", "operator_user_is_not_in": "Nutzer ist nicht in", + "period": "Zeitraum", "person_and_attributes": "Person & Attribute", "phone": "Telefon", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Bitte entferne das Segment aus diesen Umfragen, um es zu löschen.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segment erfolgreich aktualisiert", "segment_used_in_other_surveys_make_changes_here": "Dieses Segment wird in anderen Umfragen verwendet. Nimm Änderungen hier vor.", "segments_help_you_target_users_with_same_characteristics_easily": "Segmente helfen dir, Nutzer mit gleichen Merkmalen einfach anzusprechen", + "select_at_least_one_survey": "Wähle mindestens eine Umfrage aus", + "specific_surveys": "bestimmte Umfragen", + "survey_interaction": "Umfrage-Interaktion", "target_audience": "Zielgruppe", "this_action_resets_all_filters_in_this_survey": "Diese Aktion setzt alle Filter in dieser Umfrage zurück.", + "time_unit_day": "Tag", + "time_unit_days": "Tage", + "time_unit_month": "Monat", + "time_unit_months": "Monate", + "time_unit_week": "Woche", + "time_unit_weeks": "Wochen", "title_is_required": "Titel ist erforderlich.", "unknown_filter_type": "Unbekannter Filtertyp", "unlock_segments_description": "Organisiere Kontakte in Segmenten, um bestimmte Nutzergruppen gezielt anzusprechen", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Wert muss eine Zahl sein.", "value_must_be_positive": "Wert muss eine positive Zahl sein.", "view_filters": "Filter anzeigen", - "where": "Wo" + "where": "Wo", + "within_last": "innerhalb der letzten" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Weiter zu den Einstellungen", "convert_to_multiple_choice": "In Mehrfachauswahl umwandeln", "convert_to_single_choice": "In Einfachauswahl umwandeln", + "cooldown_period_across_surveys": "Abkühlphase (umfragenübergreifend)", + "cooldown_period_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der workspace-weiten Abkühlphase interagiert.", "country": "Land", "create_group": "Gruppe erstellen", "create_your_own_survey": "Erstelle deine eigene Umfrage", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Frageneinstellungen ausblenden", "hostname": "Hostname", "if_you_really_want_that_answer_ask_until_you_get_it": "Zeige die Umfrage bei jedem Auslösen weiter an, bis eine Antwort oder Teilantwort abgegeben wurde.", - "ignore_global_waiting_time": "Wartezeit ignorieren", - "ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, sobald ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage gezeigt wurde.", + "ignore_global_cooldown_period": "Abkühlphase ignorieren", + "ignore_global_cooldown_period_description": "Diese Umfrage kann angezeigt werden, sobald ihre Bedingungen erfüllt sind – auch wenn kürzlich eine andere Umfrage gezeigt wurde.", "image": "Bild", "includes_all_of": "Enthält alle von", "includes_one_of": "Enthält eines von", @@ -3253,8 +3278,8 @@ "options": "Optionen*", "options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.", "override_theme_with_individual_styles_for_this_survey": "Überschreibe das Theme mit individuellen Styles für diese Umfrage.", - "overwrite_global_waiting_time": "Benutzerdefinierte Cooldown-Periode festlegen", - "overwrite_global_waiting_time_description": "Überschreibe die Workspace-Konfiguration nur für diese Umfrage.", + "overwrite_global_cooldown_period": "Individuelle Abkühlphase festlegen", + "overwrite_global_cooldown_period_description": "Überschreibe die Workspace-Konfiguration nur für diese Umfrage.", "overwrite_placement": "Platzierung überschreiben", "overwrite_survey_logo": "Benutzerdefiniertes Umfrage-Logo festlegen", "overwrite_the_global_placement_of_the_survey": "Überschreibe die globale Platzierung der Umfrage", @@ -3329,8 +3354,8 @@ "required": "Pflichtfeld", "reset_to_theme_styles": "Auf Theme-Stile zurücksetzen", "reset_to_theme_styles_main_text": "Möchtest du das Styling wirklich auf die Theme-Stile zurücksetzen? Dadurch werden alle benutzerdefinierten Anpassungen entfernt.", - "respect_global_waiting_time": "Cooldown-Periode nutzen", - "respect_global_waiting_time_description": "Diese Umfrage folgt der Cooldown-Periode, die in der Workspace-Konfiguration festgelegt ist. Sie wird nur angezeigt, wenn in diesem Zeitraum keine andere Umfrage erschienen ist.", + "respect_global_cooldown_period": "Abkühlphase verwenden", + "respect_global_cooldown_period_description": "Diese Umfrage folgt der Abkühlphase, die in der Workspace-Konfiguration festgelegt wurde. Sie wird nur angezeigt, wenn während dieser Phase keine andere Umfrage erschienen ist.", "response_limit_can_t_be_set_to_0": "Das Antwortlimit kann nicht auf 0 gesetzt werden", "response_limit_needs_to_exceed_number_of_received_responses": "Das Antwortlimit muss die Anzahl der erhaltenen Antworten überschreiten ({responseCount}).", "response_limits_redirections_and_more": "Antwortlimits, Weiterleitungen und mehr.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst", "wait_n_days_before_showing_this_survey_again": "Warte oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dieser Umfrage.", "wait_n_seconds_before_showing_the_survey": "Warte Sekunden, bevor die Umfrage angezeigt wird.", - "waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)", - "waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der arbeitsbereichsweiten Abkühlphase interagiert.", "welcome_message": "Willkommensnachricht", "when": "Wann", "wide": "Breit", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Umfrage immer anzeigen", "display_criteria.time_based_day": "Tag", "display_criteria.time_based_days": "Tage", - "display_criteria.time_based_description": "Globale Wartezeit", + "display_criteria.time_based_description": "Globale Abkühlphase", "display_criteria.trigger_description": "Umfrage-Trigger", "documentation_title": "Verteile Intercept-Umfragen auf allen Plattformen", "html_embed": "HTML-Einbettung in ", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index c502ae8b464a..d0b5f0a07313 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks Logo", "general": { "cannot_delete_only_workspace": "This is your only workspace, it cannot be deleted. Create a new workspace first.", + "cooldown_period_updated_successfully": "Cooldown period updated successfully", "custom_scripts": "Custom Scripts", "custom_scripts_card_description": "Add tracking scripts and pixels to all link surveys in this workspace.", "custom_scripts_description": "Scripts will be injected into the of all link survey pages.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.", "error_saving_workspace_information": "Error saving workspace information", "only_owners_or_managers_can_delete_workspaces": "Only owners or managers can delete workspaces", - "recontact_waiting_time": "Cooldown Period (across surveys)", - "recontact_waiting_time_settings_description": "Control how frequently users can be surveyed across all Website & App Surveys in this workspace.", + "recontact_cooldown_period": "Cooldown Period (across surveys)", + "recontact_cooldown_period_settings_description": "Control how frequently users can be surveyed across all Website & App Surveys in this workspace.", "this_action_cannot_be_undone": "This action cannot be undone.", "wait_x_days_before_showing_next_survey": "Wait X days before showing next survey:", - "waiting_period_updated_successfully": "Waiting period updated successfully", "whats_your_workspace_called": "What is your workspace called?", "workspace_deleted_successfully": "Workspace deleted successfully", "workspace_name_settings_description": "Change your workspace’s name.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Add filter below", "add_your_first_filter_to_get_started": "Add your first filter to get started", + "any_survey": "any survey", "cannot_delete_segment_used_in_surveys": "You cannot delete this segment since it is still used in these surveys:", "clone_and_edit_segment": "Clone & Edit Segment", "create_group": "Create group", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "No attributes yet!", "no_filters_yet": "There are no filters yet!", "no_segments_yet": "You currently have no saved segments.", + "number": "number", "operator_contains": "contains", "operator_does_not_contain": "does not contain", "operator_ends_with": "ends with", + "operator_have_completed": "have completed", + "operator_have_not_completed": "have not completed", + "operator_have_not_seen": "have not seen", + "operator_have_seen": "have seen", + "operator_have_started_responding_to": "have started responding to", "operator_is_after": "is after", "operator_is_before": "is before", "operator_is_between": "is between", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Equals", "operator_title_greater_equal": "Greater than or equal to", "operator_title_greater_than": "Greater than", + "operator_title_have_completed": "Have completed", + "operator_title_have_not_completed": "Have not completed", + "operator_title_have_not_seen": "Have not seen", + "operator_title_have_seen": "Have seen", + "operator_title_have_started_responding_to": "Have started responding to", "operator_title_is_after": "Is after", "operator_title_is_before": "Is before", "operator_title_is_between": "Is between", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "User is not in", "operator_user_is_in": "User is in", "operator_user_is_not_in": "User is not in", + "period": "period", "person_and_attributes": "Person & Attributes", "phone": "Phone", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Please remove the segment from these surveys in order to delete it.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segment updated successfully", "segment_used_in_other_surveys_make_changes_here": "This segment is used in other surveys. Make changes here.", "segments_help_you_target_users_with_same_characteristics_easily": "Segments help you target users with the same characteristics easily", + "select_at_least_one_survey": "Select at least one survey", + "specific_surveys": "specific surveys", + "survey_interaction": "Survey interaction", "target_audience": "Target Audience", "this_action_resets_all_filters_in_this_survey": "This action resets all filters in this survey.", + "time_unit_day": "day", + "time_unit_days": "days", + "time_unit_month": "month", + "time_unit_months": "months", + "time_unit_week": "week", + "time_unit_weeks": "weeks", "title_is_required": "Title is required.", "unknown_filter_type": "Unknown filter type", "unlock_segments_description": "Organize contacts into segments to target specific user groups", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Value must be a number.", "value_must_be_positive": "Value must be a positive number.", "view_filters": "View filters", - "where": "Where" + "where": "Where", + "within_last": "within the last" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Continue to Settings", "convert_to_multiple_choice": "Convert to Multi-select", "convert_to_single_choice": "Convert to Single-select", + "cooldown_period_across_surveys": "Cooldown Period (across surveys)", + "cooldown_period_across_surveys_description": "To prevent survey fatigue, choose how this survey interacts with the workspace-wide Cooldown Period.", "country": "Country", "create_group": "Create group", "create_your_own_survey": "Create your own survey", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Hide Question settings", "hostname": "Hostname", "if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.", - "ignore_global_waiting_time": "Ignore Cooldown Period", - "ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.", + "ignore_global_cooldown_period": "Ignore Cooldown Period", + "ignore_global_cooldown_period_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.", "image": "Image", "includes_all_of": "Includes all of", "includes_one_of": "Includes one of", @@ -3253,8 +3278,8 @@ "options": "Options*", "options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.", "override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.", - "overwrite_global_waiting_time": "Set custom Cooldown Period", - "overwrite_global_waiting_time_description": "Override the workspace configuration for this survey only.", + "overwrite_global_cooldown_period": "Set custom Cooldown Period", + "overwrite_global_cooldown_period_description": "Override the workspace configuration for this survey only.", "overwrite_placement": "Overwrite placement", "overwrite_survey_logo": "Set custom survey logo", "overwrite_the_global_placement_of_the_survey": "Overwrite the global placement of the survey", @@ -3329,8 +3354,8 @@ "required": "Required", "reset_to_theme_styles": "Reset to theme styles", "reset_to_theme_styles_main_text": "Are you sure you want to reset the styling to the theme styles? This will remove all custom styling.", - "respect_global_waiting_time": "Use Cooldown Period", - "respect_global_waiting_time_description": "This survey follows the Cooldown Period set in the workspace’s configuration. It only shows if no other survey has appeared during that period.", + "respect_global_cooldown_period": "Use Cooldown Period", + "respect_global_cooldown_period_description": "This survey follows the Cooldown Period set in the workspace’s configuration. It only shows if no other survey has appeared during that period.", "response_limit_can_t_be_set_to_0": "Response limit cannot be set to 0", "response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).", "response_limits_redirections_and_more": "Response limits, redirections and more.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey", "wait_n_days_before_showing_this_survey_again": "Wait or more days to pass between the last shown survey and showing this survey.", "wait_n_seconds_before_showing_the_survey": "Wait seconds before showing the survey.", - "waiting_time_across_surveys": "Cooldown Period (across surveys)", - "waiting_time_across_surveys_description": "To prevent survey fatigue, choose how this survey interacts with the workspace-wide Cooldown Period.", "welcome_message": "Welcome message", "when": "When", "wide": "Wide", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Always show survey", "display_criteria.time_based_day": "Day", "display_criteria.time_based_days": "Days", - "display_criteria.time_based_description": "Global waiting time", + "display_criteria.time_based_description": "Global cooldown period", "display_criteria.trigger_description": "Survey trigger", "documentation_title": "Distribute intercept surveys on all platforms", "html_embed": "HTML embed in ", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index b15e0b47bfe5..b37a01207904 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Logo de Formbricks", "general": { "cannot_delete_only_workspace": "Este es tu único espacio de trabajo, no se puede eliminar. Crea primero un nuevo espacio de trabajo.", + "cooldown_period_updated_successfully": "Período de espera actualizado correctamente", "custom_scripts": "Scripts personalizados", "custom_scripts_card_description": "Añade scripts de seguimiento y píxeles a todas las encuestas con enlace en este espacio de trabajo.", "custom_scripts_description": "Los scripts se inyectarán en el de todas las páginas de encuestas con enlace.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Elimina el espacio de trabajo con todas las encuestas, respuestas, personas, acciones y atributos. Esta acción no se puede deshacer.", "error_saving_workspace_information": "Error al guardar la información del espacio de trabajo", "only_owners_or_managers_can_delete_workspaces": "Solo los propietarios o administradores pueden eliminar espacios de trabajo", - "recontact_waiting_time": "Periodo de espera (entre encuestas)", - "recontact_waiting_time_settings_description": "Controla con qué frecuencia se puede encuestar a los usuarios en todas las encuestas de sitio web y aplicación de este espacio de trabajo.", + "recontact_cooldown_period": "Periodo de espera (entre encuestas)", + "recontact_cooldown_period_settings_description": "Controla con qué frecuencia se puede encuestar a los usuarios en todas las Encuestas de Web y App de este espacio de trabajo.", "this_action_cannot_be_undone": "Esta acción no se puede deshacer.", "wait_x_days_before_showing_next_survey": "Esperar X días antes de mostrar la siguiente encuesta:", - "waiting_period_updated_successfully": "Periodo de espera actualizado correctamente", "whats_your_workspace_called": "¿Cómo se llama tu espacio de trabajo?", "workspace_deleted_successfully": "Espacio de trabajo eliminado correctamente", "workspace_name_settings_description": "Cambia el nombre de tu espacio de trabajo.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Añadir filtro abajo", "add_your_first_filter_to_get_started": "Añade tu primer filtro para empezar", + "any_survey": "cualquier encuesta", "cannot_delete_segment_used_in_surveys": "No puedes eliminar este segmento ya que todavía se utiliza en estas encuestas:", "clone_and_edit_segment": "Clonar y editar segmento", "create_group": "Crear grupo", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "¡Aún no hay atributos!", "no_filters_yet": "¡Aún no hay filtros!", "no_segments_yet": "Actualmente no tienes segmentos guardados.", + "number": "número", "operator_contains": "contiene", "operator_does_not_contain": "no contiene", "operator_ends_with": "termina con", + "operator_have_completed": "han completado", + "operator_have_not_completed": "no han completado", + "operator_have_not_seen": "no han visto", + "operator_have_seen": "han visto", + "operator_have_started_responding_to": "han comenzado a responder", "operator_is_after": "es después de", "operator_is_before": "es antes de", "operator_is_between": "está entre", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Es igual a", "operator_title_greater_equal": "Mayor o igual que", "operator_title_greater_than": "Mayor que", + "operator_title_have_completed": "Han completado", + "operator_title_have_not_completed": "No han completado", + "operator_title_have_not_seen": "No han visto", + "operator_title_have_seen": "Han visto", + "operator_title_have_started_responding_to": "Han comenzado a responder", "operator_title_is_after": "Es después de", "operator_title_is_before": "Es antes de", "operator_title_is_between": "Está entre", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "El usuario no está en", "operator_user_is_in": "El usuario está en", "operator_user_is_not_in": "El usuario no está en", + "period": "período", "person_and_attributes": "Persona y atributos", "phone": "Teléfono", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, elimina el segmento de estas encuestas para poder borrarlo.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "¡Segmento actualizado con éxito!", "segment_used_in_other_surveys_make_changes_here": "Este segmento se usa en otras encuestas. Realiza cambios aquí.", "segments_help_you_target_users_with_same_characteristics_easily": "Los segmentos te ayudan a dirigirte fácilmente a usuarios con las mismas características", + "select_at_least_one_survey": "Selecciona al menos una encuesta", + "specific_surveys": "encuestas específicas", + "survey_interaction": "Interacción con encuesta", "target_audience": "Público objetivo", "this_action_resets_all_filters_in_this_survey": "Esta acción restablece todos los filtros en esta encuesta.", + "time_unit_day": "día", + "time_unit_days": "días", + "time_unit_month": "mes", + "time_unit_months": "meses", + "time_unit_week": "semana", + "time_unit_weeks": "semanas", "title_is_required": "El título es obligatorio.", "unknown_filter_type": "Tipo de filtro desconocido", "unlock_segments_description": "Organiza contactos en segmentos para dirigirte a grupos específicos de usuarios", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "El valor debe ser un número.", "value_must_be_positive": "El valor debe ser un número positivo.", "view_filters": "Ver filtros", - "where": "Donde" + "where": "Donde", + "within_last": "en los últimos" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Continuar a ajustes", "convert_to_multiple_choice": "Convertir a selección múltiple", "convert_to_single_choice": "Convertir a selección única", + "cooldown_period_across_surveys": "Periodo de espera (entre encuestas)", + "cooldown_period_across_surveys_description": "Para evitar la fatiga por encuestas, elige cómo interactúa esta encuesta con el Periodo de espera general del espacio de trabajo.", "country": "País", "create_group": "Crear grupo", "create_your_own_survey": "Crea tu propia encuesta", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Ocultar ajustes de la pregunta", "hostname": "Nombre de host", "if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.", - "ignore_global_waiting_time": "Ignorar periodo de espera", - "ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.", + "ignore_global_cooldown_period": "Ignorar Periodo de espera", + "ignore_global_cooldown_period_description": "Esta encuesta se puede mostrar cuando se cumplan sus condiciones, incluso si se mostró otra encuesta recientemente.", "image": "Imagen", "includes_all_of": "Incluye todo de", "includes_one_of": "Incluye uno de", @@ -3253,8 +3278,8 @@ "options": "Opciones*", "options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.", "override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.", - "overwrite_global_waiting_time": "Establecer periodo de espera personalizado", - "overwrite_global_waiting_time_description": "Anula la configuración del espacio de trabajo solo para esta encuesta.", + "overwrite_global_cooldown_period": "Establecer Periodo de espera personalizado", + "overwrite_global_cooldown_period_description": "Anula la configuración del espacio de trabajo solo para esta encuesta.", "overwrite_placement": "Sobrescribir ubicación", "overwrite_survey_logo": "Establecer logotipo personalizado para la encuesta", "overwrite_the_global_placement_of_the_survey": "Sobrescribir la ubicación global de la encuesta", @@ -3329,8 +3354,8 @@ "required": "Obligatorio", "reset_to_theme_styles": "Restablecer a los estilos del tema", "reset_to_theme_styles_main_text": "¿Estás seguro de que quieres restablecer el estilo a los estilos del tema? Esto eliminará todos los estilos personalizados.", - "respect_global_waiting_time": "Usar periodo de espera", - "respect_global_waiting_time_description": "Esta encuesta sigue el periodo de espera establecido en la configuración del espacio de trabajo. Solo se muestra si no ha aparecido ninguna otra encuesta durante ese periodo.", + "respect_global_cooldown_period": "Usar Periodo de espera", + "respect_global_cooldown_period_description": "Esta encuesta sigue el Periodo de espera establecido en la configuración del espacio de trabajo. Solo se muestra si no ha aparecido ninguna otra encuesta durante ese periodo.", "response_limit_can_t_be_set_to_0": "El límite de respuestas no puede establecerse en 0", "response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).", "response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta", "wait_n_days_before_showing_this_survey_again": "Esperar o más días entre la última encuesta mostrada y la visualización de esta encuesta.", "wait_n_seconds_before_showing_the_survey": "Esperar segundos antes de mostrar la encuesta.", - "waiting_time_across_surveys": "Periodo de espera (entre encuestas)", - "waiting_time_across_surveys_description": "Para evitar la fatiga de encuestas, elige cómo interactúa esta encuesta con el periodo de espera general del espacio de trabajo.", "welcome_message": "Mensaje de bienvenida", "when": "Cuando", "wide": "Ancho", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Mostrar encuesta siempre", "display_criteria.time_based_day": "Día", "display_criteria.time_based_days": "Días", - "display_criteria.time_based_description": "Tiempo de espera global", + "display_criteria.time_based_description": "Periodo de espera global", "display_criteria.trigger_description": "Disparador de encuesta", "documentation_title": "Distribuye encuestas interceptadas en todas las plataformas", "html_embed": "Inserción HTML en ", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index b1000c17a173..22d1739767c2 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Logo Formbricks", "general": { "cannot_delete_only_workspace": "C'est ton seul espace de travail, il ne peut pas être supprimé. Crée d'abord un nouvel espace de travail.", + "cooldown_period_updated_successfully": "Période de refroidissement mise à jour avec succès", "custom_scripts": "Scripts personnalisés", "custom_scripts_card_description": "Ajouter des scripts de suivi et des pixels à toutes les enquêtes par lien dans cet espace de travail.", "custom_scripts_description": "Les scripts seront injectés dans le de toutes les pages d'enquête par lien.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Supprimer l'espace de travail avec toutes les enquêtes, réponses, personnes, actions et attributs. Cette action est irréversible.", "error_saving_workspace_information": "Erreur lors de l'enregistrement des informations de l'espace de travail", "only_owners_or_managers_can_delete_workspaces": "Seuls les propriétaires ou les gestionnaires peuvent supprimer des espaces de travail", - "recontact_waiting_time": "Période de refroidissement (entre les sondages)", - "recontact_waiting_time_settings_description": "Contrôlez la fréquence à laquelle les utilisateurs peuvent être interrogés dans tous les sondages de site web et d'application de cet espace de travail.", + "recontact_cooldown_period": "Période de refroidissement (tous sondages confondus)", + "recontact_cooldown_period_settings_description": "Contrôlez la fréquence à laquelle les utilisateurs peuvent être sollicités pour l'ensemble des sondages sur site web et application dans cet espace de travail.", "this_action_cannot_be_undone": "Cette action ne peut pas être annulée.", "wait_x_days_before_showing_next_survey": "Attendre X jours avant d'afficher la prochaine enquête :", - "waiting_period_updated_successfully": "Période d'attente mise à jour avec succès", "whats_your_workspace_called": "Comment s'appelle ton espace de travail ?", "workspace_deleted_successfully": "Espace de travail supprimé avec succès", "workspace_name_settings_description": "Modifie le nom de ton espace de travail.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Ajouter un filtre ci-dessous", "add_your_first_filter_to_get_started": "Ajoutez votre premier filtre pour commencer.", + "any_survey": "n'importe quel sondage", "cannot_delete_segment_used_in_surveys": "Vous ne pouvez pas supprimer ce segment car il est encore utilisé dans ces enquêtes :", "clone_and_edit_segment": "Cloner et modifier le segment", "create_group": "Créer un groupe", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Aucun attribut pour le moment !", "no_filters_yet": "Il n'y a pas encore de filtres !", "no_segments_yet": "Aucun segment n'est actuellement enregistré.", + "number": "nombre", "operator_contains": "contient", "operator_does_not_contain": "ne contient pas", "operator_ends_with": "se termine par", + "operator_have_completed": "ont complété", + "operator_have_not_completed": "n'ont pas complété", + "operator_have_not_seen": "n'ont pas vu", + "operator_have_seen": "ont vu", + "operator_have_started_responding_to": "ont commencé à répondre à", "operator_is_after": "est après", "operator_is_before": "est avant", "operator_is_between": "est entre", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Égal", "operator_title_greater_equal": "Supérieur ou égal à", "operator_title_greater_than": "Supérieur à", + "operator_title_have_completed": "Ont complété", + "operator_title_have_not_completed": "N'ont pas complété", + "operator_title_have_not_seen": "N'ont pas vu", + "operator_title_have_seen": "Ont vu", + "operator_title_have_started_responding_to": "Ont commencé à répondre à", "operator_title_is_after": "Est après", "operator_title_is_before": "Est avant", "operator_title_is_between": "Est entre", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "L'utilisateur n'est pas dans", "operator_user_is_in": "L'utilisateur est dans", "operator_user_is_not_in": "L'utilisateur n'est pas dans", + "period": "période", "person_and_attributes": "Personne et attributs", "phone": "Téléphone", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Veuillez supprimer le segment de ces enquêtes afin de le supprimer.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segment mis à jour avec succès !", "segment_used_in_other_surveys_make_changes_here": "Ce segment est utilisé dans d'autres sondages. Fais les modifications ici.", "segments_help_you_target_users_with_same_characteristics_easily": "Les segments permettent de cibler facilement les utilisateurs ayant les mêmes caractéristiques.", + "select_at_least_one_survey": "Sélectionne au moins un sondage", + "specific_surveys": "sondages spécifiques", + "survey_interaction": "Interaction avec le sondage", "target_audience": "Public cible", "this_action_resets_all_filters_in_this_survey": "Cette action réinitialise tous les filtres de cette enquête.", + "time_unit_day": "jour", + "time_unit_days": "jours", + "time_unit_month": "mois", + "time_unit_months": "mois", + "time_unit_week": "semaine", + "time_unit_weeks": "semaines", "title_is_required": "Le titre est requis.", "unknown_filter_type": "Type de filtre inconnu", "unlock_segments_description": "Organisez les contacts en segments pour cibler des groupes d'utilisateurs spécifiques", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "La valeur doit être un nombre.", "value_must_be_positive": "La valeur doit être un nombre positif.", "view_filters": "Filtres de vue", - "where": "Où" + "where": "Où", + "within_last": "au cours des derniers" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Continuer vers les paramètres", "convert_to_multiple_choice": "Convertir en choix multiples", "convert_to_single_choice": "Convertir en choix unique", + "cooldown_period_across_surveys": "Période de refroidissement (tous sondages confondus)", + "cooldown_period_across_surveys_description": "Pour éviter la lassitude liée aux sondages, choisissez comment ce sondage interagit avec la période de refroidissement globale de l'espace de travail.", "country": "Pays", "create_group": "Créer un groupe", "create_your_own_survey": "Créez votre propre enquête", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Masquer les paramètres de la question", "hostname": "Nom d'hôte", "if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.", - "ignore_global_waiting_time": "Ignorer la période de refroidissement", - "ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.", + "ignore_global_cooldown_period": "Ignorer la période de refroidissement", + "ignore_global_cooldown_period_description": "Ce sondage peut s'afficher dès que ses conditions sont remplies, même si un autre sondage a été montré récemment.", "image": "Image", "includes_all_of": "Comprend tous les", "includes_one_of": "Comprend un de", @@ -3253,8 +3278,8 @@ "options": "Options*", "options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique : {questionIndexes}. Veuillez d'abord les supprimer de la logique.", "override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.", - "overwrite_global_waiting_time": "Définir une période de refroidissement personnalisée", - "overwrite_global_waiting_time_description": "Remplacez la configuration de l'espace de travail uniquement pour cette enquête.", + "overwrite_global_cooldown_period": "Définir une période de refroidissement personnalisée", + "overwrite_global_cooldown_period_description": "Remplacez la configuration de l'espace de travail uniquement pour ce sondage.", "overwrite_placement": "Surcharge de placement", "overwrite_survey_logo": "Définir un logo d'enquête personnalisé", "overwrite_the_global_placement_of_the_survey": "Surcharger le placement global de l'enquête", @@ -3329,8 +3354,8 @@ "required": "Requis", "reset_to_theme_styles": "Réinitialiser aux styles de thème", "reset_to_theme_styles_main_text": "Êtes-vous sûr de vouloir réinitialiser le style aux styles du thème ? Cela supprimera tous les styles personnalisés.", - "respect_global_waiting_time": "Utiliser la période de refroidissement", - "respect_global_waiting_time_description": "Ce sondage suit la période de refroidissement définie dans la configuration de l'espace de travail. Il s'affiche uniquement si aucun autre sondage n'est apparu pendant cette période.", + "respect_global_cooldown_period": "Utiliser la période de refroidissement", + "respect_global_cooldown_period_description": "Ce sondage respecte la période de refroidissement définie dans la configuration de l'espace de travail. Il ne s'affiche que si aucun autre sondage n'est apparu pendant cette période.", "response_limit_can_t_be_set_to_0": "La limite de réponse ne peut pas être fixée à 0.", "response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).", "response_limits_redirections_and_more": "Limites de réponse, redirections et plus.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Attendez quelques secondes après le déclencheur avant de montrer l'enquête.", "wait_n_days_before_showing_this_survey_again": "Attendre jour(s) ou plus entre le dernier sondage affiché et l'affichage de ce sondage.", "wait_n_seconds_before_showing_the_survey": "Attendre secondes avant d'afficher le sondage.", - "waiting_time_across_surveys": "Période de refroidissement (entre les sondages)", - "waiting_time_across_surveys_description": "Pour éviter la fatigue liée aux sondages, choisissez comment ce sondage interagit avec la période de refroidissement globale de l'espace de travail.", "welcome_message": "Message de bienvenue", "when": "Quand", "wide": "Large", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Afficher toujours l'enquête", "display_criteria.time_based_day": "Jour", "display_criteria.time_based_days": "Jours", - "display_criteria.time_based_description": "Temps d'attente global", + "display_criteria.time_based_description": "Période de refroidissement globale", "display_criteria.trigger_description": "Déclencheur d'enquête", "documentation_title": "Distribuer des sondages d'interception sur toutes les plateformes", "html_embed": "Code HTML intégré dans ", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 37155e9a7374..8ae6f3860491 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks logó", "general": { "cannot_delete_only_workspace": "Ez az egyetlen munkaterülete, nem lehet törölni. Először hozzon létre egy új munkaterületet.", + "cooldown_period_updated_successfully": "A várakozási időszak sikeresen frissítve", "custom_scripts": "Egyéni parancsfájlok", "custom_scripts_card_description": "Követő parancsfájlok és képpontok hozzáadása a munkaterületen lévő összes hivatkozás-kérdőívhez.", "custom_scripts_description": "A parancsfájlok az összes hivatkozáskérdőív-oldal részébe beszúrásra kerülnek.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.", "error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor", "only_owners_or_managers_can_delete_workspaces": "Csak tulajdonosok vagy kezelők törölhetnek munkaterületeket", - "recontact_waiting_time": "Várakozási időszak (kérdőívek között)", - "recontact_waiting_time_settings_description": "Annak vezérlése, hogy a felhasználók milyen gyakran kérdezhetők meg a munkaterületen lévő összes weboldal- és alkalmazás-kérdőívben.", + "recontact_cooldown_period": "Lehűlési Időszak (felmérések között)", + "recontact_cooldown_period_settings_description": "Szabályozza, hogy milyen gyakran jelenhetnek meg felmérések a felhasználók számára az összes Weboldal & Alkalmazás Felmérés esetében ebben a munkaterületen.", "this_action_cannot_be_undone": "Ezt a műveletet nem lehet visszavonni.", "wait_x_days_before_showing_next_survey": "Várakozás X napot a következő kérdőív megjelenése előtt:", - "waiting_period_updated_successfully": "A várakozási időszak sikeresen frissítve", "whats_your_workspace_called": "Hogy hívják a munkaterületét?", "workspace_deleted_successfully": "A munkaterület sikeresen törölve", "workspace_name_settings_description": "A munkaterület nevének megváltoztatása.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Szűrő hozzáadása lent", "add_your_first_filter_to_get_started": "Adja hozzá az első szűrőt a kezdéshez", + "any_survey": "bármely felmérés", "cannot_delete_segment_used_in_surveys": "Nem tudja törölni ezt a szegmenst, mert még mindig használatban van ezekben a kérdőívekben:", "clone_and_edit_segment": "Szegmens klónozása és szerkesztése", "create_group": "Csoport létrehozása", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Még nincsenek attribútumok!", "no_filters_yet": "Még nincsenek szűrők!", "no_segments_yet": "Jelenleg nincsenek mentett szegmensei.", + "number": "szám", "operator_contains": "tartalmazza", "operator_does_not_contain": "nem tartalmazza", "operator_ends_with": "ezzel végződik", + "operator_have_completed": "kitöltötték", + "operator_have_not_completed": "nem töltötték ki", + "operator_have_not_seen": "nem látták", + "operator_have_seen": "látták", + "operator_have_started_responding_to": "megkezdték a válaszadást", "operator_is_after": "ez után", "operator_is_before": "ez előtt", "operator_is_between": "ezek között", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Egyenlő", "operator_title_greater_equal": "Nagyobb mint vagy egyenlő", "operator_title_greater_than": "Nagyobb mint", + "operator_title_have_completed": "Kitöltötték", + "operator_title_have_not_completed": "Nem töltötték ki", + "operator_title_have_not_seen": "Nem látták", + "operator_title_have_seen": "Látták", + "operator_title_have_started_responding_to": "Megkezdték a válaszadást", "operator_title_is_after": "Ez után", "operator_title_is_before": "Ez előtt", "operator_title_is_between": "Ezek között", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Felhasználó nem ebben", "operator_user_is_in": "Felhasználó ebben", "operator_user_is_not_in": "Felhasználó nem ebben", + "period": "időszak", "person_and_attributes": "Személy és attribútumok", "phone": "Telefon", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Távolítsa el a szegmenst ezekből a kérdőívekből, hogy törölhesse azt.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "A szegmens sikeresen frissítve", "segment_used_in_other_surveys_make_changes_here": "Ez a szegmens más kérdőívekben is használva van. Itt végezze el a változtatásokat.", "segments_help_you_target_users_with_same_characteristics_easily": "A szegmensek segítik a hasonló jellemzőkkel rendelkező felhasználók könnyű megcélzását", + "select_at_least_one_survey": "Válasszon ki legalább egy felmérést", + "specific_surveys": "meghatározott felmérések", + "survey_interaction": "Felmérés interakció", "target_audience": "Célközönség", "this_action_resets_all_filters_in_this_survey": "Ez a művelet visszaállítja az összes szűrőt ebben a kérdőívben.", + "time_unit_day": "nap", + "time_unit_days": "nap", + "time_unit_month": "hónap", + "time_unit_months": "hónap", + "time_unit_week": "hét", + "time_unit_weeks": "hét", "title_is_required": "A cím megadása kötelező.", "unknown_filter_type": "Ismeretlen szűrőtípus", "unlock_segments_description": "Partnerek szegmensekbe szervezése meghatározott felhasználói csoportok megcélzásához", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Az értéknek számnak kell lennie.", "value_must_be_positive": "Az értéknek pozitív számnak kell lennie.", "view_filters": "Szűrők megtekintése", - "where": "Ahol" + "where": "Ahol", + "within_last": "az utóbbi" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Folytatás a beállításokhoz", "convert_to_multiple_choice": "Átalakítás többválasztósra", "convert_to_single_choice": "Átalakítás egyválasztósra", + "cooldown_period_across_surveys": "Lehűlési Időszak (felmérések között)", + "cooldown_period_across_surveys_description": "A felmérési fáradtság megelőzése érdekében válassza ki, hogy ez a felmérés hogyan viszonyuljon a munkaterület szintű Lehűlési Időszakhoz.", "country": "Ország", "create_group": "Csoport létrehozása", "create_your_own_survey": "Saját kérdőív létrehozása", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Kérdésbeállítások elrejtése", "hostname": "Gépnév", "if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ vagy egy részleges választ el nem küldenek.", - "ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása", - "ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.", + "ignore_global_cooldown_period": "Lehűlési Időszak figyelmen kívül hagyása", + "ignore_global_cooldown_period_description": "Ez a felmérés bármikor megjelenhet, ha a feltételei teljesülnek, még akkor is, ha nemrégiben már megjelent egy másik felmérés.", "image": "Kép", "includes_all_of": "Tartalmazza ezekből az összeset", "includes_one_of": "Tartalmazza ezek egyikét", @@ -3253,8 +3278,8 @@ "options": "Beállítások*", "options_used_in_logic_bulk_error": "A következő lehetőségek használatban vannak a logikában: {questionIndexes}. Először távolítsa el azokat a logikából.", "override_theme_with_individual_styles_for_this_survey": "A téma felülírása egyéni stílusokkal ennél a kérdőívnél.", - "overwrite_global_waiting_time": "Egyéni várakozási időszak beállítása", - "overwrite_global_waiting_time_description": "A munkaterület beállításainak felülbírálása csak ennél a kérdőívnél.", + "overwrite_global_cooldown_period": "Egyéni Lehűlési Időszak beállítása", + "overwrite_global_cooldown_period_description": "A munkaterület konfigurációjának felülbírálása kizárólag ennél a felmérés esetében.", "overwrite_placement": "Elhelyezés felülírása", "overwrite_survey_logo": "Egyéni kérdőívlogó beállítása", "overwrite_the_global_placement_of_the_survey": "A kérdőív globális elhelyezésének felülírása", @@ -3329,8 +3354,8 @@ "required": "Kötelező", "reset_to_theme_styles": "Visszaállítás a téma stílusaira", "reset_to_theme_styles_main_text": "Biztosan vissza szeretné állítani a stílust a téma stílusaira? Ez eltávolítja az összes egyéni stílust.", - "respect_global_waiting_time": "Várakozási időszak használata", - "respect_global_waiting_time_description": "Ez a kérdőív a munkaterület beállításában megadott várakozási időszakot követi. Csak akkor jelenik meg, ha nem jelent meg más kérdőív abban az időszakban.", + "respect_global_cooldown_period": "Lehűlési Időszak alkalmazása", + "respect_global_cooldown_period_description": "Ez a felmérés követi a munkaterület konfigurációjában beállított Lehűlési Időszakot. Csak akkor jelenik meg, ha az adott időszakban nem jelent meg másik felmérés.", "response_limit_can_t_be_set_to_0": "A válaszkorlátot nem lehet 0 értékre állítani", "response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).", "response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Várakozás néhány másodpercig az aktiválás után, mielőtt megjelenítené a kérdőívet", "wait_n_days_before_showing_this_survey_again": "Várakozás vagy több napig az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.", "wait_n_seconds_before_showing_the_survey": "Várakozás másodpercig a kérdőív megjelenítése előtt.", - "waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)", - "waiting_time_across_surveys_description": "A kérdőívekbe való belefáradás megakadályozásához válassza ki, hogy ez a kérdőív hogyan lép kölcsönhatásba a munkaterület-szintű várakozási időszakkal.", "welcome_message": "Üdvözlő üzenet", "when": "Amikor", "wide": "Széles", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Mindig jelenjen meg a kérdőív", "display_criteria.time_based_day": "Nap", "display_criteria.time_based_days": "Napok", - "display_criteria.time_based_description": "Globális várakozási idő", + "display_criteria.time_based_description": "Globális lehűlési időszak", "display_criteria.trigger_description": "Kérdőív aktiválója", "documentation_title": "Elfogási kérdőívek terjesztése az összes platformon", "html_embed": "HTML-beágyazás a szakaszban", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index bc76d33bbc99..54225a242a02 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricksのロゴ", "general": { "cannot_delete_only_workspace": "これは唯一のワークスペースのため、削除できません。まず新しいワークスペースを作成してください。", + "cooldown_period_updated_successfully": "クールダウン期間が正常に更新されました", "custom_scripts": "カスタムスクリプト", "custom_scripts_card_description": "このワークスペース内のすべてのリンクアンケートにトラッキングスクリプトとピクセルを追加します。", "custom_scripts_description": "すべてのリンクアンケートページのにスクリプトが挿入されます。", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "すべてのフォーム、回答、人物、アクション、属性を含むワークスペースを削除します。この操作は元に戻せません。", "error_saving_workspace_information": "ワークスペース情報の保存中にエラーが発生しました", "only_owners_or_managers_can_delete_workspaces": "ワークスペースを削除できるのはオーナーまたはマネージャーのみです", - "recontact_waiting_time": "クールダウン期間(アンケート全体)", - "recontact_waiting_time_settings_description": "このワークスペース内のすべてのウェブサイト&アプリアンケートにおいて、ユーザーにアンケートを表示する頻度を制御します。", + "recontact_cooldown_period": "クールダウン期間(全サーベイ共通)", + "recontact_cooldown_period_settings_description": "このワークスペース内の全てのウェブサイト・アプリサーベイで、ユーザーにサーベイを表示する頻度を設定します。", "this_action_cannot_be_undone": "このアクションは元に戻せません。", "wait_x_days_before_showing_next_survey": "次のフォームを表示するまでの待機日数:", - "waiting_period_updated_successfully": "待機期間が正常に更新されました", "whats_your_workspace_called": "ワークスペースの名前は何ですか?", "workspace_deleted_successfully": "ワークスペースが正常に削除されました", "workspace_name_settings_description": "ワークスペースの名前を変更します。", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "下にフィルターを追加", "add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください", + "any_survey": "すべてのアンケート", "cannot_delete_segment_used_in_surveys": "このセグメントは以下のフォームで使用されているため削除できません:", "clone_and_edit_segment": "セグメントを複製して編集", "create_group": "グループを作成", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "属性がまだありません!", "no_filters_yet": "フィルターはまだありません!", "no_segments_yet": "保存されたセグメントはまだありません。", + "number": "数値", "operator_contains": "を含む", "operator_does_not_contain": "を含まない", "operator_ends_with": "で終わる", + "operator_have_completed": "完了した", + "operator_have_not_completed": "完了していない", + "operator_have_not_seen": "閲覧していない", + "operator_have_seen": "閲覧した", + "operator_have_started_responding_to": "回答を開始した", "operator_is_after": "より後", "operator_is_before": "より前", "operator_is_between": "の間である", @@ -2341,6 +2348,11 @@ "operator_title_equals": "と等しい", "operator_title_greater_equal": "以上", "operator_title_greater_than": "より大きい", + "operator_title_have_completed": "完了した", + "operator_title_have_not_completed": "完了していない", + "operator_title_have_not_seen": "閲覧していない", + "operator_title_have_seen": "閲覧した", + "operator_title_have_started_responding_to": "回答を開始した", "operator_title_is_after": "より後", "operator_title_is_before": "より前", "operator_title_is_between": "の間である", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "ユーザーが含まれない", "operator_user_is_in": "ユーザーが含まれる", "operator_user_is_not_in": "ユーザーが含まれない", + "period": "期間", "person_and_attributes": "人物と属性", "phone": "電話", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "このセグメントを削除するには、まず以下のフォームから外してください。", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "セグメントを更新しました!", "segment_used_in_other_surveys_make_changes_here": "このセグメントは他のアンケートで使用されています。変更はこちらから行ってください。", "segments_help_you_target_users_with_same_characteristics_easily": "セグメントを使うと、同じ特性を持つユーザーを簡単にターゲティングできます", + "select_at_least_one_survey": "少なくとも1つのアンケートを選択してください", + "specific_surveys": "特定のアンケート", + "survey_interaction": "アンケートのインタラクション", "target_audience": "ターゲットオーディエンス", "this_action_resets_all_filters_in_this_survey": "この操作はこのフォームのすべてのフィルターをリセットします。", + "time_unit_day": "日", + "time_unit_days": "日", + "time_unit_month": "か月", + "time_unit_months": "か月", + "time_unit_week": "週間", + "time_unit_weeks": "週間", "title_is_required": "タイトルは必須です。", "unknown_filter_type": "不明なフィルタータイプ", "unlock_segments_description": "連絡先をセグメントに整理し、特定のユーザーグループをターゲティングします", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "値は数値である必要があります。", "value_must_be_positive": "値は正の数である必要があります。", "view_filters": "フィルターを表示", - "where": "条件" + "where": "条件", + "within_last": "過去" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "設定に進む", "convert_to_multiple_choice": "複数選択に変換", "convert_to_single_choice": "単一選択に変換", + "cooldown_period_across_surveys": "クールダウン期間(全サーベイ共通)", + "cooldown_period_across_surveys_description": "サーベイ疲れを防ぐため、このサーベイがワークスペース全体のクールダウン期間とどのように連携するかを選択してください。", "country": "国", "create_group": "グループを作成", "create_your_own_survey": "独自のフォームを作成", @@ -3173,8 +3198,8 @@ "hide_question_settings": "質問設定を非表示", "hostname": "ホスト名", "if_you_really_want_that_answer_ask_until_you_get_it": "回答または部分的な回答が送信されるまで、トリガーされるたびに表示し続けます。", - "ignore_global_waiting_time": "クールダウン期間を無視", - "ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。", + "ignore_global_cooldown_period": "クールダウン期間を無視", + "ignore_global_cooldown_period_description": "このサーベイは、他のサーベイが最近表示された場合でも、条件を満たせばいつでも表示されます。", "image": "画像", "includes_all_of": "のすべてを含む", "includes_one_of": "のいずれかを含む", @@ -3253,8 +3278,8 @@ "options": "オプション*", "options_used_in_logic_bulk_error": "以下のオプションはロジックで使用されています:{questionIndexes}。まず、ロジックから削除してください。", "override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。", - "overwrite_global_waiting_time": "カスタムクールダウン期間を設定", - "overwrite_global_waiting_time_description": "このサーベイのみワークスペース設定を上書きします。", + "overwrite_global_cooldown_period": "カスタムクールダウン期間を設定", + "overwrite_global_cooldown_period_description": "このサーベイのみ、ワークスペースの設定を上書きします。", "overwrite_placement": "配置を上書き", "overwrite_survey_logo": "カスタムアンケートロゴを設定する", "overwrite_the_global_placement_of_the_survey": "フォームのグローバルな配置を上書き", @@ -3329,8 +3354,8 @@ "required": "必須", "reset_to_theme_styles": "テーマのスタイルにリセット", "reset_to_theme_styles_main_text": "スタイルをテーマのスタイルにリセットしてもよろしいですか?これにより、すべてのカスタムスタイルが削除されます。", - "respect_global_waiting_time": "クールダウン期間を使用", - "respect_global_waiting_time_description": "このアンケートは、ワークスペースの設定で設定されたクールダウン期間に従います。その期間中に他のアンケートが表示されていない場合にのみ表示されます。", + "respect_global_cooldown_period": "クールダウン期間を使用", + "respect_global_cooldown_period_description": "このサーベイは、ワークスペースの設定で指定されたクールダウン期間に従います。その期間中に他のサーベイが表示されていない場合のみ表示されます。", "response_limit_can_t_be_set_to_0": "回答数の上限を0に設定することはできません", "response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。", "response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "トリガーから数秒待ってからフォームを表示します", "wait_n_days_before_showing_this_survey_again": "前回のアンケート表示からこのアンケートを表示するまで日以上待ちます。", "wait_n_seconds_before_showing_the_survey": "アンケートを表示する前に秒待ちます。", - "waiting_time_across_surveys": "クールダウン期間(アンケート全体)", - "waiting_time_across_surveys_description": "アンケート疲れを防ぐため、このアンケートがワークスペース全体のクールダウン期間とどのように連動するかを選択してください。", "welcome_message": "ウェルカムメッセージ", "when": "条件", "wide": "広い", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "常にフォームを表示", "display_criteria.time_based_day": "日", "display_criteria.time_based_days": "日", - "display_criteria.time_based_description": "グローバル待機時間", + "display_criteria.time_based_description": "グローバルクールダウン期間", "display_criteria.trigger_description": "フォームのトリガー", "documentation_title": "すべてのプラットフォームでインターセプトフォームを配信", "html_embed": "HTML埋め込み(内)", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index f3028dc6935a..032c734eb3b1 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks-logo", "general": { "cannot_delete_only_workspace": "Dit is je enige werkruimte en kan niet worden verwijderd. Maak eerst een nieuwe werkruimte aan.", + "cooldown_period_updated_successfully": "Afkoelperiode succesvol bijgewerkt", "custom_scripts": "Aangepaste scripts", "custom_scripts_card_description": "Voeg trackingscripts en pixels toe aan alle linkenquêtes in deze werkruimte.", "custom_scripts_description": "Scripts worden geïnjecteerd in de van alle linkenquêtepagina's.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Verwijder werkruimte met alle enquêtes, reacties, personen, acties en attributen. Dit kan niet ongedaan worden gemaakt.", "error_saving_workspace_information": "Fout bij het opslaan van werkruimte-informatie", "only_owners_or_managers_can_delete_workspaces": "Alleen eigenaren of managers kunnen werkruimtes verwijderen", - "recontact_waiting_time": "Afkoelperiode (voor alle enquêtes)", - "recontact_waiting_time_settings_description": "Bepaal hoe vaak gebruikers kunnen worden bevraagd voor alle website- en app-enquêtes in deze workspace.", + "recontact_cooldown_period": "Cooldown-periode (voor alle enquêtes)", + "recontact_cooldown_period_settings_description": "Bepaal hoe vaak gebruikers een enquête te zien krijgen voor alle Website- en App-enquêtes in deze workspace.", "this_action_cannot_be_undone": "Deze actie kan niet ongedaan worden gemaakt.", "wait_x_days_before_showing_next_survey": "Wacht X dagen voordat de volgende enquête wordt getoond:", - "waiting_period_updated_successfully": "Wachtperiode succesvol bijgewerkt", "whats_your_workspace_called": "Hoe heet je werkruimte?", "workspace_deleted_successfully": "Werkruimte succesvol verwijderd", "workspace_name_settings_description": "Wijzig de naam van je werkruimte.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Voeg hieronder een filter toe", "add_your_first_filter_to_get_started": "Voeg uw eerste filter toe om aan de slag te gaan", + "any_survey": "elke enquête", "cannot_delete_segment_used_in_surveys": "U kunt dit segment niet verwijderen omdat het nog steeds in deze enquêtes wordt gebruikt:", "clone_and_edit_segment": "Segment klonen en bewerken", "create_group": "Groep aanmaken", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Nog geen attributen!", "no_filters_yet": "Er zijn nog geen filters!", "no_segments_yet": "Je hebt momenteel geen opgeslagen segmenten.", + "number": "aantal", "operator_contains": "bevat", "operator_does_not_contain": "bevat niet", "operator_ends_with": "eindigt met", + "operator_have_completed": "hebben voltooid", + "operator_have_not_completed": "hebben niet voltooid", + "operator_have_not_seen": "hebben niet gezien", + "operator_have_seen": "hebben gezien", + "operator_have_started_responding_to": "zijn begonnen met reageren op", "operator_is_after": "is na", "operator_is_before": "is eerder", "operator_is_between": "is tussen", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Gelijk aan", "operator_title_greater_equal": "Groter dan of gelijk aan", "operator_title_greater_than": "Groter dan", + "operator_title_have_completed": "Hebben voltooid", + "operator_title_have_not_completed": "Hebben niet voltooid", + "operator_title_have_not_seen": "Hebben niet gezien", + "operator_title_have_seen": "Hebben gezien", + "operator_title_have_started_responding_to": "Zijn begonnen met reageren op", "operator_title_is_after": "Is na", "operator_title_is_before": "Is eerder", "operator_title_is_between": "Is tussen", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Gebruiker zit niet in", "operator_user_is_in": "Gebruiker zit in", "operator_user_is_not_in": "Gebruiker zit niet in", + "period": "periode", "person_and_attributes": "Persoon & attributen", "phone": "Telefoon", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Verwijder het segment uit deze enquêtes om het te kunnen verwijderen.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segment succesvol bijgewerkt!", "segment_used_in_other_surveys_make_changes_here": "Dit segment wordt gebruikt in andere enquêtes. Breng wijzigingen aan hier.", "segments_help_you_target_users_with_same_characteristics_easily": "Met segmenten kunt u eenvoudig gebruikers met dezelfde kenmerken targeten", + "select_at_least_one_survey": "Selecteer minimaal één enquête", + "specific_surveys": "specifieke enquêtes", + "survey_interaction": "Enquête-interactie", "target_audience": "Doelgroep", "this_action_resets_all_filters_in_this_survey": "Met deze actie worden alle filters in deze enquête opnieuw ingesteld.", + "time_unit_day": "dag", + "time_unit_days": "dagen", + "time_unit_month": "maand", + "time_unit_months": "maanden", + "time_unit_week": "week", + "time_unit_weeks": "weken", "title_is_required": "Titel is vereist.", "unknown_filter_type": "Onbekend filtertype", "unlock_segments_description": "Organiseer contacten in segmenten om specifieke gebruikersgroepen te targeten", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Waarde moet een getal zijn.", "value_must_be_positive": "Waarde moet een positief getal zijn.", "view_filters": "Bekijk filters", - "where": "Waar" + "where": "Waar", + "within_last": "in de afgelopen" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Ga verder naar Instellingen", "convert_to_multiple_choice": "Converteren naar Multi-select", "convert_to_single_choice": "Converteren naar Enkele selectie", + "cooldown_period_across_surveys": "Cooldown-periode (voor alle enquêtes)", + "cooldown_period_across_surveys_description": "Om enquêtemoeheid te voorkomen, kies je hoe deze enquête omgaat met de workspace-brede cooldown-periode.", "country": "Land", "create_group": "Groep aanmaken", "create_your_own_survey": "Creëer uw eigen enquête", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Vraaginstellingen verbergen", "hostname": "Hostnaam", "if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer getriggerd totdat een reactie of gedeeltelijke reactie is ingediend.", - "ignore_global_waiting_time": "Afkoelperiode negeren", - "ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.", + "ignore_global_cooldown_period": "Cooldown-periode negeren", + "ignore_global_cooldown_period_description": "Deze enquête kan worden getoond zodra aan de voorwaarden wordt voldaan, ook als er onlangs een andere enquête is getoond.", "image": "Afbeelding", "includes_all_of": "Inclusief alles", "includes_one_of": "Inclusief een van", @@ -3253,8 +3278,8 @@ "options": "Opties*", "options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.", "override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.", - "overwrite_global_waiting_time": "Aangepaste afkoelperiode instellen", - "overwrite_global_waiting_time_description": "Overschrijf de werkruimte-instellingen alleen voor deze enquête.", + "overwrite_global_cooldown_period": "Aangepaste cooldown-periode instellen", + "overwrite_global_cooldown_period_description": "Overschrijf de workspace-configuratie alleen voor deze enquête.", "overwrite_placement": "Plaatsing overschrijven", "overwrite_survey_logo": "Stel aangepast enquêtelogo in", "overwrite_the_global_placement_of_the_survey": "Overschrijf de globale plaatsing van de enquête", @@ -3329,8 +3354,8 @@ "required": "Vereist", "reset_to_theme_styles": "Terugzetten naar themastijlen", "reset_to_theme_styles_main_text": "Weet u zeker dat u de stijl wilt resetten naar de themastijlen? Hiermee worden alle aangepaste styling verwijderd.", - "respect_global_waiting_time": "Afkoelperiode gebruiken", - "respect_global_waiting_time_description": "Deze enquête volgt de afkoelperiode die is ingesteld in de configuratie van de workspace. Deze wordt alleen getoond als er geen andere enquête is verschenen tijdens die periode.", + "respect_global_cooldown_period": "Cooldown-periode gebruiken", + "respect_global_cooldown_period_description": "Deze enquête volgt de cooldown-periode die is ingesteld in de workspace-configuratie. Deze wordt alleen getoond als er geen andere enquête tijdens die periode is verschenen.", "response_limit_can_t_be_set_to_0": "De responslimiet kan niet worden ingesteld op 0", "response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).", "response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wacht een paar seconden na de trigger voordat u de enquête weergeeft", "wait_n_days_before_showing_this_survey_again": "Wacht of meer dagen tussen de laatst getoonde enquête en het tonen van deze enquête.", "wait_n_seconds_before_showing_the_survey": "Wacht seconden voordat de enquête wordt getoond.", - "waiting_time_across_surveys": "Afkoelperiode (voor alle enquêtes)", - "waiting_time_across_surveys_description": "Om enquêtemoeheid te voorkomen, kies hoe deze enquête omgaat met de workspace-brede afkoelperiode.", "welcome_message": "Welkomstbericht", "when": "Wanneer", "wide": "Breed", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Toon altijd enquête", "display_criteria.time_based_day": "Dag", "display_criteria.time_based_days": "Dagen", - "display_criteria.time_based_description": "Mondiale wachttijd", + "display_criteria.time_based_description": "Globale cooldown-periode", "display_criteria.trigger_description": "Enquêtetrigger", "documentation_title": "Verspreid onderscheppingsenquêtes op alle platforms", "html_embed": "HTML insluiten in ", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 49ca3a225c21..9b9f738a2f24 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Logo da Formbricks", "general": { "cannot_delete_only_workspace": "Este é o seu único workspace, não pode ser excluído. Crie um novo workspace primeiro.", + "cooldown_period_updated_successfully": "Período de espera atualizado com sucesso", "custom_scripts": "Scripts personalizados", "custom_scripts_card_description": "Adicione scripts de rastreamento e pixels a todas as pesquisas de link neste workspace.", "custom_scripts_description": "Os scripts serão injetados no de todas as páginas de pesquisa de link.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Excluir workspace com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.", "error_saving_workspace_information": "Erro ao salvar informações do workspace", "only_owners_or_managers_can_delete_workspaces": "Apenas proprietários ou gerentes podem excluir workspaces", - "recontact_waiting_time": "Período de espera (entre pesquisas)", - "recontact_waiting_time_settings_description": "Controle com que frequência os usuários podem ser pesquisados em todas as pesquisas de website e app neste workspace.", + "recontact_cooldown_period": "Período de Resfriamento (entre pesquisas)", + "recontact_cooldown_period_settings_description": "Controle a frequência com que os usuários podem ser pesquisados em todas as Pesquisas de Website e App neste workspace.", "this_action_cannot_be_undone": "Essa ação não pode ser desfeita.", "wait_x_days_before_showing_next_survey": "Aguardar X dias antes de mostrar a próxima pesquisa:", - "waiting_period_updated_successfully": "Período de espera atualizado com sucesso", "whats_your_workspace_called": "Como seu workspace se chama?", "workspace_deleted_successfully": "Workspace excluído com sucesso", "workspace_name_settings_description": "Altere o nome do seu workspace.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Adicionar filtro abaixo", "add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar", + "any_survey": "qualquer pesquisa", "cannot_delete_segment_used_in_surveys": "Você não pode deletar esse segmento porque ele ainda é usado nessas pesquisas:", "clone_and_edit_segment": "Clonar e Editar Segmento", "create_group": "Criar grupo", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Ainda não tem atributos!", "no_filters_yet": "Ainda não tem filtros!", "no_segments_yet": "Você não tem segmentos salvos no momento.", + "number": "número", "operator_contains": "contém", "operator_does_not_contain": "não contém", "operator_ends_with": "termina com", + "operator_have_completed": "completaram", + "operator_have_not_completed": "não completaram", + "operator_have_not_seen": "não viram", + "operator_have_seen": "viram", + "operator_have_started_responding_to": "começaram a responder", "operator_is_after": "é depois", "operator_is_before": "é antes", "operator_is_between": "está entre", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Igual", "operator_title_greater_equal": "Maior ou igual a", "operator_title_greater_than": "Maior que", + "operator_title_have_completed": "Completaram", + "operator_title_have_not_completed": "Não completaram", + "operator_title_have_not_seen": "Não viram", + "operator_title_have_seen": "Viram", + "operator_title_have_started_responding_to": "Começaram a responder", "operator_title_is_after": "É depois", "operator_title_is_before": "É antes", "operator_title_is_between": "Está entre", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Usuário não está em", "operator_user_is_in": "Usuário está em", "operator_user_is_not_in": "Usuário não está em", + "period": "período", "person_and_attributes": "Pessoa & Atributos", "phone": "Celular", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento dessas pesquisas para deletá-lo.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segmento atualizado com sucesso!", "segment_used_in_other_surveys_make_changes_here": "Este segmento é usado em outras pesquisas. Faça alterações aqui.", "segments_help_you_target_users_with_same_characteristics_easily": "Segmentos ajudam você a direcionar usuários com as mesmas características facilmente", + "select_at_least_one_survey": "Selecione pelo menos uma pesquisa", + "specific_surveys": "pesquisas específicas", + "survey_interaction": "Interação com pesquisa", "target_audience": "Público-alvo", "this_action_resets_all_filters_in_this_survey": "Essa ação reseta todos os filtros dessa pesquisa.", + "time_unit_day": "dia", + "time_unit_days": "dias", + "time_unit_month": "mês", + "time_unit_months": "meses", + "time_unit_week": "semana", + "time_unit_weeks": "semanas", "title_is_required": "É necessário um título.", "unknown_filter_type": "Tipo de filtro desconhecido", "unlock_segments_description": "Organize contatos em segmentos para direcionar grupos específicos de usuários", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "O valor deve ser um número.", "value_must_be_positive": "O valor deve ser um número positivo.", "view_filters": "Ver filtros", - "where": "Onde" + "where": "Onde", + "within_last": "nos últimos" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Continuar para Configurações", "convert_to_multiple_choice": "Converter para Múltipla Escolha", "convert_to_single_choice": "Converter para Escolha Única", + "cooldown_period_across_surveys": "Período de Resfriamento (entre pesquisas)", + "cooldown_period_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o Período de Resfriamento global do workspace.", "country": "país", "create_group": "Criar grupo", "create_your_own_survey": "Crie sua própria pesquisa", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Ocultar configurações da pergunta", "hostname": "nome do host", "if_you_really_want_that_answer_ask_until_you_get_it": "Continuar exibindo sempre que acionado até que uma resposta ou resposta parcial seja enviada.", - "ignore_global_waiting_time": "Ignorar período de espera", - "ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.", + "ignore_global_cooldown_period": "Ignorar Período de Resfriamento", + "ignore_global_cooldown_period_description": "Esta pesquisa pode aparecer sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido exibida recentemente.", "image": "imagem", "includes_all_of": "Inclui tudo de", "includes_one_of": "Inclui um de", @@ -3253,8 +3278,8 @@ "options": "Opções*", "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.", "override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.", - "overwrite_global_waiting_time": "Definir período de espera personalizado", - "overwrite_global_waiting_time_description": "Substituir a configuração do espaço de trabalho apenas para esta pesquisa.", + "overwrite_global_cooldown_period": "Definir Período de Resfriamento personalizado", + "overwrite_global_cooldown_period_description": "Substituir a configuração do workspace apenas para esta pesquisa.", "overwrite_placement": "Substituir posicionamento", "overwrite_survey_logo": "Definir logo personalizado para a pesquisa", "overwrite_the_global_placement_of_the_survey": "Substituir a posição global da pesquisa", @@ -3329,8 +3354,8 @@ "required": "Obrigatório", "reset_to_theme_styles": "Redefinir para estilos do tema", "reset_to_theme_styles_main_text": "Tem certeza de que quer redefinir o estilo para o tema padrão? Isso vai remover todas as personalizações.", - "respect_global_waiting_time": "Usar período de espera", - "respect_global_waiting_time_description": "Esta pesquisa segue o período de espera definido na configuração do workspace. Ela só é exibida se nenhuma outra pesquisa tiver aparecido durante esse período.", + "respect_global_cooldown_period": "Usar Período de Resfriamento", + "respect_global_cooldown_period_description": "Esta pesquisa segue o Período de Resfriamento definido na configuração do workspace. Ela só aparece se nenhuma outra pesquisa foi exibida durante esse período.", "response_limit_can_t_be_set_to_0": "Limite de resposta não pode ser 0", "response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).", "response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Espera alguns segundos depois do gatilho antes de mostrar a pesquisa", "wait_n_days_before_showing_this_survey_again": "Aguardar ou mais dias entre a última exibição e a próxima exibição desta pesquisa.", "wait_n_seconds_before_showing_the_survey": "Aguardar segundos antes de exibir a pesquisa.", - "waiting_time_across_surveys": "Período de espera (entre pesquisas)", - "waiting_time_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o período de espera geral do workspace.", "welcome_message": "Mensagem de boas-vindas", "when": "Quando", "wide": "Largo", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Mostrar pesquisa sempre", "display_criteria.time_based_day": "Dia", "display_criteria.time_based_days": "Dias", - "display_criteria.time_based_description": "Tempo de espera global", + "display_criteria.time_based_description": "Período de resfriamento global", "display_criteria.trigger_description": "Gatilho de Pesquisa", "documentation_title": "Distribua pesquisas de interceptação em todas as plataformas", "html_embed": "HTML embutido no ", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 849e14d76743..5b7e2318d0f3 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Logotipo do Formbricks", "general": { "cannot_delete_only_workspace": "Este é o teu único espaço de trabalho e não pode ser eliminado. Cria primeiro um novo espaço de trabalho.", + "cooldown_period_updated_successfully": "Período de espera atualizado com sucesso", "custom_scripts": "Scripts personalizados", "custom_scripts_card_description": "Adicionar scripts de rastreamento e pixels a todos os inquéritos de link nesta área de trabalho.", "custom_scripts_description": "Os scripts serão injetados no de todas as páginas de inquéritos de link.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Elimina o espaço de trabalho com todos os inquéritos, respostas, pessoas, ações e atributos. Esta ação não pode ser revertida.", "error_saving_workspace_information": "Erro ao guardar informações do espaço de trabalho", "only_owners_or_managers_can_delete_workspaces": "Apenas proprietários ou gestores podem eliminar espaços de trabalho", - "recontact_waiting_time": "Período de espera (entre inquéritos)", - "recontact_waiting_time_settings_description": "Controle com que frequência os utilizadores podem ser inquiridos em todos os inquéritos de website e aplicação neste espaço de trabalho.", + "recontact_cooldown_period": "Período de Espera (entre inquéritos)", + "recontact_cooldown_period_settings_description": "Controla com que frequência os utilizadores podem ser inquiridos em todos os Inquéritos de Site e App neste espaço de trabalho.", "this_action_cannot_be_undone": "Esta ação não pode ser desfeita.", "wait_x_days_before_showing_next_survey": "Aguardar X dias antes de mostrar o próximo inquérito:", - "waiting_period_updated_successfully": "Período de espera atualizado com sucesso", "whats_your_workspace_called": "Como se chama o teu espaço de trabalho?", "workspace_deleted_successfully": "Espaço de trabalho eliminado com sucesso", "workspace_name_settings_description": "Altera o nome do teu espaço de trabalho.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Adicionar filtro abaixo", "add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar", + "any_survey": "qualquer inquérito", "cannot_delete_segment_used_in_surveys": "Não pode eliminar este segmento, pois ainda é utilizado nestes questionários:", "clone_and_edit_segment": "Clonar e Editar Segmento", "create_group": "Criar grupo", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Ainda não há atributos!", "no_filters_yet": "Ainda não há filtros!", "no_segments_yet": "Atualmente, não tem segmentos guardados.", + "number": "número", "operator_contains": "contém", "operator_does_not_contain": "não contém", "operator_ends_with": "termina com", + "operator_have_completed": "concluíram", + "operator_have_not_completed": "não concluíram", + "operator_have_not_seen": "não viram", + "operator_have_seen": "viram", + "operator_have_started_responding_to": "começaram a responder a", "operator_is_after": "é depois", "operator_is_before": "é antes", "operator_is_between": "está entre", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Igual", "operator_title_greater_equal": "Maior ou igual a", "operator_title_greater_than": "Maior que", + "operator_title_have_completed": "Concluíram", + "operator_title_have_not_completed": "Não concluíram", + "operator_title_have_not_seen": "Não viram", + "operator_title_have_seen": "Viram", + "operator_title_have_started_responding_to": "Começaram a responder a", "operator_title_is_after": "É depois", "operator_title_is_before": "É antes", "operator_title_is_between": "Está entre", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "O utilizador não está em", "operator_user_is_in": "O utilizador está em", "operator_user_is_not_in": "O utilizador não está em", + "period": "período", "person_and_attributes": "Pessoa e Atributos", "phone": "Telefone", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento destes questionários para o eliminar.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segmento atualizado com sucesso!", "segment_used_in_other_surveys_make_changes_here": "Este segmento é utilizado noutros inquéritos. Faz alterações aqui.", "segments_help_you_target_users_with_same_characteristics_easily": "Os segmentos ajudam-no a direcionar utilizadores com as mesmas características facilmente", + "select_at_least_one_survey": "Seleciona pelo menos um inquérito", + "specific_surveys": "inquéritos específicos", + "survey_interaction": "Interação com inquérito", "target_audience": "Público-alvo", "this_action_resets_all_filters_in_this_survey": "Esta ação redefine todos os filtros nesta pesquisa.", + "time_unit_day": "dia", + "time_unit_days": "dias", + "time_unit_month": "mês", + "time_unit_months": "meses", + "time_unit_week": "semana", + "time_unit_weeks": "semanas", "title_is_required": "Título é obrigatório.", "unknown_filter_type": "Tipo de filtro desconhecido", "unlock_segments_description": "Organize contactos em segmentos para direcionar grupos de utilizadores específicos", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "O valor deve ser um número.", "value_must_be_positive": "O valor deve ser um número positivo.", "view_filters": "Ver filtros", - "where": "Onde" + "where": "Onde", + "within_last": "nos últimos" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Continuar para Definições", "convert_to_multiple_choice": "Converter para Seleção Múltipla", "convert_to_single_choice": "Converter para Seleção Única", + "cooldown_period_across_surveys": "Período de Espera (entre inquéritos)", + "cooldown_period_across_surveys_description": "Para evitar fadiga de inquéritos, escolhe como este inquérito interage com o Período de Espera geral do espaço de trabalho.", "country": "País", "create_group": "Criar grupo", "create_your_own_survey": "Crie o seu próprio inquérito", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Ocultar definições da pergunta", "hostname": "Nome do host", "if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que for acionado até que uma resposta ou resposta parcial seja submetida.", - "ignore_global_waiting_time": "Ignorar período de espera", - "ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.", + "ignore_global_cooldown_period": "Ignorar Período de Espera", + "ignore_global_cooldown_period_description": "Este inquérito pode ser apresentado sempre que as suas condições sejam cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.", "image": "Imagem", "includes_all_of": "Inclui todos de", "includes_one_of": "Inclui um de", @@ -3253,8 +3278,8 @@ "options": "Opções*", "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.", "override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.", - "overwrite_global_waiting_time": "Definir período de espera personalizado", - "overwrite_global_waiting_time_description": "Substituir a configuração do espaço de trabalho apenas para este inquérito.", + "overwrite_global_cooldown_period": "Definir Período de Espera personalizado", + "overwrite_global_cooldown_period_description": "Substitui a configuração do espaço de trabalho apenas para este inquérito.", "overwrite_placement": "Substituir colocação", "overwrite_survey_logo": "Definir logótipo de inquérito personalizado", "overwrite_the_global_placement_of_the_survey": "Substituir a colocação global do inquérito", @@ -3329,8 +3354,8 @@ "required": "Obrigatório", "reset_to_theme_styles": "Repor para estilos do tema", "reset_to_theme_styles_main_text": "Tem a certeza de que deseja repor o estilo para os estilos do tema? Isto irá remover todos os estilos personalizados.", - "respect_global_waiting_time": "Usar período de espera", - "respect_global_waiting_time_description": "Este inquérito segue o período de espera definido na configuração do espaço de trabalho. Apenas é apresentado se nenhum outro inquérito tiver aparecido durante esse período.", + "respect_global_cooldown_period": "Usar Período de Espera", + "respect_global_cooldown_period_description": "Este inquérito segue o Período de Espera definido na configuração do espaço de trabalho. Só é apresentado se nenhum outro inquérito tiver aparecido durante esse período.", "response_limit_can_t_be_set_to_0": "O limite de respostas não pode ser definido como 0", "response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).", "response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Aguarde alguns segundos após o gatilho antes de mostrar o inquérito", "wait_n_days_before_showing_this_survey_again": "Aguardar ou mais dias entre o último inquérito mostrado e a apresentação deste inquérito.", "wait_n_seconds_before_showing_the_survey": "Aguardar segundos antes de mostrar o inquérito.", - "waiting_time_across_surveys": "Período de espera (entre inquéritos)", - "waiting_time_across_surveys_description": "Para prevenir fadiga de inquéritos, escolha como este inquérito interage com o período de espera geral do espaço de trabalho.", "welcome_message": "Mensagem de boas-vindas", "when": "Quando", "wide": "Largo", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Mostrar sempre o inquérito", "display_criteria.time_based_day": "Dia", "display_criteria.time_based_days": "Dias", - "display_criteria.time_based_description": "Tempo de espera global", + "display_criteria.time_based_description": "Período de espera geral", "display_criteria.trigger_description": "Desencadeador de Inquérito", "documentation_title": "Distribuir inquéritos de interceção em todas as plataformas", "html_embed": "HTML embutido em ", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index f93f8e79bf38..c9d608a332bd 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Logo Formbricks", "general": { "cannot_delete_only_workspace": "Acesta este singurul tău spațiu de lucru și nu poate fi șters. Creează mai întâi un spațiu de lucru nou.", + "cooldown_period_updated_successfully": "Perioada de cooldown a fost actualizată cu succes", "custom_scripts": "Scripturi personalizate", "custom_scripts_card_description": "Adaugă scripturi de tracking și pixeli tuturor sondajelor cu link din acest spațiu de lucru.", "custom_scripts_description": "Scripturile vor fi injectate în pe toate paginile sondajelor cu link.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Șterge spațiul de lucru împreună cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Această acțiune nu poate fi anulată.", "error_saving_workspace_information": "Eroare la salvarea informațiilor spațiului de lucru", "only_owners_or_managers_can_delete_workspaces": "Doar proprietarii sau managerii pot șterge spațiile de lucru", - "recontact_waiting_time": "Perioadă de răcire (între sondaje)", - "recontact_waiting_time_settings_description": "Controlează cât de des pot fi chestionați utilizatorii în toate sondajele Website & App din acest workspace.", + "recontact_cooldown_period": "Perioadă de așteptare (între sondaje)", + "recontact_cooldown_period_settings_description": "Controlează cât de des pot fi chestionați utilizatorii prin toate Sondajele Web & App din acest spațiu de lucru.", "this_action_cannot_be_undone": "Această acțiune nu poate fi anulată.", "wait_x_days_before_showing_next_survey": "Așteaptă X zile înainte de a afișa următorul sondaj:", - "waiting_period_updated_successfully": "Perioada de așteptare a fost actualizată cu succes", "whats_your_workspace_called": "Cum se numește spațiul tău de lucru?", "workspace_deleted_successfully": "Spațiul de lucru a fost șters cu succes", "workspace_name_settings_description": "Schimbă numele spațiului tău de lucru.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Adăugați un filtru mai jos", "add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe", + "any_survey": "orice chestionar", "cannot_delete_segment_used_in_surveys": "Nu puteți șterge acest segment deoarece încă este folosit în aceste sondaje:", "clone_and_edit_segment": "Clonează & Editează Segment", "create_group": "Creează grup", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Niciun atribut încă!", "no_filters_yet": "Nu există filtre încă!", "no_segments_yet": "În prezent nu aveți segmente salvate.", + "number": "număr", "operator_contains": "conține", "operator_does_not_contain": "nu conține", "operator_ends_with": "se termină cu", + "operator_have_completed": "au completat", + "operator_have_not_completed": "nu au completat", + "operator_have_not_seen": "nu au văzut", + "operator_have_seen": "au văzut", + "operator_have_started_responding_to": "au început să răspundă la", "operator_is_after": "este după", "operator_is_before": "este înainte", "operator_is_between": "este între", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Egal", "operator_title_greater_equal": "Mai mare sau egal cu", "operator_title_greater_than": "Mai mare decât", + "operator_title_have_completed": "Au completat", + "operator_title_have_not_completed": "Nu au completat", + "operator_title_have_not_seen": "Nu au văzut", + "operator_title_have_seen": "Au văzut", + "operator_title_have_started_responding_to": "Au început să răspundă la", "operator_title_is_after": "Este după", "operator_title_is_before": "Este înainte", "operator_title_is_between": "Este între", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Utilizatorul nu este în", "operator_user_is_in": "Utilizatorul este în", "operator_user_is_not_in": "Utilizatorul nu este în", + "period": "perioadă", "person_and_attributes": "Persoană & Atribute", "phone": "Telefon", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Vă rugăm să eliminați segmentul din aceste chestionare pentru a-l șterge.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segment actualizat cu succes!", "segment_used_in_other_surveys_make_changes_here": "Acest segment este utilizat în alte sondaje. Fă modificările aici.", "segments_help_you_target_users_with_same_characteristics_easily": "Segmentele vă ajută să vizați utilizatorii cu aceleași caracteristici ușor", + "select_at_least_one_survey": "Selectează cel puțin un chestionar", + "specific_surveys": "chestionare specifice", + "survey_interaction": "Interacțiune cu chestionarul", "target_audience": "Audiență Țintă", "this_action_resets_all_filters_in_this_survey": "Acțiunea aceasta resetează toate filtrele în acest sondaj.", + "time_unit_day": "zi", + "time_unit_days": "zile", + "time_unit_month": "lună", + "time_unit_months": "luni", + "time_unit_week": "săptămână", + "time_unit_weeks": "săptămâni", "title_is_required": "Titlul este obligatoriu.", "unknown_filter_type": "Tip de filtru necunoscut", "unlock_segments_description": "Organizează contactele în segmente pentru a viza grupuri de utilizatori specifici", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Valoarea trebuie să fie un număr.", "value_must_be_positive": "Valoarea trebuie să fie un număr pozitiv.", "view_filters": "Vizualizați filtrele", - "where": "Unde" + "where": "Unde", + "within_last": "în ultimele" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Continuă către Setări", "convert_to_multiple_choice": "Convertiți la selectare multiplă", "convert_to_single_choice": "Convertiți la selectare unică", + "cooldown_period_across_surveys": "Perioadă de așteptare (între sondaje)", + "cooldown_period_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interactionează acest sondaj cu Perioada de așteptare globală din spațiul de lucru.", "country": "Țară", "create_group": "Creează grup", "create_your_own_survey": "Creează-ți propriul chestionar", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Ascunde setările întrebării", "hostname": "Nume gazdă", "if_you_really_want_that_answer_ask_until_you_get_it": "Continuă să afișezi ori de câte ori este declanșat până când este trimis un răspuns sau un răspuns parțial.", - "ignore_global_waiting_time": "Ignoră perioada de răcire", - "ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.", + "ignore_global_cooldown_period": "Ignoră Perioada de așteptare", + "ignore_global_cooldown_period_description": "Acest sondaj poate fi afișat oricând condițiile sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.", "image": "Imagine", "includes_all_of": "Include toate\",\"contextDescription\":\"Part of a survey completion screen referencing conditions met when all items are included\"}", "includes_one_of": "Include una dintre", @@ -3253,8 +3278,8 @@ "options": "Opțiuni*", "options_used_in_logic_bulk_error": "Următoarele opțiuni sunt folosite în logică: {questionIndexes}. Vă rugăm să le eliminați din logică mai întâi.", "override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.", - "overwrite_global_waiting_time": "Setează perioadă de răcire personalizată", - "overwrite_global_waiting_time_description": "Suprascrie configurația spațiului de lucru doar pentru acest sondaj.", + "overwrite_global_cooldown_period": "Setează o Perioadă de așteptare personalizată", + "overwrite_global_cooldown_period_description": "Suprascrie configurația din spațiul de lucru doar pentru acest sondaj.", "overwrite_placement": "Suprascriere amplasare", "overwrite_survey_logo": "Setează un logo personalizat pentru chestionar", "overwrite_the_global_placement_of_the_survey": "Suprascrie amplasarea globală a sondajului", @@ -3329,8 +3354,8 @@ "required": "Obligatoriu", "reset_to_theme_styles": "Resetare la stilurile temei", "reset_to_theme_styles_main_text": "Sigur doriți să resetați stilul la stilurile de temă? Acest lucru va elimina toate stilizările personalizate.", - "respect_global_waiting_time": "Folosește perioada de răcire", - "respect_global_waiting_time_description": "Acest sondaj respectă perioada de răcire setată în configurația spațiului de lucru. Va fi afișat doar dacă nu a mai apărut alt sondaj în acea perioadă.", + "respect_global_cooldown_period": "Folosește Perioada de așteptare", + "respect_global_cooldown_period_description": "Acest sondaj respectă Perioada de așteptare setată în configurația spațiului de lucru. Se afișează doar dacă niciun alt sondaj nu a apărut în acea perioadă.", "response_limit_can_t_be_set_to_0": "Limitul de răspunsuri nu poate fi setat la 0", "response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).", "response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Așteptați câteva secunde după declanșare înainte de a afișa sondajul", "wait_n_days_before_showing_this_survey_again": "Așteaptă sau mai multe zile între ultimul chestionar afișat și afișarea acestui chestionar.", "wait_n_seconds_before_showing_the_survey": "Așteaptă secunde înainte de a afișa chestionarul.", - "waiting_time_across_surveys": "Perioadă de răcire (între sondaje)", - "waiting_time_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interacționează acest sondaj cu perioada de răcire la nivel de workspace.", "welcome_message": "Mesaj de bun venit", "when": "Când", "wide": "Larg", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Arată întotdeauna sondajul", "display_criteria.time_based_day": "Zi", "display_criteria.time_based_days": "Zile", - "display_criteria.time_based_description": "Timp de așteptare global", + "display_criteria.time_based_description": "Perioadă de așteptare globală", "display_criteria.trigger_description": "Declanșator sondaj", "documentation_title": "Distribuie sondaje de interceptare pe toate platformele", "html_embed": "HTML încorporat în ", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index fdce7f4b60f6..a002a3592021 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Логотип Formbricks", "general": { "cannot_delete_only_workspace": "Это ваше единственное рабочее пространство, его нельзя удалить. Сначала создайте новое рабочее пространство.", + "cooldown_period_updated_successfully": "Период ожидания успешно обновлен", "custom_scripts": "Пользовательские скрипты", "custom_scripts_card_description": "Добавьте трекинговые скрипты и пиксели ко всем опросам по ссылке в этом рабочем пространстве.", "custom_scripts_description": "Скрипты будут внедряться в всех страниц опросов по ссылке.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Удалить рабочее пространство со всеми опросами, ответами, людьми, действиями и атрибутами. Это действие нельзя отменить.", "error_saving_workspace_information": "Ошибка при сохранении информации о рабочем пространстве", "only_owners_or_managers_can_delete_workspaces": "Только владельцы или менеджеры могут удалять рабочие пространства", - "recontact_waiting_time": "Период ожидания (между опросами)", - "recontact_waiting_time_settings_description": "Управляйте частотой, с которой пользователи могут проходить опросы на сайте и в приложении в этом рабочем пространстве.", + "recontact_cooldown_period": "Период ожидания (между опросами)", + "recontact_cooldown_period_settings_description": "Настройте, как часто пользователи могут видеть опросы во всех опросах на сайте и в приложении этого рабочего пространства.", "this_action_cannot_be_undone": "Это действие нельзя отменить.", "wait_x_days_before_showing_next_survey": "Ждать X дней перед показом следующего опроса:", - "waiting_period_updated_successfully": "Период ожидания успешно обновлён", "whats_your_workspace_called": "Как называется твоё рабочее пространство?", "workspace_deleted_successfully": "Рабочее пространство успешно удалено", "workspace_name_settings_description": "Измените название вашего рабочего пространства.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Добавить фильтр ниже", "add_your_first_filter_to_get_started": "Добавьте первый фильтр, чтобы начать", + "any_survey": "любой опрос", "cannot_delete_segment_used_in_surveys": "Вы не можете удалить этот сегмент, так как он всё ещё используется в следующих опросах:", "clone_and_edit_segment": "Клонировать и редактировать сегмент", "create_group": "Создать группу", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Пока нет атрибутов!", "no_filters_yet": "Пока нет фильтров!", "no_segments_yet": "У вас пока нет сохранённых сегментов.", + "number": "количество", "operator_contains": "содержит", "operator_does_not_contain": "не содержит", "operator_ends_with": "оканчивается на", + "operator_have_completed": "завершили", + "operator_have_not_completed": "не завершили", + "operator_have_not_seen": "не видели", + "operator_have_seen": "видели", + "operator_have_started_responding_to": "начали отвечать на", "operator_is_after": "после", "operator_is_before": "до", "operator_is_between": "находится между", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Равно", "operator_title_greater_equal": "Больше или равно", "operator_title_greater_than": "Больше чем", + "operator_title_have_completed": "Завершили", + "operator_title_have_not_completed": "Не завершили", + "operator_title_have_not_seen": "Не видели", + "operator_title_have_seen": "Видели", + "operator_title_have_started_responding_to": "Начали отвечать на", "operator_title_is_after": "После", "operator_title_is_before": "До", "operator_title_is_between": "Находится между", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Пользователь не входит в", "operator_user_is_in": "Пользователь входит в", "operator_user_is_not_in": "Пользователь не входит в", + "period": "период", "person_and_attributes": "Пользователь и атрибуты", "phone": "Телефон", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Пожалуйста, удалите этот сегмент из указанных опросов, чтобы его удалить.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Сегмент успешно обновлён!", "segment_used_in_other_surveys_make_changes_here": "Этот сегмент используется в других опросах. Внесите изменения здесь.", "segments_help_you_target_users_with_same_characteristics_easily": "Сегменты помогают легко находить пользователей с одинаковыми характеристиками", + "select_at_least_one_survey": "Выбери хотя бы один опрос", + "specific_surveys": "определённые опросы", + "survey_interaction": "Взаимодействие с опросом", "target_audience": "Целевая аудитория", "this_action_resets_all_filters_in_this_survey": "Это действие сбросит все фильтры в этом опросе.", + "time_unit_day": "день", + "time_unit_days": "дней", + "time_unit_month": "месяц", + "time_unit_months": "месяцев", + "time_unit_week": "неделя", + "time_unit_weeks": "недель", "title_is_required": "Требуется указать название.", "unknown_filter_type": "Неизвестный тип фильтра", "unlock_segments_description": "Организуйте контакты по сегментам для таргетирования определённых групп пользователей", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Значение должно быть числом.", "value_must_be_positive": "Значение должно быть положительным числом.", "view_filters": "Просмотреть фильтры", - "where": "Где" + "where": "Где", + "within_last": "за последние" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Перейти к настройкам", "convert_to_multiple_choice": "Преобразовать в мультивыбор", "convert_to_single_choice": "Преобразовать в одиночный выбор", + "cooldown_period_across_surveys": "Период ожидания (между опросами)", + "cooldown_period_across_surveys_description": "Чтобы избежать усталости от опросов, выберите, как этот опрос взаимодействует с общим периодом ожидания рабочего пространства.", "country": "Страна", "create_group": "Создать группу", "create_your_own_survey": "Создайте свой опрос", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Скрыть настройки вопроса", "hostname": "Имя хоста", "if_you_really_want_that_answer_ask_until_you_get_it": "Продолжать показывать при каждом срабатывании, пока не будет отправлен ответ или частичный ответ.", - "ignore_global_waiting_time": "Игнорировать период ожидания", - "ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.", + "ignore_global_cooldown_period": "Игнорировать период ожидания", + "ignore_global_cooldown_period_description": "Этот опрос может отображаться при выполнении условий, даже если недавно был показан другой опрос.", "image": "Изображение", "includes_all_of": "Включает все из", "includes_one_of": "Включает одно из", @@ -3253,8 +3278,8 @@ "options": "Параметры*", "options_used_in_logic_bulk_error": "Следующие варианты используются в логике: {questionIndexes}. Пожалуйста, сначала удалите их из логики.", "override_theme_with_individual_styles_for_this_survey": "Переопределить тему индивидуальными стилями для этого опроса.", - "overwrite_global_waiting_time": "Установить свой период ожидания", - "overwrite_global_waiting_time_description": "Переопределить настройки рабочего пространства только для этого опроса.", + "overwrite_global_cooldown_period": "Установить свой период ожидания", + "overwrite_global_cooldown_period_description": "Переопределить настройки рабочего пространства только для этого опроса.", "overwrite_placement": "Переопределить размещение", "overwrite_survey_logo": "Установить индивидуальный логотип опроса", "overwrite_the_global_placement_of_the_survey": "Переопределить глобальное размещение опроса", @@ -3329,8 +3354,8 @@ "required": "Обязательно", "reset_to_theme_styles": "Сбросить к стилям темы", "reset_to_theme_styles_main_text": "Вы уверены, что хотите сбросить оформление к стилям темы? Все индивидуальные настройки будут удалены.", - "respect_global_waiting_time": "Использовать период ожидания", - "respect_global_waiting_time_description": "Этот опрос следует периоду ожидания, установленному в настройках рабочего пространства. Он будет показан только если в этот период не появлялись другие опросы.", + "respect_global_cooldown_period": "Использовать период ожидания", + "respect_global_cooldown_period_description": "Этот опрос следует периоду ожидания, установленному в конфигурации рабочего пространства. Он отображается только в том случае, если в течение этого периода не появлялись другие опросы.", "response_limit_can_t_be_set_to_0": "Лимит ответов не может быть равен 0", "response_limit_needs_to_exceed_number_of_received_responses": "Лимит ответов должен превышать количество полученных ответов ({responseCount}).", "response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Подождите несколько секунд после срабатывания триггера перед показом опроса", "wait_n_days_before_showing_this_survey_again": "Подождать или более дней между последним показом опроса и показом этого опроса.", "wait_n_seconds_before_showing_the_survey": "Подождать секунд перед показом опроса.", - "waiting_time_across_surveys": "Период ожидания (между опросами)", - "waiting_time_across_surveys_description": "Чтобы избежать усталости от опросов, выберите, как этот опрос взаимодействует с общим периодом ожидания в рабочем пространстве.", "welcome_message": "Приветственное сообщение", "when": "Когда", "wide": "Широкая", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "Всегда показывать опрос", "display_criteria.time_based_day": "День", "display_criteria.time_based_days": "Дни", - "display_criteria.time_based_description": "Глобальное время ожидания", + "display_criteria.time_based_description": "Глобальный период ожидания", "display_criteria.trigger_description": "Триггер опроса", "documentation_title": "Распространяйте перехватывающие опросы на всех платформах", "html_embed": "Встраивание HTML в ", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index ca110793548f..08e674724701 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks-logotyp", "general": { "cannot_delete_only_workspace": "Detta är din enda arbetsyta, den kan inte tas bort. Skapa först en ny arbetsyta.", + "cooldown_period_updated_successfully": "Nedkylningsperioden har uppdaterats", "custom_scripts": "Anpassade skript", "custom_scripts_card_description": "Lägg till spårningsskript och pixlar i alla länkundersökningar i denna arbetsyta.", "custom_scripts_description": "Skript kommer att injiceras i på alla länkundersökningssidor.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Ta bort arbetsyta med alla enkäter, svar, personer, åtgärder och attribut. Detta kan inte ångras.", "error_saving_workspace_information": "Fel vid sparande av arbetsytans information", "only_owners_or_managers_can_delete_workspaces": "Endast ägare eller administratörer kan ta bort arbetsytor", - "recontact_waiting_time": "Väntetid (mellan enkäter)", - "recontact_waiting_time_settings_description": "Styr hur ofta användare kan bli tillfrågade i alla webbplats- och appenkäter i denna arbetsyta.", + "recontact_cooldown_period": "Väntetid (mellan enkäter)", + "recontact_cooldown_period_settings_description": "Kontrollera hur ofta användare kan få enkäter över alla Webbplats- och Appenkäter i den här arbetsytan.", "this_action_cannot_be_undone": "Denna åtgärd kan inte ångras.", "wait_x_days_before_showing_next_survey": "Vänta X dagar innan nästa enkät visas:", - "waiting_period_updated_successfully": "Väntetiden har uppdaterats", "whats_your_workspace_called": "Vad heter din arbetsyta?", "workspace_deleted_successfully": "Arbetsytan har tagits bort", "workspace_name_settings_description": "Ändra namnet på din arbetsyta.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Lägg till filter nedan", "add_your_first_filter_to_get_started": "Lägg till ditt första filter för att komma igång", + "any_survey": "vilken undersökning som helst", "cannot_delete_segment_used_in_surveys": "Du kan inte ta bort detta segment eftersom det fortfarande används i dessa enkäter:", "clone_and_edit_segment": "Klona och redigera segment", "create_group": "Skapa grupp", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Inga attribut ännu!", "no_filters_yet": "Det finns inga filter ännu!", "no_segments_yet": "Du har för närvarande inga sparade segment.", + "number": "antal", "operator_contains": "innehåller", "operator_does_not_contain": "innehåller inte", "operator_ends_with": "slutar med", + "operator_have_completed": "har slutfört", + "operator_have_not_completed": "har inte slutfört", + "operator_have_not_seen": "har inte sett", + "operator_have_seen": "har sett", + "operator_have_started_responding_to": "har börjat svara på", "operator_is_after": "är efter", "operator_is_before": "är före", "operator_is_between": "är mellan", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Är lika med", "operator_title_greater_equal": "Större än eller lika med", "operator_title_greater_than": "Större än", + "operator_title_have_completed": "Har slutfört", + "operator_title_have_not_completed": "Har inte slutfört", + "operator_title_have_not_seen": "Har inte sett", + "operator_title_have_seen": "Har sett", + "operator_title_have_started_responding_to": "Har börjat svara på", "operator_title_is_after": "Är efter", "operator_title_is_before": "Är före", "operator_title_is_between": "Är mellan", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Användaren är inte i", "operator_user_is_in": "Användaren är i", "operator_user_is_not_in": "Användaren är inte i", + "period": "period", "person_and_attributes": "Person och attribut", "phone": "Telefon", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Vänligen ta bort segmentet från dessa enkäter för att kunna ta bort det.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segment uppdaterat!", "segment_used_in_other_surveys_make_changes_here": "Detta segment används i andra undersökningar. Gör ändringar här.", "segments_help_you_target_users_with_same_characteristics_easily": "Segment hjälper dig att enkelt rikta in dig på användare med samma egenskaper", + "select_at_least_one_survey": "Välj minst en undersökning", + "specific_surveys": "specifika undersökningar", + "survey_interaction": "Undersökningsinteraktion", "target_audience": "Målgrupp", "this_action_resets_all_filters_in_this_survey": "Denna åtgärd återställer alla filter i denna enkät.", + "time_unit_day": "dag", + "time_unit_days": "dagar", + "time_unit_month": "månad", + "time_unit_months": "månader", + "time_unit_week": "vecka", + "time_unit_weeks": "veckor", "title_is_required": "Titel krävs.", "unknown_filter_type": "Okänd filtertyp", "unlock_segments_description": "Organisera kontakter i segment för att rikta in dig på specifika användargrupper", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Värdet måste vara ett nummer.", "value_must_be_positive": "Värdet måste vara ett positivt nummer.", "view_filters": "Visa filter", - "where": "Där" + "where": "Där", + "within_last": "inom de senaste" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Fortsätt till inställningar", "convert_to_multiple_choice": "Konvertera till flerval", "convert_to_single_choice": "Konvertera till enkelval", + "cooldown_period_across_surveys": "Väntetid (mellan enkäter)", + "cooldown_period_across_surveys_description": "För att undvika enkättrötthet, välj hur den här enkäten interagerar med arbetsytans övergripande väntetid.", "country": "Land", "create_group": "Skapa grupp", "create_your_own_survey": "Skapa din egen enkät", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Dölj frågeinställningar", "hostname": "Värdnamn", "if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när den utlöses tills ett svar eller delsvar skickas in.", - "ignore_global_waiting_time": "Ignorera väntetid", - "ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.", + "ignore_global_cooldown_period": "Ignorera väntetid", + "ignore_global_cooldown_period_description": "Den här enkäten kan visas när dess villkor är uppfyllda, även om en annan enkät visades nyligen.", "image": "Bild", "includes_all_of": "Inkluderar alla av", "includes_one_of": "Inkluderar en av", @@ -3253,8 +3278,8 @@ "options": "Alternativ*", "options_used_in_logic_bulk_error": "Följande alternativ används i logiken: {questionIndexes}. Vänligen ta bort dem från logiken först.", "override_theme_with_individual_styles_for_this_survey": "Åsidosätt temat med individuella stilar för denna enkät.", - "overwrite_global_waiting_time": "Ange anpassad väntetid", - "overwrite_global_waiting_time_description": "Åsidosätt arbetsytans konfiguration för enbart denna undersökning.", + "overwrite_global_cooldown_period": "Ange anpassad väntetid", + "overwrite_global_cooldown_period_description": "Åsidosätt arbetsytans konfiguration för just den här enkäten.", "overwrite_placement": "Åsidosätt placering", "overwrite_survey_logo": "Ange anpassad logotyp för undersökningen", "overwrite_the_global_placement_of_the_survey": "Åsidosätt den globala placeringen av enkäten", @@ -3329,8 +3354,8 @@ "required": "Obligatorisk", "reset_to_theme_styles": "Återställ till temastil", "reset_to_theme_styles_main_text": "Är du säker på att du vill återställa stilen till temastilen? Detta kommer att ta bort all anpassad styling.", - "respect_global_waiting_time": "Använd väntetid", - "respect_global_waiting_time_description": "Denna enkät följer väntetiden som är inställd i arbetsytans konfiguration. Den visas endast om ingen annan enkät har visats under den perioden.", + "respect_global_cooldown_period": "Använd väntetid", + "respect_global_cooldown_period_description": "Den här enkäten följer väntetiden som är inställd i arbetsytans konfiguration. Den visas bara om ingen annan enkät har dykt upp under den perioden.", "response_limit_can_t_be_set_to_0": "Svarsgränsen kan inte sättas till 0", "response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).", "response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Vänta några sekunder efter utlösningen innan enkäten visas", "wait_n_days_before_showing_this_survey_again": "Vänta eller fler dagar mellan den senast visade enkäten och visning av denna enkät.", "wait_n_seconds_before_showing_the_survey": "Vänta sekunder innan enkäten visas.", - "waiting_time_across_surveys": "Väntetid (mellan enkäter)", - "waiting_time_across_surveys_description": "För att undvika enkättrötthet, välj hur denna enkät ska förhålla sig till arbetsytans gemensamma väntetid.", "welcome_message": "Välkomstmeddelande", "when": "När", "wide": "Bred", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index ebd42cc39144..435a3a9fbd68 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks Logosu", "general": { "cannot_delete_only_workspace": "Bu senin tek çalışma alanın, silinemez. Önce yeni bir çalışma alanı oluştur.", + "cooldown_period_updated_successfully": "Bekleme süresi başarıyla güncellendi", "custom_scripts": "Özel Scriptler", "custom_scripts_card_description": "Bu çalışma alanındaki tüm bağlantı anketlerine takip scriptleri ve pikseller ekleyin.", "custom_scripts_description": "Scriptler, tüm bağlantı anketi sayfalarının etiketine enjekte edilecektir.", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "Çalışma alanını tüm anketler, yanıtlar, kişiler, aksiyonlar ve özelliklerle birlikte sil. Bu işlem geri alınamaz.", "error_saving_workspace_information": "Çalışma alanı bilgileri kaydedilirken hata oluştu", "only_owners_or_managers_can_delete_workspaces": "Yalnızca sahipler veya yöneticiler çalışma alanlarını silebilir", - "recontact_waiting_time": "Bekleme Süresi (anketler arası)", - "recontact_waiting_time_settings_description": "Bu çalışma alanındaki tüm Web Sitesi ve Uygulama Anketlerinde kullanıcılara ne sıklıkla anket gösterileceğini kontrol et.", + "recontact_cooldown_period": "Bekleme Süresi (tüm anketler için)", + "recontact_cooldown_period_settings_description": "Bu çalışma alanındaki tüm Web Sitesi ve Uygulama Anketleri için kullanıcılara ne sıklıkla anket gösterileceğini kontrol et.", "this_action_cannot_be_undone": "Bu işlem geri alınamaz.", "wait_x_days_before_showing_next_survey": "Bir sonraki anketi göstermeden önce X gün bekle:", - "waiting_period_updated_successfully": "Bekleme süresi başarıyla güncellendi", "whats_your_workspace_called": "Çalışma alanının adı ne?", "workspace_deleted_successfully": "Çalışma alanı başarıyla silindi", "workspace_name_settings_description": "Çalışma alanının adını değiştir.", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "Aşağıya filtre ekle", "add_your_first_filter_to_get_started": "Başlamak için ilk filtreni ekle", + "any_survey": "herhangi bir anket", "cannot_delete_segment_used_in_surveys": "Bu segmenti silemezsin çünkü hala şu anketlerde kullanılıyor:", "clone_and_edit_segment": "Segmenti Kopyala ve Düzenle", "create_group": "Grup oluştur", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "Henüz özellik yok!", "no_filters_yet": "Henüz filtre yok!", "no_segments_yet": "Şu anda kaydedilmiş segmentiniz bulunmuyor.", + "number": "sayı", "operator_contains": "içerir", "operator_does_not_contain": "içermez", "operator_ends_with": "ile biter", + "operator_have_completed": "tamamladı", + "operator_have_not_completed": "tamamlamadı", + "operator_have_not_seen": "görmediler", + "operator_have_seen": "gördüler", + "operator_have_started_responding_to": "yanıtlamaya başladı", "operator_is_after": "sonrasında", "operator_is_before": "öncesinde", "operator_is_between": "arasında", @@ -2341,6 +2348,11 @@ "operator_title_equals": "Eşittir", "operator_title_greater_equal": "Büyük veya eşit", "operator_title_greater_than": "Büyüktür", + "operator_title_have_completed": "Tamamladı", + "operator_title_have_not_completed": "Tamamlamadı", + "operator_title_have_not_seen": "Görmediler", + "operator_title_have_seen": "Gördüler", + "operator_title_have_started_responding_to": "Yanıtlamaya başladı", "operator_title_is_after": "Sonrasında", "operator_title_is_before": "Öncesinde", "operator_title_is_between": "Arasında", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "Kullanıcı dahil değil", "operator_user_is_in": "Kullanıcı dahil", "operator_user_is_not_in": "Kullanıcı dahil değil", + "period": "dönem", "person_and_attributes": "Kişi ve Özellikler", "phone": "Telefon", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Silmek için lütfen segmenti bu anketlerden kaldır.", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "Segment başarıyla güncellendi", "segment_used_in_other_surveys_make_changes_here": "Bu segment diğer anketlerde kullanılıyor. Değişiklikleri buradan yapın.", "segments_help_you_target_users_with_same_characteristics_easily": "Segmentler, aynı özelliklere sahip kullanıcıları kolayca hedeflemenize yardımcı olur", + "select_at_least_one_survey": "En az bir anket seçin", + "specific_surveys": "belirli anketler", + "survey_interaction": "Anket etkileşimi", "target_audience": "Hedef Kitle", "this_action_resets_all_filters_in_this_survey": "Bu işlem, bu anketteki tüm filtreleri sıfırlar.", + "time_unit_day": "gün", + "time_unit_days": "gün", + "time_unit_month": "ay", + "time_unit_months": "ay", + "time_unit_week": "hafta", + "time_unit_weeks": "hafta", "title_is_required": "Başlık zorunludur.", "unknown_filter_type": "Bilinmeyen filtre türü", "unlock_segments_description": "Belirli kullanıcı gruplarını hedeflemek için kişileri segmentler halinde düzenle", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "Değer bir sayı olmalıdır.", "value_must_be_positive": "Değer pozitif bir sayı olmalıdır.", "view_filters": "Filtreleri görüntüle", - "where": "Nerede" + "where": "Nerede", + "within_last": "son" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "Ayarlara Devam Et", "convert_to_multiple_choice": "Çoklu Seçime Dönüştür", "convert_to_single_choice": "Tekli Seçime Dönüştür", + "cooldown_period_across_surveys": "Bekleme Süresi (tüm anketler için)", + "cooldown_period_across_surveys_description": "Anket yorgunluğunu önlemek için, bu anketin çalışma alanı genelindeki Bekleme Süresi ile nasıl etkileşime gireceğini seç.", "country": "Ülke", "create_group": "Grup oluştur", "create_your_own_survey": "Kendi anketini oluştur", @@ -3173,8 +3198,8 @@ "hide_question_settings": "Soru ayarlarını gizle", "hostname": "Sunucu adı", "if_you_really_want_that_answer_ask_until_you_get_it": "Bir yanıt veya kısmi yanıt gönderilene kadar tetiklendiğinde göstermeye devam et.", - "ignore_global_waiting_time": "Bekleme Süresini Yoksay", - "ignore_global_waiting_time_description": "Bu anket, son zamanlarda başka bir anket gösterilmiş olsa bile koşulları karşılandığında gösterilebilir.", + "ignore_global_cooldown_period": "Bekleme Süresini Yoksay", + "ignore_global_cooldown_period_description": "Bu anket, koşulları karşılandığında her zaman gösterilebilir; yakın zamanda başka bir anket gösterilmiş olsa bile.", "image": "Görsel", "includes_all_of": "Tümünü içerir", "includes_one_of": "Birini içerir", @@ -3253,8 +3278,8 @@ "options": "Seçenekler*", "options_used_in_logic_bulk_error": "Şu seçenekler mantıkta kullanılıyor: {questionIndexes}. Lütfen önce bunları mantıktan kaldır.", "override_theme_with_individual_styles_for_this_survey": "Bu anket için temayı özel stillerle geçersiz kıl.", - "overwrite_global_waiting_time": "Özel Bekleme Süresi Ayarla", - "overwrite_global_waiting_time_description": "Yalnızca bu anket için çalışma alanı yapılandırmasını geçersiz kıl.", + "overwrite_global_cooldown_period": "Özel Bekleme Süresi Ayarla", + "overwrite_global_cooldown_period_description": "Sadece bu anket için çalışma alanı yapılandırmasını geçersiz kıl.", "overwrite_placement": "Yerleşimi değiştir", "overwrite_survey_logo": "Özel anket logosu ayarla", "overwrite_the_global_placement_of_the_survey": "Anketin genel yerleşimini değiştir", @@ -3329,8 +3354,8 @@ "required": "Zorunlu", "reset_to_theme_styles": "Tema stillerine sıfırla", "reset_to_theme_styles_main_text": "Stillendirmeyi tema stillerine sıfırlamak istediğinden emin misin? Bu işlem tüm özel stillendirmeleri kaldıracak.", - "respect_global_waiting_time": "Bekleme Süresini Kullan", - "respect_global_waiting_time_description": "Bu anket, çalışma alanının yapılandırmasında ayarlanan Bekleme Süresine uyar. Yalnızca bu süre içinde başka bir anket görünmediyse gösterilir.", + "respect_global_cooldown_period": "Bekleme Süresini Kullan", + "respect_global_cooldown_period_description": "Bu anket, çalışma alanının yapılandırmasında belirlenen Bekleme Süresine uyar. Sadece bu süre içinde başka bir anket görünmemişse gösterilir.", "response_limit_can_t_be_set_to_0": "Yanıt limiti 0'a ayarlanamaz", "response_limit_needs_to_exceed_number_of_received_responses": "Yanıt limiti, alınan yanıt sayısını ({responseCount}) aşmalıdır.", "response_limits_redirections_and_more": "Yanıt limitleri, yönlendirmeler ve daha fazlası.", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Tetikleyiciden sonra anketi göstermeden önce birkaç saniye bekle", "wait_n_days_before_showing_this_survey_again": "Son gösterilen anket ile bu anketi gösterme arasında veya daha fazla gün geçmesini bekle.", "wait_n_seconds_before_showing_the_survey": "Anketi göstermeden önce saniye bekle.", - "waiting_time_across_surveys": "Bekleme Süresi (anketler arası)", - "waiting_time_across_surveys_description": "Anket yorgunluğunu önlemek için, bu anketin tüm alan genelinde geçerli Bekleme Süresi ile nasıl etkileşime gireceğini seç.", "welcome_message": "Hoş geldin mesajı", "when": "Ne zaman", "wide": "Geniş", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index b62faa11140f..3710d57ea7ba 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks Logo", "general": { "cannot_delete_only_workspace": "这是您唯一的工作区,无法删除。请先创建一个新工作区。", + "cooldown_period_updated_successfully": "冷却期已成功更新", "custom_scripts": "自定义脚本", "custom_scripts_card_description": "为此工作区内所有链接调查添加跟踪脚本和像素代码。", "custom_scripts_description": "脚本将被注入到所有链接调查页面的中。", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "删除工作区及其所有调查、回应、人员、动作和属性。此操作无法撤销。", "error_saving_workspace_information": "保存工作区信息时出错", "only_owners_or_managers_can_delete_workspaces": "只有所有者或管理员可以删除工作区", - "recontact_waiting_time": "冷却期(跨问卷)", - "recontact_waiting_time_settings_description": "控制在此工作区内所有网站和应用问卷的调查频率。", + "recontact_cooldown_period": "冷却期(跨调查)", + "recontact_cooldown_period_settings_description": "控制此工作区中所有网站和应用调查对用户进行调查的频率。", "this_action_cannot_be_undone": "此操作无法撤销。", "wait_x_days_before_showing_next_survey": "在显示下一个调查前等待 X 天:", - "waiting_period_updated_successfully": "等待周期更新成功", "whats_your_workspace_called": "您的工作区叫什么名字?", "workspace_deleted_successfully": "工作区删除成功", "workspace_name_settings_description": "更改您的工作区名称。", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "在下方添加 过滤器", "add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始", + "any_survey": "任意调查", "cannot_delete_segment_used_in_surveys": "您 不能 删除 此 段 ,因为 它 仍然 用于 这些 问卷 中:", "clone_and_edit_segment": "复制 和 编辑 段", "create_group": "创建 群组", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "暂无属性!", "no_filters_yet": "还 没有 筛选器!", "no_segments_yet": "您 目前 尚无 保存 的 段。", + "number": "数量", "operator_contains": "包含", "operator_does_not_contain": "不包含", "operator_ends_with": "以...结束", + "operator_have_completed": "已完成", + "operator_have_not_completed": "未完成", + "operator_have_not_seen": "未查看", + "operator_have_seen": "已查看", + "operator_have_started_responding_to": "已开始回复", "operator_is_after": "在...之后", "operator_is_before": "在...之前", "operator_is_between": "介于...之间", @@ -2341,6 +2348,11 @@ "operator_title_equals": "等于", "operator_title_greater_equal": "大于或等于", "operator_title_greater_than": "大于", + "operator_title_have_completed": "已完成", + "operator_title_have_not_completed": "未完成", + "operator_title_have_not_seen": "未查看", + "operator_title_have_seen": "已查看", + "operator_title_have_started_responding_to": "已开始回复", "operator_title_is_after": "在...之后", "operator_title_is_before": "在...之前", "operator_title_is_between": "介于...之间", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "用户不属于", "operator_user_is_in": "用户属于", "operator_user_is_not_in": "用户不属于", + "period": "时间段", "person_and_attributes": "人员 及 属性", "phone": "电话", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "请 从 这些 调查 中 移除 该 部分 以 进行 删除。", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "片段 更新 成功", "segment_used_in_other_surveys_make_changes_here": "此细分在其他调查中使用。请在这里进行修改。", "segments_help_you_target_users_with_same_characteristics_easily": "细分 帮助 您 轻松 定位 具有 相同 特征 的 用户", + "select_at_least_one_survey": "请至少选择一个调查", + "specific_surveys": "特定调查", + "survey_interaction": "调查互动", "target_audience": "目标 受众", "this_action_resets_all_filters_in_this_survey": "此操作将重置此问卷中的所有筛选器。", + "time_unit_day": "天", + "time_unit_days": "天", + "time_unit_month": "个月", + "time_unit_months": "个月", + "time_unit_week": "周", + "time_unit_weeks": "周", "title_is_required": "标题 是 必需的", "unknown_filter_type": "未知 过滤器 类型", "unlock_segments_description": "将 联系人 组织成 段,以 目标 具体 用户 群体", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "值 必须 是 一个 数字。", "value_must_be_positive": "值必须是正数。", "view_filters": "查看 筛选条件", - "where": "位置" + "where": "位置", + "within_last": "在过去" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "继续 到 设置", "convert_to_multiple_choice": "转换为 多选", "convert_to_single_choice": "转换为 单选", + "cooldown_period_across_surveys": "冷却期(跨调查)", + "cooldown_period_across_surveys_description": "为防止调查疲劳,请选择此调查如何与工作区范围的冷却期互动。", "country": "国家", "create_group": "创建 群组", "create_your_own_survey": "创建 你 的 调查", @@ -3173,8 +3198,8 @@ "hide_question_settings": "隐藏问题设置", "hostname": "主 机 名", "if_you_really_want_that_answer_ask_until_you_get_it": "在触发时持续显示,直到提交回复或部分回复为止。", - "ignore_global_waiting_time": "忽略冷却期", - "ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。", + "ignore_global_cooldown_period": "忽略冷却期", + "ignore_global_cooldown_period_description": "只要满足条件,此调查就可以显示,即使最近已显示过其他调查。", "image": "图片", "includes_all_of": "包括所有 ", "includes_one_of": "包括一 个", @@ -3253,8 +3278,8 @@ "options": "选项*", "options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。", "override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。", - "overwrite_global_waiting_time": "自定义冷却期", - "overwrite_global_waiting_time_description": "仅针对此调查覆盖工作空间配置。", + "overwrite_global_cooldown_period": "设置自定义冷却期", + "overwrite_global_cooldown_period_description": "仅为此调查覆盖工作区配置。", "overwrite_placement": "覆盖 放置", "overwrite_survey_logo": "设置自定义调查 logo", "overwrite_the_global_placement_of_the_survey": "覆盖 全局 调查 放置", @@ -3329,8 +3354,8 @@ "required": "必需的", "reset_to_theme_styles": "重置 为 主题 风格", "reset_to_theme_styles_main_text": "您 确定 要 将 样式 重置 为 主题 样式吗?这 将 删除 所有 自定义 样式。", - "respect_global_waiting_time": "使用冷却期", - "respect_global_waiting_time_description": "此问卷遵循工作区配置的冷却期,仅在该期间未出现其他问卷时才会显示。", + "respect_global_cooldown_period": "使用冷却期", + "respect_global_cooldown_period_description": "此调查遵循工作区配置中设置的冷却期。仅当该期间内未显示其他调查时才会显示。", "response_limit_can_t_be_set_to_0": "不 能 将 响应 限制 设置 为 0", "response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 ({responseCount})。", "response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "触发后等待几秒再显示问卷", "wait_n_days_before_showing_this_survey_again": "在上次显示的调查与显示此调查之间等待 天或更长时间。", "wait_n_seconds_before_showing_the_survey": "在显示调查前等待 秒。", - "waiting_time_across_surveys": "冷却期(跨问卷)", - "waiting_time_across_surveys_description": "为防止问卷疲劳,请选择此问卷与工作区冷却期的交互方式。", "welcome_message": "欢迎 信息", "when": "当", "wide": "宽", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "始终 显示 调查", "display_criteria.time_based_day": "天", "display_criteria.time_based_days": "天", - "display_criteria.time_based_description": "全球 等待 时间", + "display_criteria.time_based_description": "全局冷却期", "display_criteria.trigger_description": "调查 触发", "documentation_title": "分发 截获 调查 在 所有 平台", "html_embed": " 中的 HTML 嵌入", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 5d019e3884fe..fc272e90fcd2 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1991,6 +1991,7 @@ "formbricks_logo": "Formbricks 標誌", "general": { "cannot_delete_only_workspace": "這是您唯一的工作區,無法刪除。請先建立新的工作區。", + "cooldown_period_updated_successfully": "冷卻期已成功更新", "custom_scripts": "自訂腳本", "custom_scripts_card_description": "將追蹤腳本與像素碼加入此工作區內所有連結調查問卷。", "custom_scripts_description": "腳本將注入至所有連結調查問卷頁面的 。", @@ -2005,11 +2006,10 @@ "delete_workspace_settings_description": "刪除工作區及其所有問卷、回應、人員、操作和屬性。此操作無法復原。", "error_saving_workspace_information": "儲存工作區資訊時發生錯誤", "only_owners_or_managers_can_delete_workspaces": "僅有擁有者或管理者可刪除工作區", - "recontact_waiting_time": "冷卻期(跨問卷)", - "recontact_waiting_time_settings_description": "控制此工作區內所有網站與應用程式問卷的調查頻率。", + "recontact_cooldown_period": "冷卻期(跨問卷)", + "recontact_cooldown_period_settings_description": "控制此工作區中所有網站與應用程式問卷對使用者的調查頻率。", "this_action_cannot_be_undone": "此操作無法復原。", "wait_x_days_before_showing_next_survey": "在顯示下一份問卷前請等待 X 天:", - "waiting_period_updated_successfully": "等待期間已成功更新", "whats_your_workspace_called": "您的工作區名稱是什麼?", "workspace_deleted_successfully": "工作區已成功刪除", "workspace_name_settings_description": "變更您的工作區名稱。", @@ -2302,6 +2302,7 @@ "segments": { "add_filter_below": "在下方新增篩選器", "add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用", + "any_survey": "任何問卷", "cannot_delete_segment_used_in_surveys": "您無法刪除此區隔,因為它仍在這些問卷中使用:", "clone_and_edit_segment": "複製和編輯區隔", "create_group": "建立群組", @@ -2323,9 +2324,15 @@ "no_attributes_yet": "尚無屬性!", "no_filters_yet": "尚無篩選器!", "no_segments_yet": "您目前沒有已儲存的區隔。", + "number": "數量", "operator_contains": "包含", "operator_does_not_contain": "不包含", "operator_ends_with": "結尾為", + "operator_have_completed": "已完成", + "operator_have_not_completed": "尚未完成", + "operator_have_not_seen": "尚未看過", + "operator_have_seen": "已看過", + "operator_have_started_responding_to": "已開始回答", "operator_is_after": "在之後", "operator_is_before": "在之前", "operator_is_between": "介於", @@ -2341,6 +2348,11 @@ "operator_title_equals": "等於", "operator_title_greater_equal": "大於或等於", "operator_title_greater_than": "大於", + "operator_title_have_completed": "已完成", + "operator_title_have_not_completed": "尚未完成", + "operator_title_have_not_seen": "尚未看過", + "operator_title_have_seen": "已看過", + "operator_title_have_started_responding_to": "已開始回答", "operator_title_is_after": "在之後", "operator_title_is_before": "在之前", "operator_title_is_between": "介於", @@ -2357,6 +2369,7 @@ "operator_title_user_is_not_in": "使用者不屬於", "operator_user_is_in": "使用者屬於", "operator_user_is_not_in": "使用者不屬於", + "period": "期間", "person_and_attributes": "人員與屬性", "phone": "電話", "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "請從這些問卷中移除區隔,以便將其刪除。", @@ -2372,8 +2385,17 @@ "segment_updated_successfully": "區隔已成功更新!", "segment_used_in_other_surveys_make_changes_here": "此區隔已用於其他問卷。請在此進行變更。", "segments_help_you_target_users_with_same_characteristics_easily": "區隔可協助您輕鬆針對具有相同特徵的使用者", + "select_at_least_one_survey": "請至少選擇一份問卷", + "specific_surveys": "特定問卷", + "survey_interaction": "問卷互動", "target_audience": "目標受眾", "this_action_resets_all_filters_in_this_survey": "此操作會重設此問卷中的所有篩選器。", + "time_unit_day": "天", + "time_unit_days": "天", + "time_unit_month": "個月", + "time_unit_months": "個月", + "time_unit_week": "週", + "time_unit_weeks": "週", "title_is_required": "標題為必填項。", "unknown_filter_type": "未知的篩選器類型", "unlock_segments_description": "將聯絡人整理到區隔中,以鎖定特定的使用者群組", @@ -2383,7 +2405,8 @@ "value_must_be_a_number": "值必須是數字。", "value_must_be_positive": "值必須是正數。", "view_filters": "檢視篩選器", - "where": "何處" + "where": "何處", + "within_last": "在過去" }, "settings": { "api_keys": { @@ -3053,6 +3076,8 @@ "continue_to_settings": "繼續設定", "convert_to_multiple_choice": "轉換為多選", "convert_to_single_choice": "轉換為單選", + "cooldown_period_across_surveys": "冷卻期(跨問卷)", + "cooldown_period_across_surveys_description": "為避免問卷疲勞,請選擇此問卷如何與工作區的冷卻期互動。", "country": "國家/地區", "create_group": "建立群組", "create_your_own_survey": "建立您自己的問卷", @@ -3173,8 +3198,8 @@ "hide_question_settings": "隱藏問題設定", "hostname": "主機名稱", "if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示直到提交回應或部分回應為止。", - "ignore_global_waiting_time": "忽略冷卻期", - "ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。", + "ignore_global_cooldown_period": "忽略冷卻期", + "ignore_global_cooldown_period_description": "此問卷可在符合條件時隨時顯示,即使最近已顯示過其他問卷。", "image": "圖片", "includes_all_of": "包含全部", "includes_one_of": "包含其中之一", @@ -3253,8 +3278,8 @@ "options": "選項*", "options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。", "override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。", - "overwrite_global_waiting_time": "自訂冷卻期", - "overwrite_global_waiting_time_description": "僅針對此調查問卷覆寫工作區設定。", + "overwrite_global_cooldown_period": "設定自訂冷卻期", + "overwrite_global_cooldown_period_description": "僅針對此問卷覆寫工作區設定。", "overwrite_placement": "覆寫位置", "overwrite_survey_logo": "設定自訂問卷標誌", "overwrite_the_global_placement_of_the_survey": "覆寫問卷的整體位置", @@ -3329,8 +3354,8 @@ "required": "必填", "reset_to_theme_styles": "重設為主題樣式", "reset_to_theme_styles_main_text": "您確定要將樣式重設為主題樣式嗎?這將移除所有自訂樣式。", - "respect_global_waiting_time": "使用冷卻期", - "respect_global_waiting_time_description": "此問卷將遵循工作區設定的冷卻期。僅在該期間內未顯示其他問卷時才會顯示。", + "respect_global_cooldown_period": "使用冷卻期", + "respect_global_cooldown_period_description": "此問卷遵循工作區設定中的冷卻期。只有在該期間內未顯示其他問卷時才會顯示。", "response_limit_can_t_be_set_to_0": "回應限制不能設定為 0", "response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ({responseCount})。", "response_limits_redirections_and_more": "回應限制、重新導向等。", @@ -3480,8 +3505,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷", "wait_n_days_before_showing_this_survey_again": "在上次顯示問卷後,等待 天或更長時間後再次顯示此問卷。", "wait_n_seconds_before_showing_the_survey": "等待 秒後再顯示問卷。", - "waiting_time_across_surveys": "冷卻期(跨問卷)", - "waiting_time_across_surveys_description": "為避免問卷疲勞,請選擇此問卷如何與工作區的冷卻期互動。", "welcome_message": "歡迎訊息", "when": "當", "wide": "寬版", @@ -3742,7 +3765,7 @@ "display_criteria.time_based_always": "永遠顯示問卷", "display_criteria.time_based_day": "天", "display_criteria.time_based_days": "天", - "display_criteria.time_based_description": "全域等待時間", + "display_criteria.time_based_description": "全域冷卻期", "display_criteria.trigger_description": "問卷觸發條件", "documentation_title": "在所有平台上發布截取式問卷", "html_embed": "在 中嵌入 HTML", diff --git a/apps/web/modules/ee/contacts/actions.ts b/apps/web/modules/ee/contacts/actions.ts index e1ef658f097c..59d43367bd4d 100644 --- a/apps/web/modules/ee/contacts/actions.ts +++ b/apps/web/modules/ee/contacts/actions.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { ZContactAttributesInput } from "@formbricks/types/contact-attribute"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; @@ -14,6 +14,7 @@ import { getWorkspaceIdFromContactId, } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { createContactsFromCSV, deleteContact, getContact, getContacts } from "./lib/contacts"; import { updateContactAttributes } from "./lib/update-contact-attributes"; import { @@ -32,10 +33,11 @@ export const getContactsAction = authenticatedActionClient .inputSchema(ZGetContactsAction) .action(async ({ ctx, parsedInput }) => { const workspaceId = parsedInput.workspaceId; + const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); await checkAuthorizationUpdated({ userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(workspaceId), + organizationId, access: [ { type: "organization", @@ -49,6 +51,11 @@ export const getContactsAction = authenticatedActionClient ], }); + const isContactsEnabled = await getIsContactsEnabled(organizationId); + if (!isContactsEnabled) { + throw new OperationNotAllowedError("Contacts are not enabled for this organization"); + } + return getContacts(workspaceId, parsedInput.offset, parsedInput.searchValue); }); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.test.ts index e5905069292c..3b31eae292c6 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.test.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.test.ts @@ -5,6 +5,7 @@ import { DatabaseError } from "@formbricks/types/errors"; import { TBaseFilter } from "@formbricks/types/segment"; import { validateInputs } from "@/lib/utils/validate"; import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query"; +import { type TContactInteractionData } from "@/modules/ee/contacts/segments/lib/filter/survey-interaction"; import { getPersonSegmentIds, getSegments } from "./segments"; vi.mock("@/lib/cache", () => ({ @@ -45,6 +46,7 @@ const mockWorkspaceId = "workspace-id-mock"; const mockContactId = "test-contact-id"; const mockContactUserId = "test-contact-user-id"; const mockDeviceType = "desktop" as const; +const mockInteractionData: TContactInteractionData = { displays: [], responses: [] }; const mockSegmentsData = [ { id: "segment1", filters: [{}] as TBaseFilter[] }, @@ -115,7 +117,8 @@ describe("segments lib", () => { mockWorkspaceId, mockContactId, mockContactUserId, - mockDeviceType + mockDeviceType, + mockInteractionData ); expect(validateInputs).toHaveBeenCalled(); @@ -136,7 +139,8 @@ describe("segments lib", () => { mockWorkspaceId, mockContactId, mockContactUserId, - mockDeviceType + mockDeviceType, + mockInteractionData ); expect(result).toEqual([]); @@ -151,7 +155,8 @@ describe("segments lib", () => { mockWorkspaceId, mockContactId, mockContactUserId, - mockDeviceType + mockDeviceType, + mockInteractionData ); expect(result).toEqual([]); @@ -162,7 +167,13 @@ describe("segments lib", () => { test("should call validateInputs with correct parameters", async () => { vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]); - await getPersonSegmentIds(mockWorkspaceId, mockContactId, mockContactUserId, mockDeviceType); + await getPersonSegmentIds( + mockWorkspaceId, + mockContactId, + mockContactUserId, + mockDeviceType, + mockInteractionData + ); expect(validateInputs).toHaveBeenCalledWith( [mockWorkspaceId, expect.anything()], [mockContactId, expect.anything()], @@ -177,7 +188,8 @@ describe("segments lib", () => { mockWorkspaceId, mockContactId, mockContactUserId, - mockDeviceType + mockDeviceType, + mockInteractionData ); expect(result).toEqual([mockSegmentsData[0].id]); @@ -199,7 +211,8 @@ describe("segments lib", () => { mockWorkspaceId, mockContactId, mockContactUserId, - mockDeviceType + mockDeviceType, + mockInteractionData ); expect(result).toContain("segment-no-filter"); @@ -224,13 +237,87 @@ describe("segments lib", () => { mockWorkspaceId, mockContactId, mockContactUserId, - mockDeviceType + mockDeviceType, + mockInteractionData ); expect(result).toEqual(["segment1"]); expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); + const buildInteractionSegment = (id: string, operator: string) => ({ + id, + filters: [ + { + id: `filter-${id}`, + connector: null, + resource: { + id: `resource-${id}`, + root: { type: "surveyInteraction" }, + qualifier: { operator }, + value: { surveyScope: "any", surveyIds: [], within: { amount: 30, unit: "days" } }, + }, + }, + ], + }); + + test("should evaluate an interaction-only segment in memory without any DB query", async () => { + vi.mocked(prisma.segment.findMany).mockResolvedValue([ + buildInteractionSegment("interaction-segment", "haveSeen"), + ] as unknown as Prisma.Result); + + const result = await getPersonSegmentIds( + mockWorkspaceId, + mockContactId, + mockContactUserId, + mockDeviceType, + { displays: [{ surveyId: "survey-1", createdAt: new Date() }], responses: [] } + ); + + expect(result).toEqual(["interaction-segment"]); + expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + test("should exclude a non-matching interaction-only segment without any DB query", async () => { + vi.mocked(prisma.segment.findMany).mockResolvedValue([ + buildInteractionSegment("interaction-segment", "haveSeen"), + ] as unknown as Prisma.Result); + + const result = await getPersonSegmentIds( + mockWorkspaceId, + mockContactId, + mockContactUserId, + mockDeviceType, + { displays: [], responses: [] } + ); + + expect(result).toEqual([]); + expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + test("should evaluate interaction segments in memory and only query the DB for the rest", async () => { + vi.mocked(prisma.segment.findMany).mockResolvedValue([ + buildInteractionSegment("interaction-segment", "haveSeen"), + { id: "attribute-segment", filters: [{}] as TBaseFilter[] }, + ] as unknown as Prisma.Result); + vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]); + + const result = await getPersonSegmentIds( + mockWorkspaceId, + mockContactId, + mockContactUserId, + mockDeviceType, + { displays: [{ surveyId: "survey-1", createdAt: new Date() }], responses: [] } + ); + + expect(result).toEqual(["interaction-segment", "attribute-segment"]); + // Only the non-interaction segment is built into a Prisma query / checked in the transaction. + expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + test("should return empty array on unexpected error", async () => { vi.mocked(prisma.segment.findMany).mockRejectedValue(new Error("Unexpected")); @@ -238,7 +325,8 @@ describe("segments lib", () => { mockWorkspaceId, mockContactId, mockContactUserId, - mockDeviceType + mockDeviceType, + mockInteractionData ); expect(result).toEqual([]); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.ts b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.ts index 13a3ad384aef..a6923e83509f 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/segments.ts @@ -9,6 +9,10 @@ import { TBaseFilters } from "@formbricks/types/segment"; import { cache } from "@/lib/cache"; import { validateInputs } from "@/lib/utils/validate"; import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query"; +import { + type TContactInteractionData, + tryEvaluateSurveyInteractionSegmentInMemory, +} from "@/modules/ee/contacts/segments/lib/filter/survey-interaction"; export const getSegments = reactCache( async (workspaceId: string) => @@ -41,7 +45,8 @@ export const getPersonSegmentIds = async ( workspaceId: string, contactId: string, contactUserId: string, - deviceType: "phone" | "desktop" + deviceType: "phone" | "desktop", + interactionData: TContactInteractionData ): Promise => { try { validateInputs([workspaceId, ZId], [contactId, ZId], [contactUserId, ZString]); @@ -52,10 +57,18 @@ export const getPersonSegmentIds = async ( return []; } - // Phase 1: Build WHERE clauses sequentially to avoid connection pool contention. - // segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks), - // so running all builds concurrently would saturate the pool. + // A single evaluation instant shared across every segment so all relative windows agree. + const now = new Date(); + + // Phase 1: Classify each segment. + // - Empty filters → always matches. + // - Interaction-only segments → evaluated in memory against the contact's already-loaded + // displays/responses, avoiding a DB round trip entirely (the hot-path optimization). + // - Everything else → a Prisma membership check. Build the WHERE clauses sequentially to avoid + // connection pool contention: segmentFilterToPrismaQuery can itself hit the DB (e.g. + // unmigrated-row checks), so building all concurrently would saturate the pool. const alwaysMatchIds: string[] = []; + const inMemoryMatchIds: string[] = []; const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = []; for (const segment of segments) { @@ -66,6 +79,26 @@ export const getPersonSegmentIds = async ( continue; } + // Isolate per-segment failures like the DB path below: an unexpected throw here must not bubble to + // the outer catch and wipe out every already-computed segment. On failure, leave the result null + // so this segment falls through to the Prisma path instead of being silently dropped. + let inMemoryResult: boolean | null = null; + try { + inMemoryResult = tryEvaluateSurveyInteractionSegmentInMemory(filters, interactionData, now); + } catch (error) { + logger.warn( + { segmentId: segment.id, workspaceId, error }, + "Failed to evaluate segment in memory, falling back to DB query" + ); + } + + if (inMemoryResult !== null) { + if (inMemoryResult) { + inMemoryMatchIds.push(segment.id); + } + continue; + } + const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, workspaceId, deviceType); if (!queryResult.ok) { @@ -80,10 +113,10 @@ export const getPersonSegmentIds = async ( } if (dbChecks.length === 0) { - return alwaysMatchIds; + return [...alwaysMatchIds, ...inMemoryMatchIds]; } - // Phase 2: Execute all membership checks in a single transaction. + // Phase 2: Execute the remaining membership checks in a single transaction. // Uses one connection instead of N concurrent ones, eliminating pool contention. const txResults = await prisma.$transaction( dbChecks.map(({ whereClause }) => @@ -94,9 +127,9 @@ export const getPersonSegmentIds = async ( ) ); - const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId); + const dbMatchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId); - return [...alwaysMatchIds, ...matchedIds]; + return [...alwaysMatchIds, ...inMemoryMatchIds, ...dbMatchedIds]; } catch (error) { logger.warn({ workspaceId, contactId, error }, "Failed to get person segment IDs, returning empty array"); return []; diff --git a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.test.ts index 5b7890981323..ef7930bdd2fa 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.test.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.test.ts @@ -93,7 +93,7 @@ describe("updateUser", () => { }, }, responses: { - select: { surveyId: true }, + select: { surveyId: true, createdAt: true, finished: true }, }, displays: { select: { @@ -253,12 +253,18 @@ describe("updateUser", () => { test("should use device type 'phone'", async () => { vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData as any); await updateUser(mockWorkspaceId, mockUserId, "phone"); - expect(getPersonSegmentIds).toHaveBeenCalledWith(mockWorkspaceId, mockContactId, mockUserId, "phone"); + expect(getPersonSegmentIds).toHaveBeenCalledWith(mockWorkspaceId, mockContactId, mockUserId, "phone", { + displays: [], + responses: [], + }); }); test("should use device type 'desktop'", async () => { vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData as any); await updateUser(mockWorkspaceId, mockUserId, "desktop"); - expect(getPersonSegmentIds).toHaveBeenCalledWith(mockWorkspaceId, mockContactId, mockUserId, "desktop"); + expect(getPersonSegmentIds).toHaveBeenCalledWith(mockWorkspaceId, mockContactId, mockUserId, "desktop", { + displays: [], + responses: [], + }); }); }); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts index 9b4e65ddbbbd..d7c7510eb542 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts @@ -1,5 +1,6 @@ import { createCacheKey } from "@formbricks/cache"; import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; import { normalizeLanguageCode } from "@formbricks/i18n-utils"; import { TContactAttributesInput } from "@formbricks/types/contact-attribute"; import { TJsPersonState } from "@formbricks/types/js"; @@ -8,6 +9,31 @@ import { toLegacyLanguageCodes } from "@/lib/i18n/utils"; import { formatAttributeMessage, updateAttributes } from "@/modules/ee/contacts/lib/attributes"; import { getPersonSegmentIds } from "./segments"; +/** + * Shared select for the two contact-state branches (existing contact vs newly created). Kept in one + * place so both produce the identical shape that feeds buildUserStateFromContact and the in-memory + * survey-interaction evaluator. `createdAt`/`finished` on responses (and `createdAt` on displays) are + * consumed by that evaluator (see getPersonSegmentIds), which relies on the load being complete — do + * NOT add a `take` limit without also bounding it by the widest interaction window, or the in-memory + * and Prisma evaluators would diverge. + */ +const contactStateSelect = { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + responses: { + select: { surveyId: true, createdAt: true, finished: true }, + }, + displays: { + select: { surveyId: true, createdAt: true }, + orderBy: { createdAt: "desc" as const }, + }, +} satisfies Prisma.ContactSelect; + /** * Comprehensive contact data fetcher - gets everything needed in one query * Eliminates redundant queries by fetching contact + user state data together @@ -23,26 +49,7 @@ const getContactWithFullData = async (workspaceId: string, userId: string) => { }, }, }, - select: { - id: true, - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, - }, - // Include user state data in the same query - responses: { - select: { surveyId: true }, - }, - displays: { - select: { - surveyId: true, - createdAt: true, - }, - orderBy: { createdAt: "desc" }, - }, - }, + select: contactStateSelect, }); }; @@ -66,26 +73,7 @@ const createContact = async (workspaceId: string, userId: string) => { ], }, }, - select: { - id: true, - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, - }, - // Include empty arrays for new contacts - responses: { - select: { surveyId: true }, - }, - displays: { - select: { - surveyId: true, - createdAt: true, - }, - orderBy: { createdAt: "desc" }, - }, - }, + select: contactStateSelect, }); }; @@ -103,7 +91,12 @@ const buildUserStateFromContact = async ( // Ensure segments is always an array to prevent "segments is not iterable" error let segments: string[] = []; try { - segments = await getPersonSegmentIds(workspaceId, contactData.id, userId, device); + // The contact's displays/responses are already loaded here, so interaction-only segments can be + // evaluated in memory without any additional query (see getPersonSegmentIds). + segments = await getPersonSegmentIds(workspaceId, contactData.id, userId, device, { + displays: contactData.displays, + responses: contactData.responses, + }); // Double-check that segments is actually an array if (!Array.isArray(segments)) { segments = []; diff --git a/apps/web/modules/ee/contacts/page.tsx b/apps/web/modules/ee/contacts/page.tsx index c1191eda8a0d..a5885299582e 100644 --- a/apps/web/modules/ee/contacts/page.tsx +++ b/apps/web/modules/ee/contacts/page.tsx @@ -19,8 +19,8 @@ export const ContactsPage = async ({ params: paramsProps }: { params: Promise<{ const isQuotasAllowed = await getIsQuotasEnabled(organization.id); - const contactAttributeKeys = await getContactAttributeKeys(workspace.id); - const initialContacts = await getContacts(workspace.id, 0); + const contactAttributeKeys = isContactsEnabled ? await getContactAttributeKeys(workspace.id) : []; + const initialContacts = isContactsEnabled ? await getContacts(workspace.id, 0) : []; const AddContactsButton = ( diff --git a/apps/web/modules/ee/contacts/segments/actions.ts b/apps/web/modules/ee/contacts/segments/actions.ts index ef9b49243080..eddd8776cb3d 100644 --- a/apps/web/modules/ee/contacts/segments/actions.ts +++ b/apps/web/modules/ee/contacts/segments/actions.ts @@ -20,12 +20,16 @@ import { } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getDistinctAttributeValues } from "@/modules/ee/contacts/lib/contact-attributes"; -import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper"; +import { + assertSurveyInteractionSurveyIds, + checkForRecursiveSegmentFilter, +} from "@/modules/ee/contacts/segments/lib/helper"; import { cloneSegment, createSegment, deleteSegment, getSegment, + getSurveyRefsForWorkspace, getSurveyWorkspaceIdMap, resetSegmentInSurvey, updateSegment, @@ -88,6 +92,8 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen throw new InvalidInputError(errMsg); } + await assertSurveyInteractionSurveyIds(parsedFilters.data, workspaceId); + const segment = await createSegment(parsedInput); // Set the segmentId in the context to be used in the audit log @@ -161,6 +167,9 @@ export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdate } await checkForRecursiveSegmentFilter(parsedFilters.data, parsedInput.segmentId); + + const segmentWorkspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId); + await assertSurveyInteractionSurveyIds(parsedFilters.data, segmentWorkspaceId); } const oldObject = await getSegment(parsedInput.segmentId); @@ -353,3 +362,33 @@ export const getDistinctAttributeValuesAction = authenticatedActionClient return await getDistinctAttributeValues(parsedInput.attributeKeyId); }); + +const ZGetSurveysForSegmentFilterAction = z.object({ + workspaceId: ZId, +}); + +export const getSurveysForSegmentFilterAction = authenticatedActionClient + .inputSchema(ZGetSurveysForSegmentFilterAction) + .action(async ({ ctx, parsedInput }) => { + const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "workspaceTeam", + minPermission: "read", + workspaceId: parsedInput.workspaceId, + }, + ], + }); + + await checkAdvancedTargetingPermission(organizationId); + + return await getSurveyRefsForWorkspace(parsedInput.workspaceId); + }); diff --git a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx index 0917e4e16e1f..7f544da8bca0 100644 --- a/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx +++ b/apps/web/modules/ee/contacts/segments/components/add-filter-modal.tsx @@ -1,7 +1,13 @@ "use client"; import { createId } from "@paralleldrive/cuid2"; -import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "lucide-react"; +import { + FingerprintIcon, + MonitorSmartphoneIcon, + MousePointerClickIcon, + TagIcon, + Users2Icon, +} from "lucide-react"; import React, { type JSX, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; @@ -10,6 +16,7 @@ import type { TSegment, TSegmentAttributeFilter, TSegmentPersonFilter, + TSegmentSurveyInteractionFilter, } from "@formbricks/types/segment"; import { cn } from "@/lib/cn"; import { getContactAttributeDataTypeIcon } from "@/modules/ee/contacts/utils"; @@ -27,7 +34,7 @@ interface TAddFilterModalProps { segments: TSegment[]; } -type TFilterType = "attribute" | "segment" | "device" | "person"; +type TFilterType = "attribute" | "segment" | "device" | "person" | "surveyInteraction"; export const handleAddFilter = ({ type, @@ -144,6 +151,21 @@ export const handleAddFilter = ({ onAddFilter(newFilter); setOpen(false); } + + if (type === "surveyInteraction") { + // Created fully valid and pre-filled (operator "haveSeen", any survey, within 1 month) — the + // schema requires a valid operator, so an empty "Select…" intermediate state cannot be + // persisted. Matches how every other filter type is created pre-filled. + const newFilterResource: TSegmentSurveyInteractionFilter = { + id: createId(), + root: { type: "surveyInteraction" }, + qualifier: { operator: "haveSeen" }, + value: { surveyScope: "any", surveyIds: [], within: { amount: 1, unit: "months" } }, + }; + + onAddFilter({ id: createId(), connector: "and", resource: newFilterResource }); + setOpen(false); + } }; export function AddFilterModal({ @@ -228,6 +250,12 @@ export function AddFilterModal({ return devices.filter((deviceType) => deviceType.name.toLowerCase().includes(searchValue.toLowerCase())); }, [devices, searchValue]); + const surveyInteractionLabel = t("workspace.segments.survey_interaction"); + const showSurveyInteraction = useMemo(() => { + if (!searchValue) return true; + return surveyInteractionLabel.toLowerCase().includes(searchValue.toLowerCase()); + }, [searchValue, surveyInteractionLabel]); + const allFiltersFiltered = useMemo( () => [ { @@ -235,9 +263,16 @@ export function AddFilterModal({ contactAttributeFiltered, segments: segmentsFiltered, devices: deviceTypesFiltered, + surveyInteraction: showSurveyInteraction, }, ], - [contactAttributeKeysFiltered, deviceTypesFiltered, contactAttributeFiltered, segmentsFiltered] + [ + contactAttributeKeysFiltered, + deviceTypesFiltered, + contactAttributeFiltered, + segmentsFiltered, + showSurveyInteraction, + ] ); const getAllTabContent = () => { @@ -248,7 +283,8 @@ export function AddFilterModal({ filterArr.attributes.length === 0 && filterArr.segments.length === 0 && filterArr.devices.length === 0 && - filterArr.contactAttributeFiltered.length === 0 + filterArr.contactAttributeFiltered.length === 0 && + !filterArr.surveyInteraction ); }) ? (
@@ -318,6 +354,23 @@ export function AddFilterModal({ /> ))} + {filters.surveyInteraction ? ( + } + label={surveyInteractionLabel} + onClick={() => { + handleAddFilter({ type: "surveyInteraction", onAddFilter, setOpen }); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleAddFilter({ type: "surveyInteraction", onAddFilter, setOpen }); + } + }} + /> + ) : null} + {filters.segments.map((segment) => ( void; - handleAddFilterBelow: (resourceId: string, filter: TBaseFilter) => void; onCreateGroup: (filterId: string) => void; onDeleteFilter: (filterId: string) => void; onMoveFilter: (filterId: string, direction: "up" | "down") => void; viewOnly?: boolean; } -function SegmentFilterItemConnector({ +interface TSegmentFilterProps extends TBaseFilterProps { + segments: TSegment[]; + contactAttributeKeys: TContactAttributeKey[]; + handleAddFilterBelow: (resourceId: string, filter: TBaseFilter) => void; +} + +export function SegmentFilterItemConnector({ connector, segment, setSegment, filterId, viewOnly, -}: { +}: Readonly<{ connector: TSegmentConnector; segment: TSegment; setSegment: (segment: TSegment) => void; filterId: string; viewOnly?: boolean; -}) { +}>) { const { t } = useTranslation(); const updateLocalSurvey = (newConnector: TSegmentConnector) => { const updatedSegment = structuredClone(segment); @@ -131,21 +139,21 @@ function SegmentFilterItemConnector({ ); } -function SegmentFilterItemContextMenu({ +export function SegmentFilterItemContextMenu({ filterId, onAddFilterBelow, onCreateGroup, onDeleteFilter, onMoveFilter, viewOnly, -}: { +}: Readonly<{ filterId: string; onAddFilterBelow: () => void; onCreateGroup: (filterId: string) => void; onDeleteFilter: (filterId: string) => void; onMoveFilter: (filterId: string, direction: "up" | "down") => void; viewOnly?: boolean; -}) { +}>) { const { t } = useTranslation(); return (
@@ -201,7 +209,8 @@ function SegmentFilterItemContextMenu({ ); } -type TAttributeSegmentFilterProps = TSegmentFilterProps & { +type TAttributeSegmentFilterProps = TBaseFilterProps & { + contactAttributeKeys: TContactAttributeKey[]; onAddFilterBelow: () => void; resource: TSegmentAttributeFilter; updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void; @@ -218,7 +227,7 @@ function AttributeSegmentFilter({ setSegment, contactAttributeKeys, viewOnly, -}: TAttributeSegmentFilterProps) { +}: Readonly) { const { contactAttributeKey } = resource.root; const { t } = useTranslation(); const operatorText = convertOperatorToText(resource.qualifier.operator, t); @@ -386,7 +395,7 @@ function AttributeSegmentFilter({ }} value={attrKeyValue}>
@@ -443,7 +452,7 @@ function AttributeSegmentFilter({ ); } -type TPersonSegmentFilterProps = TSegmentFilterProps & { +type TPersonSegmentFilterProps = TBaseFilterProps & { onAddFilterBelow: () => void; resource: TSegmentPersonFilter; updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void; @@ -460,7 +469,7 @@ function PersonSegmentFilter({ segment, setSegment, viewOnly, -}: TPersonSegmentFilterProps) { +}: Readonly) { const { personIdentifier } = resource.root; const { t } = useTranslation(); const operatorText = convertOperatorToText(resource.qualifier.operator, t); @@ -553,7 +562,7 @@ function PersonSegmentFilter({ }} value={personIdentifier}>
@@ -623,7 +632,8 @@ function PersonSegmentFilter({ ); } -type TSegmentSegmentFilterProps = TSegmentFilterProps & { +type TSegmentSegmentFilterProps = TBaseFilterProps & { + segments: TSegment[]; onAddFilterBelow: () => void; resource: TSegmentSegmentFilter; }; @@ -638,7 +648,7 @@ function SegmentSegmentFilter({ segments, setSegment, viewOnly, -}: TSegmentSegmentFilterProps) { +}: Readonly) { const { segmentId } = resource.root; const { t } = useTranslation(); const operatorText = convertOperatorToText(resource.qualifier.operator, t); @@ -705,7 +715,7 @@ function SegmentSegmentFilter({ }} value={currentSegment?.id}>
@@ -736,7 +746,7 @@ function SegmentSegmentFilter({ ); } -type TDeviceFilterProps = TSegmentFilterProps & { +type TDeviceFilterProps = TBaseFilterProps & { onAddFilterBelow: () => void; resource: TSegmentDeviceFilter; }; @@ -750,7 +760,7 @@ function DeviceFilter({ segment, setSegment, viewOnly, -}: TDeviceFilterProps) { +}: Readonly) { const { value } = resource; const { t } = useTranslation(); const operatorText = convertOperatorToText(resource.qualifier.operator, t); @@ -862,7 +872,7 @@ export function SegmentFilter({ onDeleteFilter, onMoveFilter, viewOnly = false, -}: TSegmentFilterProps) { +}: Readonly) { const { t } = useTranslation(); const [addFilterModalOpen, setAddFilterModalOpen] = useState(false); const updateFilterValueInSegment = (filterId: string, newValue: TSegmentFilterValue) => { @@ -897,14 +907,12 @@ export function SegmentFilter({ + + {filterModal} + + ); + + case "surveyInteraction": + return ( + <> + diff --git a/apps/web/modules/ee/contacts/segments/components/survey-interaction-filter.tsx b/apps/web/modules/ee/contacts/segments/components/survey-interaction-filter.tsx new file mode 100644 index 000000000000..b11a12d76fc5 --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/components/survey-interaction-filter.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + SURVEY_INTERACTION_OPERATORS, + SURVEY_INTERACTION_TIME_UNITS, + type TSegmentSurveyInteractionFilter, + type TSegmentSurveyInteractionFilterValue, + type TSurveyInteractionOperator, + type TSurveyInteractionTimeUnit, +} from "@formbricks/types/segment"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; +import { + convertOperatorToText, + updateOperatorInFilter, + updateSurveyInteractionValueInFilter, +} from "@/modules/ee/contacts/segments/lib/utils"; +import { Input } from "@/modules/ui/components/input"; +import { MultiSelect } from "@/modules/ui/components/multi-select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; +import { getSurveysForSegmentFilterAction } from "../actions"; +import { + SegmentFilterItemConnector, + SegmentFilterItemContextMenu, + type TBaseFilterProps, +} from "./segment-filter"; + +type TSurveyInteractionFilterProps = TBaseFilterProps & { + onAddFilterBelow: () => void; + resource: TSegmentSurveyInteractionFilter; +}; + +export function SurveyInteractionFilter({ + connector, + onAddFilterBelow, + onCreateGroup, + onDeleteFilter, + onMoveFilter, + resource, + segment, + setSegment, + viewOnly, +}: Readonly) { + const { t } = useTranslation(); + // Pin to the concrete value type: the filter's `value` field is inferred from a refined Zod schema, + // which TypeScript does not treat as a plain (spreadable) object without this annotation. + const value: TSegmentSurveyInteractionFilterValue = resource.value; + const [surveys, setSurveys] = useState<{ id: string; name: string; status: string }[]>([]); + + // Load the workspace's surveys once so the "specific surveys" picker is ready the moment the user + // switches scope. Self-fetching here avoids prop-drilling surveys through the four SegmentEditor + // render sites. + useEffect(() => { + let active = true; + const loadSurveys = async () => { + try { + const result = await getSurveysForSegmentFilterAction({ workspaceId: segment.workspaceId }); + if (active && result?.data) { + setSurveys(result.data); + } + } catch (error) { + // Non-fatal: the picker just stays empty. Log so a failing action is observable rather than + // silently swallowed. + console.error("Failed to load surveys for segment filter", error); + } + }; + void loadSurveys(); + return () => { + active = false; + }; + }, [segment.workspaceId]); + + const commitValue = (newValue: TSegmentSurveyInteractionFilterValue) => { + const updatedSegment = structuredClone(segment); + if (updatedSegment.filters) { + updateSurveyInteractionValueInFilter(updatedSegment.filters, resource.id, newValue); + } + setSegment(updatedSegment); + }; + + const updateOperatorInSegment = (newOperator: TSurveyInteractionOperator) => { + const updatedSegment = structuredClone(segment); + if (updatedSegment.filters) { + updateOperatorInFilter(updatedSegment.filters, resource.id, newOperator); + } + setSegment(updatedSegment); + }; + + const operatorArr = SURVEY_INTERACTION_OPERATORS.map((operator) => ({ + id: operator, + name: convertOperatorToText(operator, t), + })); + + const getTimeUnitLabel = (unit: TSurveyInteractionTimeUnit, amount: number) => { + const isSingular = amount === 1; + switch (unit) { + case "days": + return isSingular ? t("workspace.segments.time_unit_day") : t("workspace.segments.time_unit_days"); + case "weeks": + return isSingular ? t("workspace.segments.time_unit_week") : t("workspace.segments.time_unit_weeks"); + case "months": + return isSingular + ? t("workspace.segments.time_unit_month") + : t("workspace.segments.time_unit_months"); + } + }; + + // Exclude the surveys this segment already gates (in the survey editor that's the survey being + // edited): targeting a segment by interaction with a survey it controls is circular. + const excludedSurveyIds = new Set(segment.surveys ?? []); + const surveyOptions = surveys + .filter((survey) => !excludedSurveyIds.has(survey.id)) + .map((survey) => ({ + // Name is the primary label (id beneath it as secondary); search matches both name and id. + value: survey.id, + label: survey.name || survey.id, + description: survey.id, + })); + + const handleAmountChange = (raw: string) => { + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) return; + const clamped = Math.min(999, Math.max(1, parsed)); + commitValue({ ...value, within: { ...value.within, amount: clamped } }); + }; + + return ( +
+
+ + + + + + +

{t("workspace.segments.within_last")}

+ + { + if (viewOnly) return; + handleAmountChange(e.target.value); + }} + step={1} + type="number" + value={value.within.amount} + /> + + + + +
+ + {value.surveyScope === "specific" ? ( +
+

{t("common.surveys")}

+ { + commitValue({ ...value, surveyIds: selected }); + }} + options={surveyOptions} + placeholder={t("common.select")} + value={value.surveyIds} + /> + {value.surveyIds.length === 0 ? ( +

{t("workspace.segments.select_at_least_one_survey")}

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/modules/ee/contacts/segments/components/targeting-card.tsx b/apps/web/modules/ee/contacts/segments/components/targeting-card.tsx index 903f3bb87379..7600c9734358 100644 --- a/apps/web/modules/ee/contacts/segments/components/targeting-card.tsx +++ b/apps/web/modules/ee/contacts/segments/components/targeting-card.tsx @@ -177,7 +177,7 @@ export function TargetingCard({ asChild className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
-
+
{ expect(result.ok).toBe(true); if (result.ok) { - expect(result.data.whereClause.AND?.[1]).toEqual({ - AND: [ - { - attributes: { - some: { - attributeKey: { key: "email" }, - value: { equals: "test@example.com", mode: "insensitive" }, - }, - }, + // AND-before-OR precedence: [email AND name] OR [age AND company]. + // The `or` connector on filter_3 opens a new AND group, so email+name form the first group and + // age+company the second — NOT a flat (email AND name AND company) AND age. + const emailClause = { + attributes: { + some: { + attributeKey: { key: "email" }, + value: { equals: "test@example.com", mode: "insensitive" }, }, - { - attributes: { - some: { - attributeKey: { key: "name" }, - value: { contains: "John", mode: "insensitive" }, - }, - }, + }, + }; + const nameClause = { + attributes: { + some: { + attributeKey: { key: "name" }, + value: { contains: "John", mode: "insensitive" }, }, - { - attributes: { - some: { - attributeKey: { - key: "company", - }, - }, - }, + }, + }; + const ageClause = { + attributes: { + some: { + attributeKey: { key: "age" }, + valueNumber: { gt: 30 }, }, - ], - OR: [ - { - attributes: { - some: { - attributeKey: { key: "age" }, - valueNumber: { gt: 30 }, - }, - }, + }, + }; + const companyClause = { + attributes: { + some: { + attributeKey: { key: "company" }, }, - ], + }, + }; + expect(result.data.whereClause.AND?.[1]).toEqual({ + OR: [{ AND: [emailClause, nameClause] }, { AND: [ageClause, companyClause] }], }); } }); @@ -752,9 +750,17 @@ describe("segmentFilterToPrismaQuery", () => { )?.[1] as any; expect(whereClause).toBeDefined(); - // First group (AND conditions) - const subgroup = whereClause.AND?.[0]; - expect(subgroup.AND[0].AND[0]).toStrictEqual({ + // Connectors on group_1's children are [null, and, or, or, and], so AND-before-OR precedence + // groups them as: (subgroup AND segment) OR (device) OR (person AND note). The device filter is + // built without a deviceType so it drops out, leaving two OR groups: + // (subgroup AND segment) OR (person AND note) + const groupClause = whereClause.AND[0]; + expect(groupClause.OR).toHaveLength(2); + + // First OR group: subgroup (isNotSet AND endsWith AND >=) AND the nested role segment. + const firstOrGroup = groupClause.OR[0]; + const subgroup = firstOrGroup.AND[0]; + expect(subgroup.AND[0]).toStrictEqual({ NOT: { attributes: { some: { @@ -763,8 +769,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - - expect(subgroup.AND[0].AND[1]).toStrictEqual({ + expect(subgroup.AND[1]).toStrictEqual({ attributes: { some: { attributeKey: { key: "email" }, @@ -772,8 +777,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - - expect(subgroup.AND[0].AND[2]).toStrictEqual({ + expect(subgroup.AND[2]).toStrictEqual({ attributes: { some: { attributeKey: { key: "age" }, @@ -782,8 +786,8 @@ describe("segmentFilterToPrismaQuery", () => { }, }); - // Segment inclusion - expect(whereClause.AND[0].AND[1].AND[0]).toStrictEqual({ + // Segment inclusion (nested role segment) is the second member of the first OR group. + expect(firstOrGroup.AND[1].AND[0]).toStrictEqual({ attributes: { some: { attributeKey: { key: "role" }, @@ -792,12 +796,9 @@ describe("segmentFilterToPrismaQuery", () => { }, }); - // Note: Device filters are evaluated at runtime (from User-Agent), not as database queries. - // When no deviceType is provided to segmentFilterToPrismaQuery, device filters return empty constraint. - // So we check the person filter which should be in OR[0] since device filter is skipped. - - // Person filter (OR condition) - device filter returns {} so person filter is at OR[0] - expect(whereClause.AND[0].OR[0]).toStrictEqual({ + // Second OR group: person AND note (device filter was skipped — evaluated at runtime, not in SQL). + const secondOrGroup = groupClause.OR[1]; + expect(secondOrGroup.AND[0]).toStrictEqual({ attributes: { some: { attributeKey: { key: "userId" }, @@ -805,9 +806,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - - // Empty string (AND condition) - expect(whereClause.AND[0].AND[2]).toStrictEqual({ + expect(secondOrGroup.AND[1]).toStrictEqual({ attributes: { some: { attributeKey: { key: "note" }, @@ -1152,9 +1151,16 @@ describe("segmentFilterToPrismaQuery", () => { if (result.ok) { const whereClause = (result.data.whereClause as Prisma.ContactWhereInput as any).AND?.[1]; - // First subgroup (text operators) - const firstSubgroup = whereClause.AND?.[0]; - expect(firstSubgroup.AND[0].AND).toContainEqual({ + // group_1's children connectors are [null, and, or], so AND-before-OR precedence yields: + // (subgroup_1 AND subgroup_2) OR subgroup_3 + const groupClause = whereClause.AND[0]; + expect(groupClause.OR).toHaveLength(2); + + // First OR group: subgroup_1 (text operators) AND subgroup_2 (numeric operators). + const firstOrGroup = groupClause.OR[0]; + + const firstSubgroup = firstOrGroup.AND[0]; + expect(firstSubgroup.AND).toContainEqual({ attributes: { some: { attributeKey: { key: "firstName" }, @@ -1162,7 +1168,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - expect(firstSubgroup.AND[0].AND).toContainEqual({ + expect(firstSubgroup.AND).toContainEqual({ attributes: { some: { attributeKey: { key: "lastName" }, @@ -1170,8 +1176,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - - expect(firstSubgroup.AND[0].AND).toContainEqual({ + expect(firstSubgroup.AND).toContainEqual({ attributes: { some: { attributeKey: { key: "title" }, @@ -1180,9 +1185,8 @@ describe("segmentFilterToPrismaQuery", () => { }, }); - // Second subgroup (numeric operators - uses clean Prisma filter post-backfill) - const secondSubgroup = whereClause.AND?.[0]; - expect(secondSubgroup.AND[1].AND).toContainEqual({ + const secondSubgroup = firstOrGroup.AND[1]; + expect(secondSubgroup.AND).toContainEqual({ attributes: { some: { attributeKey: { key: "loginCount" }, @@ -1190,7 +1194,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - expect(secondSubgroup.AND[1].AND).toContainEqual({ + expect(secondSubgroup.AND).toContainEqual({ attributes: { some: { attributeKey: { key: "purchaseAmount" }, @@ -1199,9 +1203,9 @@ describe("segmentFilterToPrismaQuery", () => { }, }); - // Third subgroup (negation operators in OR clause) - const thirdSubgroup = whereClause.AND?.[0]; - expect(thirdSubgroup.OR[0].OR).toContainEqual({ + // Second OR group: subgroup_3 (all-OR negation operators). + const thirdSubgroup = groupClause.OR[1]; + expect(thirdSubgroup.OR).toContainEqual({ NOT: { attributes: { some: { @@ -1210,8 +1214,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - - expect(thirdSubgroup.OR[0].OR).toContainEqual({ + expect(thirdSubgroup.OR).toContainEqual({ attributes: { some: { attributeKey: { key: "company" }, @@ -1219,8 +1222,7 @@ describe("segmentFilterToPrismaQuery", () => { }, }, }); - - expect(thirdSubgroup.OR[0].OR).toContainEqual({ + expect(thirdSubgroup.OR).toContainEqual({ attributes: { some: { attributeKey: { key: "interests" }, @@ -1787,3 +1789,123 @@ describe("segmentFilterToPrismaQuery", () => { }); }); }); + +describe("survey interaction filters", () => { + const mockSegmentId = "segment-si"; + const mockWorkspaceId = "workspace-si"; + // Fixed clock so the window cutoff is deterministic. + const now = new Date("2026-07-20T00:00:00.000Z"); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + const buildFilter = (operator: string, value: unknown): TBaseFilters => [ + { + id: "filter_si", + connector: null, + resource: { + id: "si_1", + root: { type: "surveyInteraction" as const }, + qualifier: { operator: operator as never }, + value: value as never, + }, + }, + ]; + + // Extract the single filter's where clause from the wrapped result. + const extractClause = (result: unknown) => { + const whereClause = (result as any).data.whereClause; + return whereClause.AND[1].AND[0]; + }; + + test("haveSeen with any scope maps to displays within the window", async () => { + const filters = buildFilter("haveSeen", { + surveyScope: "any", + surveyIds: [], + within: { amount: 1, unit: "months" }, + }); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockWorkspaceId); + const clause = extractClause(result); + + expect(clause.displays.some.createdAt.gte).toEqual(new Date("2026-06-20T00:00:00.000Z")); + expect(clause.displays.some.surveyId).toBeUndefined(); + }); + + test("haveSeen with specific scope constrains surveyId", async () => { + const filters = buildFilter("haveSeen", { + surveyScope: "specific", + surveyIds: ["survey_a", "survey_b"], + within: { amount: 2, unit: "weeks" }, + }); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockWorkspaceId); + const clause = extractClause(result); + + expect(clause.displays.some.surveyId).toEqual({ in: ["survey_a", "survey_b"] }); + // 2 weeks = 14 days before the fixed clock + expect(clause.displays.some.createdAt.gte).toEqual(new Date("2026-07-06T00:00:00.000Z")); + }); + + test("haveNotSeen wraps the displays clause in NOT", async () => { + const filters = buildFilter("haveNotSeen", { + surveyScope: "any", + surveyIds: [], + within: { amount: 1, unit: "days" }, + }); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockWorkspaceId); + const clause = extractClause(result); + + expect(clause.NOT.displays.some.createdAt.gte).toEqual(new Date("2026-07-19T00:00:00.000Z")); + }); + + test("haveStartedRespondingTo maps to responses without finished flag", async () => { + const filters = buildFilter("haveStartedRespondingTo", { + surveyScope: "any", + surveyIds: [], + within: { amount: 1, unit: "months" }, + }); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockWorkspaceId); + const clause = extractClause(result); + + expect(clause.responses.some.finished).toBeUndefined(); + expect(clause.responses.some.createdAt.gte).toEqual(new Date("2026-06-20T00:00:00.000Z")); + }); + + test("haveCompleted maps to responses with finished=true", async () => { + const filters = buildFilter("haveCompleted", { + surveyScope: "specific", + surveyIds: ["survey_a"], + within: { amount: 1, unit: "months" }, + }); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockWorkspaceId); + const clause = extractClause(result); + + expect(clause.responses.some.finished).toBe(true); + expect(clause.responses.some.surveyId).toEqual({ in: ["survey_a"] }); + }); + + test("haveNotCompleted wraps the completed clause in NOT", async () => { + const filters = buildFilter("haveNotCompleted", { + surveyScope: "any", + surveyIds: [], + within: { amount: 1, unit: "months" }, + }); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockWorkspaceId); + const clause = extractClause(result); + + expect(clause.NOT.responses.some.finished).toBe(true); + }); +}); diff --git a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts index 6ab054d46283..a450963d7740 100644 --- a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts +++ b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts @@ -14,11 +14,13 @@ import { TSegmentFilterValue, TSegmentPersonFilter, TSegmentSegmentFilter, + TSegmentSurveyInteractionFilter, ZRelativeDateValue, } from "@formbricks/types/segment"; import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils"; import { endOfDay, startOfDay, subtractTimeUnit } from "../date-utils"; import { getSegment } from "../segments"; +import { SURVEY_INTERACTION_SEMANTICS, getSurveyInteractionWindowStart } from "./survey-interaction"; // SQL operator mapping for number filters const SQL_OPERATORS: Record = { @@ -352,6 +354,35 @@ const buildDeviceFilterWhereClause = ( } }; +/** + * Builds a Prisma where clause from a survey interaction filter. + * "seen" maps to Display rows, "started responding to" maps to Response rows (any), + * "completed" maps to Response rows with finished=true. Negative operators wrap the + * positive clause in NOT, meaning "no matching interaction within the window". + * "any" scope omits the surveyId condition; "specific" scope constrains to the chosen ids. + * + * Operator semantics and the window boundary come from the shared spec in `survey-interaction.ts`, + * which the in-memory evaluator (contact-sync hot path) also uses — keeping the two paths identical. + */ +const buildSurveyInteractionFilterWhereClause = ( + filter: TSegmentSurveyInteractionFilter +): Prisma.ContactWhereInput => { + const { value } = filter; + const semantics = SURVEY_INTERACTION_SEMANTICS[filter.qualifier.operator]; + const windowStart = getSurveyInteractionWindowStart(value, new Date()); + + const some = { + ...(value.surveyScope === "specific" ? { surveyId: { in: value.surveyIds } } : {}), + ...(semantics.requireFinished ? { finished: true } : {}), + createdAt: { gte: windowStart }, + }; + + const relationClause: Prisma.ContactWhereInput = + semantics.source === "displays" ? { displays: { some } } : { responses: { some } }; + + return semantics.negate ? { NOT: relationClause } : relationClause; +}; + /** * Builds a Prisma where clause from a segment filter */ @@ -425,6 +456,8 @@ const processSingleFilter = async ( workspaceId, deviceType ); + case "surveyInteraction": + return buildSurveyInteractionFilterWhereClause(filter as TSegmentSurveyInteractionFilter); default: return {}; } @@ -441,41 +474,54 @@ const processFilters = async ( ): Promise => { if (filters.length === 0) return {}; - const query: { AND: Prisma.ContactWhereInput[]; OR: Prisma.ContactWhereInput[] } = { - AND: [], - OR: [], - }; + // AND-before-OR precedence, identical to the in-memory `combineFilterResults`: consecutive `and` + // connectors form one AND group, an `or` connector starts a new group, and the segment matches if + // ANY group matches. This produces `{ OR: [{ AND: [...] }, ...] }` rather than a flat + // `{ AND: [...], OR: [...] }` (which Prisma would AND together, giving `(A ∧ B) ∧ C` where the + // shared boolean evaluator gives `(A ∧ B) ∨ C`). Keeping both paths on the same precedence is the + // parity invariant survey-interaction filters rely on. + const andGroups: Prisma.ContactWhereInput[][] = []; + // `null` marks a started-but-still-empty group so an all-match-all group ("true") is not confused + // with "no group yet". An empty `{}` clause means match-all, which is the identity for AND, so it is + // dropped from its group; a group that ends up empty is therefore an unconditional match. + let currentGroup: Prisma.ContactWhereInput[] | null = null; for (let i = 0; i < filters.length; i++) { const { resource, connector } = filters[i]; - let whereClause: Prisma.ContactWhereInput; - - // Process the resource based on its type - if (isResourceFilter(resource)) { - // If it's a single filter, process it directly - whereClause = await processSingleFilter(resource, segmentPath, workspaceId, deviceType); - } else { - // If it's a group of filters, process it recursively - whereClause = await processFilters(resource, segmentPath, workspaceId, deviceType); + + const whereClause = isResourceFilter(resource) + ? await processSingleFilter(resource, segmentPath, workspaceId, deviceType) + : await processFilters(resource, segmentPath, workspaceId, deviceType); + + // The first filter's connector is `null` and always starts the first group; from then on an `or` + // connector opens a new AND group. + if (currentGroup === null || (i > 0 && connector === "or")) { + if (currentGroup !== null) andGroups.push(currentGroup); + currentGroup = []; } - if (Object.keys(whereClause).length === 0) continue; - if (filters.length === 1) query.AND = [whereClause]; - else { - if (i === 0) { - if (filters[1].connector === "and") query.AND.push(whereClause); - else query.OR.push(whereClause); - } else { - if (connector === "and") query.AND.push(whereClause); - else query.OR.push(whereClause); - } + if (Object.keys(whereClause).length > 0) { + currentGroup.push(whereClause); } } + if (currentGroup !== null) andGroups.push(currentGroup); - return { - ...(query.AND.length > 0 ? { AND: query.AND } : {}), - ...(query.OR.length > 0 ? { OR: query.OR } : {}), - }; + // Drop groups left empty after match-all clauses were skipped (e.g. a runtime device filter built + // without a deviceType, or a missing nested segment — non-constraining at build time). This mirrors + // the previous "skip empty clauses" behavior: an empty clause contributes nothing rather than + // collapsing an OR branch to match-all. Interaction filters always emit a concrete clause, so their + // OR-of-AND precedence is unaffected. + const nonEmptyGroups = andGroups.filter((group) => group.length > 0); + + if (nonEmptyGroups.length === 0) return {}; + + // Single AND group (no `or` connectors, or a single filter): keep the historical `{ AND: [...] }` + // shape so pure-AND / single-filter segments are byte-identical to before — only genuinely mixed + // AND/OR segments change shape (from the old flat `{ AND, OR }` to correct OR-of-AND groups). + if (nonEmptyGroups.length === 1) return { AND: nonEmptyGroups[0] }; + + const groupClauses = nonEmptyGroups.map((group) => (group.length === 1 ? group[0] : { AND: group })); + return { OR: groupClauses }; }; /** diff --git a/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.parity.test.ts b/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.parity.test.ts new file mode 100644 index 000000000000..25a601fdc7cd --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.parity.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + TBaseFilters, + TSegmentConnector, + TSurveyInteractionOperator, + TSurveyInteractionTimeUnit, +} from "@formbricks/types/segment"; +import { segmentFilterToPrismaQuery } from "./prisma-query"; +import { + type TContactInteractionData, + tryEvaluateSurveyInteractionSegmentInMemory, +} from "./survey-interaction"; + +// The two evaluation paths (in-memory hot path vs Prisma dashboard/preview) MUST return identical +// membership for the same segment — that is the core invariant of the feature. Unit tests elsewhere +// check each path in isolation; this suite feeds the SAME fixtures through BOTH and asserts they agree, +// including for mixed AND/OR connectors (the precedence bug fixed alongside this test). + +vi.mock("@formbricks/database", () => ({ + prisma: {}, +})); +vi.mock("../segments", () => ({ getSegment: vi.fn() })); +vi.mock("react", () => ({ cache: (fn: (...args: unknown[]) => unknown) => fn })); + +const WORKSPACE_ID = "workspace-parity"; +const SEGMENT_ID = "segment-parity"; +// Anchor to the real clock: the Prisma builder computes its window from `new Date()` internally, so the +// in-memory `now` must be the same instant (within ms) for the two paths to agree. Fixtures sit days +// away from every window edge, so the sub-second difference is irrelevant. +const NOW = new Date(); + +const daysAgo = (n: number): Date => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000); + +// ---- Minimal interpreter for the ContactWhereInput subset our builders emit ------------------------- + +interface FixtureContact { + displays: { surveyId: string; createdAt: Date }[]; + responses: { surveyId: string; createdAt: Date; finished: boolean }[]; +} + +type SomeClause = { + surveyId?: { in: string[] }; + finished?: boolean; + createdAt: { gte: Date }; +}; + +const rowMatchesSome = ( + row: { surveyId: string; createdAt: Date; finished?: boolean }, + some: SomeClause +): boolean => { + if (row.createdAt < some.createdAt.gte) return false; + if (some.surveyId && !some.surveyId.in.includes(row.surveyId)) return false; + if (some.finished !== undefined && row.finished !== some.finished) return false; + return true; +}; + +/** Evaluates the (interaction-only) Prisma where clause against an in-memory fixture contact. */ +const evalPrismaClause = (contact: FixtureContact, clause: Record): boolean => { + if (!clause || Object.keys(clause).length === 0) return true; // match-all + if (Array.isArray(clause.AND)) return clause.AND.every((c: any) => evalPrismaClause(contact, c)); + if (Array.isArray(clause.OR)) return clause.OR.some((c: any) => evalPrismaClause(contact, c)); + if (clause.NOT) return !evalPrismaClause(contact, clause.NOT); + if (clause.displays?.some) return contact.displays.some((d) => rowMatchesSome(d, clause.displays.some)); + if (clause.responses?.some) return contact.responses.some((r) => rowMatchesSome(r, clause.responses.some)); + throw new Error(`Unhandled clause in parity interpreter: ${JSON.stringify(clause)}`); +}; + +const prismaMembership = async (contact: FixtureContact, filters: TBaseFilters): Promise => { + const result = await segmentFilterToPrismaQuery(SEGMENT_ID, filters, WORKSPACE_ID); + if (!result.ok) throw new Error("segmentFilterToPrismaQuery failed"); + // The outer clause is { AND: [{ workspaceId }, filtersClause] }; fixtures are in-workspace, so we + // evaluate only the filters clause. + const filtersClause = result.data.whereClause.AND[1] as Record; + return evalPrismaClause(contact, filtersClause); +}; + +const inMemoryMembership = (contact: FixtureContact, filters: TBaseFilters): boolean => { + const data: TContactInteractionData = { displays: contact.displays, responses: contact.responses }; + const result = tryEvaluateSurveyInteractionSegmentInMemory(filters, data, NOW); + if (result === null) throw new Error("expected an interaction-only segment"); + return result; +}; + +// ---- Filter builders -------------------------------------------------------------------------------- + +let filterSeq = 0; +const interactionFilter = ( + operator: TSurveyInteractionOperator, + opts: { + scope?: "any" | "specific"; + surveyIds?: string[]; + amount?: number; + unit?: TSurveyInteractionTimeUnit; + connector?: TSegmentConnector; + } = {} +) => { + filterSeq += 1; + return { + id: `f_${filterSeq}`, + connector: opts.connector ?? null, + resource: { + id: `r_${filterSeq}`, + root: { type: "surveyInteraction" as const }, + qualifier: { operator }, + value: { + surveyScope: opts.scope ?? "any", + surveyIds: opts.surveyIds ?? [], + within: { amount: opts.amount ?? 1, unit: opts.unit ?? ("months" as const) }, + }, + }, + }; +}; + +// ---- Fixtures: a spread of contacts with recent/old, finished/unfinished, in/out-of-scope rows ------- + +const A = "srv0000000000000000000a"; +const B = "srv0000000000000000000b"; +const C = "srv0000000000000000000c"; + +const contacts: Record = { + none: { displays: [], responses: [] }, + seenARecent: { displays: [{ surveyId: A, createdAt: daysAgo(5) }], responses: [] }, + seenAOld: { displays: [{ surveyId: A, createdAt: daysAgo(400) }], responses: [] }, + startedBRecent: { displays: [], responses: [{ surveyId: B, createdAt: daysAgo(5), finished: false }] }, + completedCRecent: { displays: [], responses: [{ surveyId: C, createdAt: daysAgo(5), finished: true }] }, + mixed: { + displays: [ + { surveyId: A, createdAt: daysAgo(5) }, + { surveyId: B, createdAt: daysAgo(400) }, + ], + responses: [ + { surveyId: B, createdAt: daysAgo(2), finished: false }, + { surveyId: C, createdAt: daysAgo(2), finished: true }, + ], + }, +}; + +const OPERATORS: TSurveyInteractionOperator[] = [ + "haveSeen", + "haveNotSeen", + "haveStartedRespondingTo", + "haveCompleted", + "haveNotCompleted", +]; + +describe("survey-interaction eval-path parity (in-memory vs Prisma)", () => { + beforeEach(() => { + filterSeq = 0; + }); + + test.each(OPERATORS)("single filter, any scope — %s agrees across paths", async (operator) => { + const filters = [interactionFilter(operator)] as unknown as TBaseFilters; + for (const [name, contact] of Object.entries(contacts)) { + const inMem = inMemoryMembership(contact, filters); + const db = await prismaMembership(contact, filters); + expect(`${name}:${inMem}`).toBe(`${name}:${db}`); + } + }); + + test.each(OPERATORS)("single filter, specific scope [A,C] — %s agrees across paths", async (operator) => { + const filters = [ + interactionFilter(operator, { scope: "specific", surveyIds: [A, C] }), + ] as unknown as TBaseFilters; + for (const [name, contact] of Object.entries(contacts)) { + const inMem = inMemoryMembership(contact, filters); + const db = await prismaMembership(contact, filters); + expect(`${name}:${inMem}`).toBe(`${name}:${db}`); + } + }); + + test("mixed AND/OR connectors agree across paths (precedence parity)", async () => { + // (haveSeen A) AND (haveCompleted C) OR (haveStartedRespondingTo B) + // = (A∧C) ∨ B — the group boundary is the `or` connector, matching combineFilterResults. + const filters = [ + interactionFilter("haveSeen", { scope: "specific", surveyIds: [A] }), + interactionFilter("haveCompleted", { scope: "specific", surveyIds: [C], connector: "and" }), + interactionFilter("haveStartedRespondingTo", { scope: "specific", surveyIds: [B], connector: "or" }), + ] as unknown as TBaseFilters; + + for (const [name, contact] of Object.entries(contacts)) { + const inMem = inMemoryMembership(contact, filters); + const db = await prismaMembership(contact, filters); + expect(`${name}:${inMem}`).toBe(`${name}:${db}`); + } + }); + + test("negation with only out-of-window interactions matches in both paths", async () => { + // seenAOld saw A 400 days ago; within 1 month they have NOT seen it recently. + const filters = [ + interactionFilter("haveNotSeen", { scope: "specific", surveyIds: [A], amount: 1, unit: "months" }), + ] as unknown as TBaseFilters; + + const inMem = inMemoryMembership(contacts.seenAOld, filters); + const db = await prismaMembership(contacts.seenAOld, filters); + expect(inMem).toBe(true); + expect(db).toBe(true); + expect(inMem).toBe(db); + }); +}); diff --git a/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.test.ts b/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.test.ts new file mode 100644 index 000000000000..6db7388897e9 --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from "vitest"; +import { + TBaseFilters, + TSegmentSurveyInteractionFilter, + TSurveyInteractionOperator, +} from "@formbricks/types/segment"; +import { + type TContactInteractionData, + combineFilterResults, + evaluateSurveyInteractionFilterInMemory, + getSurveyInteractionWindowStart, + tryEvaluateSurveyInteractionSegmentInMemory, +} from "./survey-interaction"; + +// Fixed evaluation instant so window math is deterministic. +const NOW = new Date("2026-07-21T12:00:00.000Z"); +const daysAgo = (days: number): Date => new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000); + +const buildFilter = ( + operator: TSurveyInteractionOperator, + overrides: Partial = {} +): TSegmentSurveyInteractionFilter => ({ + id: "wq3n1x0m8p2v6t4r9y7k5c1a", + root: { type: "surveyInteraction" }, + qualifier: { operator }, + value: { + surveyScope: "any", + surveyIds: [], + within: { amount: 30, unit: "days" }, + ...overrides, + }, +}); + +const emptyData: TContactInteractionData = { displays: [], responses: [] }; + +describe("getSurveyInteractionWindowStart", () => { + test("subtracts the configured window from now", () => { + const value = buildFilter("haveSeen").value; + expect(getSurveyInteractionWindowStart(value, NOW)).toEqual(daysAgo(30)); + }); +}); + +describe("evaluateSurveyInteractionFilterInMemory", () => { + describe("haveSeen / haveNotSeen (displays)", () => { + test("haveSeen matches a display inside the window", () => { + const data: TContactInteractionData = { + displays: [{ surveyId: "s1", createdAt: daysAgo(5) }], + responses: [], + }; + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveSeen"), data, NOW)).toBe(true); + }); + + test("haveSeen ignores a display older than the window", () => { + const data: TContactInteractionData = { + displays: [{ surveyId: "s1", createdAt: daysAgo(31) }], + responses: [], + }; + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveSeen"), data, NOW)).toBe(false); + }); + + test("haveNotSeen is the negation of haveSeen", () => { + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveNotSeen"), emptyData, NOW)).toBe(true); + const data: TContactInteractionData = { + displays: [{ surveyId: "s1", createdAt: daysAgo(5) }], + responses: [], + }; + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveNotSeen"), data, NOW)).toBe(false); + }); + + test("does not count responses as 'seen'", () => { + const data: TContactInteractionData = { + displays: [], + responses: [{ surveyId: "s1", createdAt: daysAgo(1), finished: true }], + }; + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveSeen"), data, NOW)).toBe(false); + }); + }); + + describe("started / completed (responses)", () => { + const startedData: TContactInteractionData = { + displays: [], + responses: [{ surveyId: "s1", createdAt: daysAgo(2), finished: false }], + }; + + test("haveStartedRespondingTo matches an unfinished response", () => { + expect( + evaluateSurveyInteractionFilterInMemory(buildFilter("haveStartedRespondingTo"), startedData, NOW) + ).toBe(true); + }); + + test("haveCompleted requires finished=true", () => { + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveCompleted"), startedData, NOW)).toBe( + false + ); + const completedData: TContactInteractionData = { + displays: [], + responses: [{ surveyId: "s1", createdAt: daysAgo(2), finished: true }], + }; + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveCompleted"), completedData, NOW)).toBe( + true + ); + }); + + test("haveNotCompleted is the negation of haveCompleted", () => { + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveNotCompleted"), startedData, NOW)).toBe( + true + ); + }); + }); + + describe("survey scope", () => { + const data: TContactInteractionData = { + displays: [{ surveyId: "s1", createdAt: daysAgo(1) }], + responses: [], + }; + + test("specific scope matches only the listed surveys", () => { + expect( + evaluateSurveyInteractionFilterInMemory( + buildFilter("haveSeen", { surveyScope: "specific", surveyIds: ["s1"] }), + data, + NOW + ) + ).toBe(true); + expect( + evaluateSurveyInteractionFilterInMemory( + buildFilter("haveSeen", { surveyScope: "specific", surveyIds: ["other"] }), + data, + NOW + ) + ).toBe(false); + }); + }); + + test("window boundary is inclusive (createdAt exactly at windowStart matches)", () => { + const data: TContactInteractionData = { + displays: [{ surveyId: "s1", createdAt: daysAgo(30) }], + responses: [], + }; + expect(evaluateSurveyInteractionFilterInMemory(buildFilter("haveSeen"), data, NOW)).toBe(true); + }); +}); + +describe("combineFilterResults", () => { + test("empty input is false", () => { + expect(combineFilterResults([])).toBe(false); + }); + + test("single result is passed through", () => { + expect(combineFilterResults([{ result: true, connector: null }])).toBe(true); + expect(combineFilterResults([{ result: false, connector: null }])).toBe(false); + }); + + test("AND requires all in the group", () => { + expect( + combineFilterResults([ + { result: true, connector: null }, + { result: false, connector: "and" }, + ]) + ).toBe(false); + }); + + test("OR matches if any group matches", () => { + expect( + combineFilterResults([ + { result: false, connector: null }, + { result: true, connector: "or" }, + ]) + ).toBe(true); + }); + + test("AND binds tighter than OR", () => { + // false AND true OR true => (false && true) || true => true + expect( + combineFilterResults([ + { result: false, connector: null }, + { result: true, connector: "and" }, + { result: true, connector: "or" }, + ]) + ).toBe(true); + // true AND false OR false => (true && false) || false => false + expect( + combineFilterResults([ + { result: true, connector: null }, + { result: false, connector: "and" }, + { result: false, connector: "or" }, + ]) + ).toBe(false); + }); +}); + +describe("tryEvaluateSurveyInteractionSegmentInMemory", () => { + const asFilters = (resources: unknown[]): TBaseFilters => + resources.map((resource, i) => ({ + id: `f${i}`, + connector: i === 0 ? null : "and", + resource, + })) as TBaseFilters; + + test("returns null for empty filters (defers to always-match handling)", () => { + expect(tryEvaluateSurveyInteractionSegmentInMemory([], emptyData, NOW)).toBeNull(); + }); + + test("evaluates a pure interaction segment to a boolean", () => { + const data: TContactInteractionData = { + displays: [{ surveyId: "s1", createdAt: daysAgo(1) }], + responses: [], + }; + expect(tryEvaluateSurveyInteractionSegmentInMemory(asFilters([buildFilter("haveSeen")]), data, NOW)).toBe( + true + ); + }); + + test("returns null when any leaf is a non-interaction filter", () => { + const attributeFilter = { + id: "attr", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }; + const result = tryEvaluateSurveyInteractionSegmentInMemory( + asFilters([buildFilter("haveSeen"), attributeFilter]), + emptyData, + NOW + ); + expect(result).toBeNull(); + }); + + test("returns null for a malformed node (no root, not a group)", () => { + expect(tryEvaluateSurveyInteractionSegmentInMemory(asFilters([{}]), emptyData, NOW)).toBeNull(); + }); + + test("recurses into nested interaction-only groups", () => { + const data: TContactInteractionData = { + displays: [{ surveyId: "s1", createdAt: daysAgo(1) }], + responses: [], + }; + const nested = asFilters([buildFilter("haveSeen")]); + const filters = [{ id: "group", connector: null, resource: nested }] as TBaseFilters; + expect(tryEvaluateSurveyInteractionSegmentInMemory(filters, data, NOW)).toBe(true); + }); + + test("returns null when a nested group contains a non-interaction filter", () => { + const attributeFilter = { + id: "attr", + root: { type: "attribute", contactAttributeKey: "plan" }, + qualifier: { operator: "equals" }, + value: "pro", + }; + const nested = asFilters([attributeFilter]); + const filters = [{ id: "group", connector: null, resource: nested }] as TBaseFilters; + expect(tryEvaluateSurveyInteractionSegmentInMemory(filters, emptyData, NOW)).toBeNull(); + }); +}); diff --git a/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.ts b/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.ts new file mode 100644 index 000000000000..fff57d4a2ebb --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/lib/filter/survey-interaction.ts @@ -0,0 +1,193 @@ +import { + TBaseFilters, + TSegmentConnector, + TSegmentSurveyInteractionFilter, + TSegmentSurveyInteractionFilterValue, + TSurveyInteractionOperator, +} from "@formbricks/types/segment"; +import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils"; +import { subtractTimeUnit } from "../date-utils"; + +/** + * Survey-interaction targeting is evaluated two ways that MUST stay in lockstep: + * - in memory, against a contact's already-loaded displays/responses (the contact-sync hot path), and + * - as a Prisma `where` clause, against the whole workspace (the dashboard segment preview). + * + * This module is the single source of truth for the operator semantics and the time window so the two + * paths can never drift. The Prisma builder (see `prisma-query.ts`) consumes {@link SURVEY_INTERACTION_SEMANTICS} + * and {@link getSurveyInteractionWindowStart}; the in-memory path additionally uses the evaluators below. + */ + +/** The minimal display row shape needed to evaluate a "seen" interaction in memory. */ +export interface TContactDisplayRow { + surveyId: string; + createdAt: Date; +} + +/** The minimal response row shape needed to evaluate "started"/"completed" interactions in memory. */ +export interface TContactResponseRow { + surveyId: string; + createdAt: Date; + finished: boolean; +} + +/** + * A contact's interaction history. For the in-memory path to match the Prisma path exactly this MUST be + * complete for the widest filter window in play — i.e. loaded without a `take` limit (see + * `getContactWithFullData`). Truncating either relation would silently diverge the two evaluators. + */ +export interface TContactInteractionData { + displays: TContactDisplayRow[]; + responses: TContactResponseRow[]; +} + +/** + * Deleted / unknown surveys and negation, by design: a negative operator asserts the ABSENCE of a + * matching interaction within the window, so "have not seen survey X" matches a contact when no such + * interaction exists — including when X was deleted after the segment was saved (its displays/responses + * are gone, so the absence holds). This is intentional (absence = "not seen"); write-time validation + * (`assertSurveyInteractionSurveyIds`) still rejects surveys that are foreign/unknown at save time. + * Operators are validated against the workspace on save; runtime does not re-check existence (that would + * cost a query on the hot path — the whole point of the in-memory evaluator is to avoid one). + */ +/** How a single operator resolves to a presence check over the contact's interaction relations. */ +interface TSurveyInteractionSemantics { + /** Which relation the interaction lives on. */ + source: "displays" | "responses"; + /** Whether only completed (`finished`) responses count. Never set for display sources. */ + requireFinished: boolean; + /** Whether the operator asserts the ABSENCE of a matching interaction ("have not …"). */ + negate: boolean; +} + +export const SURVEY_INTERACTION_SEMANTICS: Record = { + haveSeen: { source: "displays", requireFinished: false, negate: false }, + haveNotSeen: { source: "displays", requireFinished: false, negate: true }, + haveStartedRespondingTo: { source: "responses", requireFinished: false, negate: false }, + haveCompleted: { source: "responses", requireFinished: true, negate: false }, + haveNotCompleted: { source: "responses", requireFinished: true, negate: true }, +}; + +/** + * Start of the interaction window: an interaction counts when its `createdAt` is at or after this + * instant. Centralized so the in-memory and Prisma paths compute an identical boundary. + */ +export const getSurveyInteractionWindowStart = ( + value: TSegmentSurveyInteractionFilterValue, + now: Date +): Date => subtractTimeUnit(now, value.within.amount, value.within.unit); + +const isSurveyInScope = (surveyId: string, value: TSegmentSurveyInteractionFilterValue): boolean => + value.surveyScope === "any" || value.surveyIds.includes(surveyId); + +/** + * Evaluates a single survey-interaction filter in memory. Mirrors + * `buildSurveyInteractionFilterWhereClause`: the same window boundary (`createdAt >= windowStart`), + * survey-scope test, and `finished` requirement, with the negative operators inverting the presence + * check — exactly as the Prisma path wraps its clause in `NOT`. + */ +export const evaluateSurveyInteractionFilterInMemory = ( + filter: TSegmentSurveyInteractionFilter, + data: TContactInteractionData, + now: Date +): boolean => { + const { value } = filter; + const semantics = SURVEY_INTERACTION_SEMANTICS[filter.qualifier.operator]; + const windowStart = getSurveyInteractionWindowStart(value, now); + + const hasMatch = + semantics.source === "displays" + ? data.displays.some( + (display) => display.createdAt >= windowStart && isSurveyInScope(display.surveyId, value) + ) + : data.responses.some( + (response) => + response.createdAt >= windowStart && + isSurveyInScope(response.surveyId, value) && + (!semantics.requireFinished || response.finished) + ); + + return semantics.negate ? !hasMatch : hasMatch; +}; + +interface TFilterResultPair { + result: boolean; + connector: TSegmentConnector; +} + +/** + * Combines per-filter boolean results using their connectors with AND-before-OR precedence: + * consecutive `and` connectors form a group, `or` starts a new group, and the segment matches if any + * group matches. The first pair's connector is always `null` (start of a group) and is ignored. + */ +export const combineFilterResults = (pairs: TFilterResultPair[]): boolean => { + if (pairs.length === 0) { + return false; + } + + const orGroupResults: boolean[] = []; + let currentAndGroupResult = pairs[0].result; + + for (let i = 1; i < pairs.length; i++) { + const { result, connector } = pairs[i]; + if (connector === "or") { + orGroupResults.push(currentAndGroupResult); + currentAndGroupResult = result; + } else { + // "and" (or a defensive null) folds into the current AND group + currentAndGroupResult = currentAndGroupResult && result; + } + } + orGroupResults.push(currentAndGroupResult); + + return orGroupResults.some(Boolean); +}; + +/** + * Attempts to evaluate an entire segment filter tree in memory using only the contact's already-loaded + * interaction data — the case that needs no database round trip. + * + * Returns the membership boolean when EVERY leaf is a survey-interaction filter, or `null` when any leaf + * requires data not present in memory (attribute / device / person / nested-segment reference), signaling + * the caller to fall back to the Prisma path. All-or-nothing by design: a segment can only skip the DB + * when it is fully answerable from the loaded interaction data. + */ +export const tryEvaluateSurveyInteractionSegmentInMemory = ( + filters: TBaseFilters, + data: TContactInteractionData, + now: Date +): boolean | null => { + // An empty group is not an interaction-only segment; let the caller apply its always-match semantics. + if (filters.length === 0) { + return null; + } + + const resultPairs: TFilterResultPair[] = []; + + for (const { resource, connector } of filters) { + let result: boolean; + + if (Array.isArray(resource)) { + // Nested group: only in-memory-evaluable if every descendant is too. + const nestedResult = tryEvaluateSurveyInteractionSegmentInMemory(resource, data, now); + if (nestedResult === null) { + return null; + } + result = nestedResult; + } else if (resource && isResourceFilter(resource) && resource.root.type === "surveyInteraction") { + result = evaluateSurveyInteractionFilterInMemory( + resource as TSegmentSurveyInteractionFilter, + data, + now + ); + } else { + // A non-interaction leaf (attribute / device / person / segment) or a malformed node — this + // segment needs the database path. + return null; + } + + resultPairs.push({ result, connector }); + } + + return combineFilterResults(resultPairs); +}; diff --git a/apps/web/modules/ee/contacts/segments/lib/helper.test.ts b/apps/web/modules/ee/contacts/segments/lib/helper.test.ts index 481b8a4f63af..be6357599f39 100644 --- a/apps/web/modules/ee/contacts/segments/lib/helper.test.ts +++ b/apps/web/modules/ee/contacts/segments/lib/helper.test.ts @@ -1,9 +1,23 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { InvalidInputError } from "@formbricks/types/errors"; import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment"; -import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper"; +import { + assertSurveyInteractionSurveyIds, + checkForRecursiveSegmentFilter, + collectSurveyIdsFromSegmentFilters, +} from "@/modules/ee/contacts/segments/lib/helper"; import { getSegment } from "@/modules/ee/contacts/segments/lib/segments"; +const mockSurveyFindMany = vi.fn(); + +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + findMany: (...args: unknown[]) => mockSurveyFindMany(...args), + }, + }, +})); + // Mock dependencies vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({ getSegment: vi.fn(), @@ -211,3 +225,115 @@ describe("checkForRecursiveSegmentFilter", () => { expect(getSegment).toHaveBeenCalledTimes(2); }); }); + +describe("collectSurveyIdsFromSegmentFilters", () => { + test("collects ids from specific-scope survey interaction filters, including nested groups", () => { + const filters = [ + { + id: "f1", + connector: null, + resource: { + id: "si1", + root: { type: "surveyInteraction" }, + qualifier: { operator: "haveSeen" }, + value: { surveyScope: "specific", surveyIds: ["s1", "s2"], within: { amount: 1, unit: "months" } }, + }, + }, + { + id: "group1", + connector: "and", + resource: [ + { + id: "f2", + connector: null, + resource: { + id: "si2", + root: { type: "surveyInteraction" }, + qualifier: { operator: "haveCompleted" }, + value: { surveyScope: "specific", surveyIds: ["s3"], within: { amount: 1, unit: "months" } }, + }, + }, + ], + }, + ]; + + expect(collectSurveyIdsFromSegmentFilters(filters as unknown as TBaseFilters)).toEqual([ + "s1", + "s2", + "s3", + ]); + }); + + test("ignores any-scope survey interaction filters and other filter types", () => { + const filters = [ + { + id: "f1", + connector: null, + resource: { + id: "si1", + root: { type: "surveyInteraction" }, + qualifier: { operator: "haveSeen" }, + value: { surveyScope: "any", surveyIds: [], within: { amount: 1, unit: "months" } }, + }, + }, + { + id: "f2", + connector: "and", + resource: { + id: "attr1", + root: { type: "attribute", contactAttributeKey: "email" }, + qualifier: { operator: "equals" }, + value: "a@b.com", + }, + }, + ]; + + expect(collectSurveyIdsFromSegmentFilters(filters as unknown as TBaseFilters)).toEqual([]); + }); +}); + +describe("assertSurveyInteractionSurveyIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const buildSpecificFilter = (surveyIds: string[]): TBaseFilters => + [ + { + id: "f1", + connector: null, + resource: { + id: "si1", + root: { type: "surveyInteraction" }, + qualifier: { operator: "haveSeen" }, + value: { surveyScope: "specific", surveyIds, within: { amount: 1, unit: "months" } }, + }, + }, + ] as unknown as TBaseFilters; + + test("skips the DB lookup when there are no specific survey ids", async () => { + await assertSurveyInteractionSurveyIds(buildSpecificFilter([]), "workspace-1"); + expect(mockSurveyFindMany).not.toHaveBeenCalled(); + }); + + test("passes when every referenced survey belongs to the workspace", async () => { + mockSurveyFindMany.mockResolvedValue([{ id: "s1" }, { id: "s2" }]); + + await expect( + assertSurveyInteractionSurveyIds(buildSpecificFilter(["s1", "s2"]), "workspace-1") + ).resolves.toBeUndefined(); + + expect(mockSurveyFindMany).toHaveBeenCalledWith({ + where: { id: { in: ["s1", "s2"] }, workspaceId: "workspace-1" }, + select: { id: true }, + }); + }); + + test("throws when a referenced survey is missing from the workspace", async () => { + mockSurveyFindMany.mockResolvedValue([{ id: "s1" }]); + + await expect( + assertSurveyInteractionSurveyIds(buildSpecificFilter(["s1", "s2"]), "workspace-1") + ).rejects.toThrow(new InvalidInputError("Survey not found in workspace: s2")); + }); +}); diff --git a/apps/web/modules/ee/contacts/segments/lib/helper.ts b/apps/web/modules/ee/contacts/segments/lib/helper.ts index f31b71a10649..94852f15a6dc 100644 --- a/apps/web/modules/ee/contacts/segments/lib/helper.ts +++ b/apps/web/modules/ee/contacts/segments/lib/helper.ts @@ -1,5 +1,6 @@ +import { prisma } from "@formbricks/database"; import { InvalidInputError } from "@formbricks/types/errors"; -import { TBaseFilters } from "@formbricks/types/segment"; +import { TBaseFilters, TSegmentSurveyInteractionFilter } from "@formbricks/types/segment"; import { getSegment } from "@/modules/ee/contacts/segments/lib/segments"; import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils"; @@ -36,3 +37,54 @@ export const checkForRecursiveSegmentFilter = async (filters: TBaseFilters, segm } } }; + +/** + * Collects all surveyIds referenced by "specific" scope survey-interaction filters in the (nested) + * filter tree. Filters scoped to "any" survey contribute no ids. + */ +export const collectSurveyIdsFromSegmentFilters = (filters: TBaseFilters): string[] => { + const surveyIds: string[] = []; + + for (const filter of filters) { + const { resource } = filter; + if (isResourceFilter(resource)) { + if (resource.root.type === "surveyInteraction") { + const { value } = resource as TSegmentSurveyInteractionFilter; + if (value.surveyScope === "specific") { + surveyIds.push(...value.surveyIds); + } + } + } else { + surveyIds.push(...collectSurveyIdsFromSegmentFilters(resource)); + } + } + + return surveyIds; +}; + +/** + * Ensures every survey referenced by a "specific" survey-interaction filter belongs to the given + * workspace. This is the tenancy guard for interaction filters — the runtime evaluation query is + * already workspace-scoped, but we reject unknown/foreign ids at write time to avoid persisting + * dead references. + * @throws {InvalidInputError} When a referenced survey is not found in the workspace + */ +export const assertSurveyInteractionSurveyIds = async (filters: TBaseFilters, workspaceId: string) => { + const surveyIds = Array.from(new Set(collectSurveyIdsFromSegmentFilters(filters))); + + if (surveyIds.length === 0) { + return; + } + + const foundSurveys = await prisma.survey.findMany({ + where: { id: { in: surveyIds }, workspaceId }, + select: { id: true }, + }); + + const foundIds = new Set(foundSurveys.map((survey) => survey.id)); + const missingId = surveyIds.find((id) => !foundIds.has(id)); + + if (missingId) { + throw new InvalidInputError(`Survey not found in workspace: ${missingId}`); + } +}; diff --git a/apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts b/apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts index 095b04ab720f..29b4c6a281ee 100644 --- a/apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts +++ b/apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts @@ -1,6 +1,27 @@ import { createId } from "@paralleldrive/cuid2"; import { describe, expect, test } from "vitest"; -import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment"; +import { + type TBaseFilters, + type TSurveyInteractionOperator, + ZSegmentCreateInput, + ZSegmentFilters, + ZSegmentSurveyInteractionFilterValue, + ZSegmentUpdateInput, + buildSurveyInteractionRefreshMap, +} from "@formbricks/types/segment"; + +const surveyInteractionFilter = (value: unknown) => [ + { + id: createId(), + connector: null, + resource: { + id: createId(), + root: { type: "surveyInteraction" as const }, + qualifier: { operator: "haveSeen" as const }, + value, + }, + }, +]; const validFilters = [ { @@ -71,3 +92,244 @@ describe("segment schema validation", () => { expect(result.success).toBe(true); }); }); + +describe("survey interaction filter value validation", () => { + test("accepts any-survey scope with empty surveyIds", () => { + const result = ZSegmentSurveyInteractionFilterValue.safeParse({ + surveyScope: "any", + surveyIds: [], + within: { amount: 1, unit: "months" }, + }); + + expect(result.success).toBe(true); + }); + + test("accepts specific scope with at least one survey", () => { + const result = ZSegmentSurveyInteractionFilterValue.safeParse({ + surveyScope: "specific", + surveyIds: [createId()], + within: { amount: 3, unit: "weeks" }, + }); + + expect(result.success).toBe(true); + }); + + test("rejects a surveyId that is not a valid id", () => { + const result = ZSegmentSurveyInteractionFilterValue.safeParse({ + surveyScope: "specific", + surveyIds: ["not-a-valid-id"], + within: { amount: 1, unit: "months" }, + }); + + expect(result.success).toBe(false); + }); + + test("rejects specific scope with empty surveyIds", () => { + const result = ZSegmentSurveyInteractionFilterValue.safeParse({ + surveyScope: "specific", + surveyIds: [], + within: { amount: 1, unit: "months" }, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("Select at least one survey"); + }); + + test.each([ + { description: "below 1", amount: 0 }, + { description: "above 999", amount: 1000 }, + { description: "non-integer", amount: 2.5 }, + ])("rejects amount $description", ({ amount }) => { + const result = ZSegmentSurveyInteractionFilterValue.safeParse({ + surveyScope: "any", + surveyIds: [], + within: { amount, unit: "days" }, + }); + + expect(result.success).toBe(false); + }); + + test("rejects unsupported time unit", () => { + const result = ZSegmentSurveyInteractionFilterValue.safeParse({ + surveyScope: "any", + surveyIds: [], + within: { amount: 1, unit: "years" }, + }); + + expect(result.success).toBe(false); + }); + + test("accepts a full survey interaction filter through ZSegmentFilters", () => { + const result = ZSegmentFilters.safeParse( + surveyInteractionFilter({ + surveyScope: "specific", + surveyIds: [createId(), createId()], + within: { amount: 6, unit: "months" }, + }) + ); + + expect(result.success).toBe(true); + }); + + test("rejects a survey interaction filter with an invalid value through ZSegmentFilters", () => { + const result = ZSegmentFilters.safeParse( + surveyInteractionFilter({ + surveyScope: "specific", + surveyIds: [], + within: { amount: 1, unit: "months" }, + }) + ); + + expect(result.success).toBe(false); + }); +}); + +describe("buildSurveyInteractionRefreshMap", () => { + const interactionLeaf = ( + operator: TSurveyInteractionOperator, + surveyScope: "any" | "specific", + surveyIds: string[] = [] + ) => ({ + id: createId(), + connector: null as const, + resource: { + id: createId(), + root: { type: "surveyInteraction" as const }, + qualifier: { operator }, + value: { surveyScope, surveyIds, within: { amount: 1, unit: "months" as const } }, + }, + }); + + const segmentRefLeaf = (segmentId: string) => ({ + id: createId(), + connector: null as const, + resource: { + id: createId(), + root: { type: "segment" as const, segmentId }, + qualifier: { operator: "userIsIn" as const }, + value: segmentId, + }, + }); + + const noResolver = () => undefined; + + test("no interaction filters -> all-false map, hasAny false", () => { + const { refreshBySurveyId, hasAny } = buildSurveyInteractionRefreshMap( + [{ id: "A", segmentFilters: null }], + noResolver + ); + expect(hasAny).toBe(false); + expect(refreshBySurveyId.A).toEqual({ onDisplay: false, onResponse: false, onFinished: false }); + }); + + test("maps each operator to the right source on the referenced (target) survey", () => { + // B's segment references A across all three interaction kinds; the bit lands on A, not B. + const surveys = [ + { id: "A", segmentFilters: null }, + { + id: "B", + segmentFilters: [ + interactionLeaf("haveSeen", "specific", ["A"]), + interactionLeaf("haveStartedRespondingTo", "specific", ["A"]), + interactionLeaf("haveCompleted", "specific", ["A"]), + ] as unknown as TBaseFilters, + }, + ]; + const { refreshBySurveyId, hasAny } = buildSurveyInteractionRefreshMap(surveys, noResolver); + expect(hasAny).toBe(true); + expect(refreshBySurveyId.A).toEqual({ onDisplay: true, onResponse: true, onFinished: true }); + expect(refreshBySurveyId.B).toEqual({ onDisplay: false, onResponse: false, onFinished: false }); + }); + + test("the seen-only example: B refers to A via 'have seen' -> A.onDisplay only, B untouched", () => { + const surveys = [ + { id: "A", segmentFilters: null }, + { + id: "B", + segmentFilters: [interactionLeaf("haveSeen", "specific", ["A"])] as unknown as TBaseFilters, + }, + ]; + const { refreshBySurveyId } = buildSurveyInteractionRefreshMap(surveys, noResolver); + expect(refreshBySurveyId.A).toEqual({ onDisplay: true, onResponse: false, onFinished: false }); + expect(refreshBySurveyId.B).toEqual({ onDisplay: false, onResponse: false, onFinished: false }); + }); + + test("negative operators map to the same source as their positive counterpart", () => { + const surveys = [ + { id: "A", segmentFilters: null }, + { + id: "B", + segmentFilters: [ + interactionLeaf("haveNotSeen", "specific", ["A"]), + interactionLeaf("haveNotCompleted", "specific", ["A"]), + ] as unknown as TBaseFilters, + }, + ]; + const { refreshBySurveyId } = buildSurveyInteractionRefreshMap(surveys, noResolver); + expect(refreshBySurveyId.A).toEqual({ onDisplay: true, onResponse: false, onFinished: true }); + }); + + test("'any' scope sets the source bit on every delivered survey", () => { + const surveys = [ + { id: "A", segmentFilters: [interactionLeaf("haveSeen", "any")] as unknown as TBaseFilters }, + { id: "B", segmentFilters: null }, + { id: "C", segmentFilters: null }, + ]; + const { refreshBySurveyId } = buildSurveyInteractionRefreshMap(surveys, noResolver); + for (const id of ["A", "B", "C"]) { + expect(refreshBySurveyId[id].onDisplay).toBe(true); + } + }); + + test("a target outside the delivered set is ignored (no bit, hasAny stays false)", () => { + const surveys = [ + { + id: "A", + segmentFilters: [ + interactionLeaf("haveSeen", "specific", ["not-delivered"]), + ] as unknown as TBaseFilters, + }, + ]; + const { refreshBySurveyId, hasAny } = buildSurveyInteractionRefreshMap(surveys, noResolver); + expect(hasAny).toBe(false); + expect(refreshBySurveyId.A.onDisplay).toBe(false); + }); + + test("resolves an interaction filter hidden inside a nested userIsIn segment", () => { + // A's segment only references segment "seg1" (userIsIn); seg1 holds the interaction leaf on B. + const surveys = [ + { id: "A", segmentFilters: [segmentRefLeaf("seg1")] as unknown as TBaseFilters }, + { id: "B", segmentFilters: null }, + ]; + const resolve = (segmentId: string) => + segmentId === "seg1" + ? ([interactionLeaf("haveCompleted", "specific", ["B"])] as unknown as TBaseFilters) + : undefined; + const { refreshBySurveyId, hasAny } = buildSurveyInteractionRefreshMap(surveys, resolve); + expect(hasAny).toBe(true); + expect(refreshBySurveyId.B.onFinished).toBe(true); + }); + + test("terminates on a segment-reference cycle", () => { + const surveys = [{ id: "A", segmentFilters: [segmentRefLeaf("seg1")] as unknown as TBaseFilters }]; + // seg1 -> seg2 -> seg1 (cycle), with an interaction leaf on A tucked into seg2. + const resolve = (segmentId: string) => { + if (segmentId === "seg1") return [segmentRefLeaf("seg2")] as unknown as TBaseFilters; + if (segmentId === "seg2") + return [ + segmentRefLeaf("seg1"), + interactionLeaf("haveSeen", "specific", ["A"]), + ] as unknown as TBaseFilters; + return undefined; + }; + const { refreshBySurveyId, hasAny } = buildSurveyInteractionRefreshMap(surveys, resolve); + expect(hasAny).toBe(true); + expect(refreshBySurveyId.A.onDisplay).toBe(true); + }); + + test("unresolved (deleted / foreign) nested segment ref is skipped", () => { + const surveys = [{ id: "A", segmentFilters: [segmentRefLeaf("missing")] as unknown as TBaseFilters }]; + const { hasAny } = buildSurveyInteractionRefreshMap(surveys, noResolver); + expect(hasAny).toBe(false); + }); +}); diff --git a/apps/web/modules/ee/contacts/segments/lib/segments.test.ts b/apps/web/modules/ee/contacts/segments/lib/segments.test.ts index 97c11ce7b93c..9d7727966535 100644 --- a/apps/web/modules/ee/contacts/segments/lib/segments.test.ts +++ b/apps/web/modules/ee/contacts/segments/lib/segments.test.ts @@ -19,6 +19,7 @@ import { createSegment, deleteSegment, evaluateSegment, + getExistingWorkspaceSurveyIds, getSegment, getSegments, getSegmentsByAttributeKey, @@ -199,6 +200,27 @@ describe("Segment Service Tests", () => { }); }); + describe("getExistingWorkspaceSurveyIds", () => { + test("returns only the referenced ids that belong to the workspace, scoped by workspaceId", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([{ id: "survey_known" }] as any); + + const result = await getExistingWorkspaceSurveyIds("ws_1", ["survey_known", "survey_foreign"]); + + expect(result).toEqual(new Set(["survey_known"])); + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { workspaceId: "ws_1", id: { in: ["survey_known", "survey_foreign"] } }, + select: { id: true }, + }); + }); + + test("short-circuits to an empty set without querying when no ids are given", async () => { + const result = await getExistingWorkspaceSurveyIds("ws_1", []); + + expect(result.size).toBe(0); + expect(prisma.survey.findMany).not.toHaveBeenCalled(); + }); + }); + describe("createSegment", () => { test("should create a segment without surveyId", async () => { vi.mocked(prisma.segment.create).mockResolvedValue(mockSegmentPrisma); diff --git a/apps/web/modules/ee/contacts/segments/lib/segments.ts b/apps/web/modules/ee/contacts/segments/lib/segments.ts index 4b2631e3d196..0e7c9224bd41 100644 --- a/apps/web/modules/ee/contacts/segments/lib/segments.ts +++ b/apps/web/modules/ee/contacts/segments/lib/segments.ts @@ -1,4 +1,5 @@ import { cache as reactCache } from "react"; +import { z } from "zod"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; @@ -24,6 +25,7 @@ import { TSegmentFilterValue, TSegmentPersonFilter, TSegmentSegmentFilter, + TSegmentSurveyInteractionFilter, TSegmentUpdateInput, TSegmentWithSurveyRefs, ZRelativeDateValue, @@ -35,6 +37,7 @@ import { getSurvey } from "@/lib/survey/service"; import { validateInputs } from "@/lib/utils/validate"; import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils"; import { isSameDay, subtractTimeUnit } from "./date-utils"; +import { combineFilterResults, evaluateSurveyInteractionFilterInMemory } from "./filter/survey-interaction"; export type PrismaSegment = Prisma.SegmentGetPayload<{ include: { @@ -131,6 +134,70 @@ export const getSegments = reactCache(async (workspaceId: string): Promise => { + validateInputs([workspaceId, ZId]); + try { + return await prisma.survey.findMany({ + where: { workspaceId }, + select: { id: true, name: true, status: true }, + orderBy: { updatedAt: "desc" }, + take: SURVEY_FILTER_REF_LIMIT, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +/** + * Returns the subset of `surveyIds` that actually belong to `workspaceId`. Unlike + * {@link getSurveyRefsForWorkspace} (which caps at {@link SURVEY_FILTER_REF_LIMIT} for the picker), + * this queries by the exact ids being validated, so it stays correct for workspaces with more than + * that many surveys — a referenced-but-valid survey outside the picker's recent window is no longer + * wrongly rejected. Returns an empty set for an empty input without hitting the DB. + */ +export const getExistingWorkspaceSurveyIds = reactCache( + async (workspaceId: string, surveyIds: string[]): Promise> => { + validateInputs([workspaceId, ZId], [surveyIds, z.array(ZId)]); + if (surveyIds.length === 0) return new Set(); + try { + const surveys = await prisma.survey.findMany({ + where: { workspaceId, id: { in: surveyIds } }, + select: { id: true }, + }); + return new Set(surveys.map((survey) => survey.id)); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); + export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Promise => { validateInputs([segmentCreateInput, ZSegmentCreateInput]); @@ -629,6 +696,7 @@ export const evaluateSegment = async ( } let resultPairs: ResultConnectorPair[] = []; + const now = new Date(); try { for (let filterItem of filters) { @@ -637,82 +705,57 @@ export const evaluateSegment = async ( let result: boolean; if (isResourceFilter(resource)) { - const { root } = resource; - const { type } = root; - - if (type === "attribute") { - result = evaluateAttributeFilter(userData.attributes, resource as TSegmentAttributeFilter); - resultPairs.push({ - result, - connector: filterItem.connector, - }); - } - - if (type === "person") { - result = evaluatePersonFilter(userData.userId, resource as TSegmentPersonFilter); - resultPairs.push({ - result, - connector: filterItem.connector, - }); - } - - if (type === "segment") { - result = await evaluateSegmentFilter(userData, resource as TSegmentSegmentFilter); - resultPairs.push({ - result, - connector: filterItem.connector, - }); - } - - if (type === "device") { - result = evaluateDeviceFilter(userData.deviceType, resource as TSegmentDeviceFilter); - resultPairs.push({ - result, - connector: filterItem.connector, - }); + const { type } = resource.root; + + // Exhaustive switch: a new filter root type must be handled here or the `default` throws — + // this replaces an if-chain that silently dropped unhandled types (e.g. surveyInteraction), + // leaving `result` unassigned and corrupting segment membership. + switch (type) { + case "attribute": + result = evaluateAttributeFilter(userData.attributes, resource as TSegmentAttributeFilter); + break; + case "person": + result = evaluatePersonFilter(userData.userId, resource as TSegmentPersonFilter); + break; + case "segment": + result = await evaluateSegmentFilter(userData, resource as TSegmentSegmentFilter); + break; + case "device": + result = evaluateDeviceFilter(userData.deviceType, resource as TSegmentDeviceFilter); + break; + case "surveyInteraction": + // This in-process evaluator can only resolve interaction filters when the caller supplies + // the contact's interaction history. Throwing (rather than silently skipping) guarantees a + // surveyInteraction filter can never be dropped here; the contact-sync and Prisma paths + // evaluate these filters without needing this branch. + if (!userData.interactionData) { + throw new Error( + "Cannot evaluate a surveyInteraction filter without interactionData on userData; evaluate via the contact-sync in-memory path or the Prisma query path instead." + ); + } + result = evaluateSurveyInteractionFilterInMemory( + resource as TSegmentSurveyInteractionFilter, + userData.interactionData, + now + ); + break; + default: + throw new Error(`Unsupported segment filter root type: ${type as string}`); } } else { - result = await evaluateSegment(userData, resource); - // this is a sub-group and we need to evaluate the sub-group - resultPairs.push({ - result, - connector: filterItem.connector, - }); - } - } - - if (!resultPairs.length) { - return false; - } - - // We first evaluate all `and` conditions consecutively - let intermediateResults: boolean[] = []; - - // Given that the first filter in every group/sub-group always has a connector value of "null", - // we initialize the finalResult with the result of the first filter. - let currentAndGroupResult = resultPairs[0].result; - - for (let i = 1; i < resultPairs.length; i++) { - const { result, connector } = resultPairs[i]; - - if (connector === "and") { - currentAndGroupResult = currentAndGroupResult && result; - } else if (connector === "or") { - intermediateResults.push(currentAndGroupResult); - currentAndGroupResult = result; + result = await evaluateSegment(userData, resource); } - } - // Push the final `and` group result - intermediateResults.push(currentAndGroupResult); - // Now we can evaluate the `or` conditions - let finalResult = intermediateResults[0]; - for (let i = 1; i < intermediateResults.length; i++) { - finalResult = finalResult || intermediateResults[i]; + resultPairs.push({ + result, + connector: filterItem.connector, + }); } - return finalResult; + // Combine the per-filter results with AND-before-OR precedence (shared with the in-memory + // survey-interaction evaluator so both paths agree on connector semantics). + return combineFilterResults(resultPairs); } catch (error) { logger.error(error instanceof Error ? error : new Error(String(error)), "Error evaluating segment"); diff --git a/apps/web/modules/ee/contacts/segments/lib/utils.test.ts b/apps/web/modules/ee/contacts/segments/lib/utils.test.ts index 36a001194372..84846532e2dc 100644 --- a/apps/web/modules/ee/contacts/segments/lib/utils.test.ts +++ b/apps/web/modules/ee/contacts/segments/lib/utils.test.ts @@ -32,6 +32,7 @@ import { updateOperatorInFilter, updatePersonIdentifierInFilter, updateSegmentIdInFilter, + updateSurveyInteractionValueInFilter, } from "./utils"; // Mock createId @@ -110,6 +111,15 @@ describe("Segment Utils", () => { expect(convertOperatorToText("endsWith", t)).toBe("workspace.segments.operator_ends_with"); expect(convertOperatorToText("userIsIn", t)).toBe("workspace.segments.operator_user_is_in"); expect(convertOperatorToText("userIsNotIn", t)).toBe("workspace.segments.operator_user_is_not_in"); + expect(convertOperatorToText("haveCompleted", t)).toBe("workspace.segments.operator_have_completed"); + expect(convertOperatorToText("haveNotCompleted", t)).toBe( + "workspace.segments.operator_have_not_completed" + ); + expect(convertOperatorToText("haveSeen", t)).toBe("workspace.segments.operator_have_seen"); + expect(convertOperatorToText("haveNotSeen", t)).toBe("workspace.segments.operator_have_not_seen"); + expect(convertOperatorToText("haveStartedRespondingTo", t)).toBe( + "workspace.segments.operator_have_started_responding_to" + ); // @ts-expect-error - testing default case expect(convertOperatorToText("unknown", t)).toBe("unknown"); }); @@ -627,6 +637,47 @@ describe("Segment Utils", () => { ); }); + test("updateSurveyInteractionValueInFilter", () => { + const surveyInteractionFilter = { + id: "si1", + root: { type: "surveyInteraction" as const }, + qualifier: { operator: "haveSeen" as const }, + value: { surveyScope: "any" as const, surveyIds: [], within: { amount: 1, unit: "months" as const } }, + }; + const baseFilter = createBaseFilter(surveyInteractionFilter, null, "bf1"); + const nested = { + id: "si2", + root: { type: "surveyInteraction" as const }, + qualifier: { operator: "haveCompleted" as const }, + value: { surveyScope: "any" as const, surveyIds: [], within: { amount: 1, unit: "months" as const } }, + }; + const nestedBaseFilter = createBaseFilter(nested, null, "nbf1"); + const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1"); + const group = [baseFilter, nestedGroup] as unknown as TBaseFilters; + + updateSurveyInteractionValueInFilter(group, "si1", { + surveyScope: "specific", + surveyIds: ["survey_1"], + within: { amount: 3, unit: "weeks" }, + }); + expect((group[0].resource as TSegmentFilter).value).toEqual({ + surveyScope: "specific", + surveyIds: ["survey_1"], + within: { amount: 3, unit: "weeks" }, + }); + + updateSurveyInteractionValueInFilter(group, "si2", { + surveyScope: "any", + surveyIds: [], + within: { amount: 12, unit: "days" }, + }); + expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentFilter).value).toEqual({ + surveyScope: "any", + surveyIds: [], + within: { amount: 12, unit: "days" }, + }); + }); + test("formatSegmentDateFields", () => { const dateString = "2023-01-01T12:00:00.000Z"; const segment: TSegment = { diff --git a/apps/web/modules/ee/contacts/segments/lib/utils.ts b/apps/web/modules/ee/contacts/segments/lib/utils.ts index d3709f4bef1e..099475cd43be 100644 --- a/apps/web/modules/ee/contacts/segments/lib/utils.ts +++ b/apps/web/modules/ee/contacts/segments/lib/utils.ts @@ -15,6 +15,8 @@ import { TSegmentOperator, TSegmentPersonFilter, TSegmentSegmentFilter, + TSegmentSurveyInteractionFilterValue, + TSurveyInteractionOperator, } from "@formbricks/types/segment"; // type guard to check if a resource is a filter @@ -64,6 +66,16 @@ export const convertOperatorToText = (operator: TAllOperators, t: TFunction) => return t("workspace.segments.operator_is_between"); case "isSameDay": return t("workspace.segments.operator_is_same_day"); + case "haveCompleted": + return t("workspace.segments.operator_have_completed"); + case "haveNotCompleted": + return t("workspace.segments.operator_have_not_completed"); + case "haveSeen": + return t("workspace.segments.operator_have_seen"); + case "haveNotSeen": + return t("workspace.segments.operator_have_not_seen"); + case "haveStartedRespondingTo": + return t("workspace.segments.operator_have_started_responding_to"); default: return operator; } @@ -111,6 +123,16 @@ export const convertOperatorToTitle = (operator: TAllOperators, t: TFunction) => return t("workspace.segments.operator_title_is_between"); case "isSameDay": return t("workspace.segments.operator_title_is_same_day"); + case "haveCompleted": + return t("workspace.segments.operator_title_have_completed"); + case "haveNotCompleted": + return t("workspace.segments.operator_title_have_not_completed"); + case "haveSeen": + return t("workspace.segments.operator_title_have_seen"); + case "haveNotSeen": + return t("workspace.segments.operator_title_have_not_seen"); + case "haveStartedRespondingTo": + return t("workspace.segments.operator_title_have_started_responding_to"); default: return operator; } @@ -355,7 +377,7 @@ export const toggleFilterConnector = ( export const updateOperatorInFilter = ( group: TBaseFilters, filterId: string, - newOperator: TAttributeOperator | TSegmentOperator | TDeviceOperator + newOperator: TAttributeOperator | TSegmentOperator | TDeviceOperator | TSurveyInteractionOperator ) => { for (let i = 0; i < group.length; i++) { const { resource } = group[i]; @@ -460,6 +482,23 @@ export const updateDeviceTypeInFilter = ( } }; +export const updateSurveyInteractionValueInFilter = ( + group: TBaseFilters, + filterId: string, + newValue: TSegmentSurveyInteractionFilterValue +) => { + for (const { resource } of group) { + if (isResourceFilter(resource)) { + if (resource.id === filterId) { + resource.value = newValue; + break; + } + } else { + updateSurveyInteractionValueInFilter(resource, filterId, newValue); + } + } +}; + export const formatSegmentDateFields = (segment: TSegment): TSegment => { if (typeof segment.createdAt === "string") { segment.createdAt = new Date(segment.createdAt); diff --git a/apps/web/modules/ee/unify-feedback/sources/components/feedback-source-type-selector.tsx b/apps/web/modules/ee/unify-feedback/sources/components/feedback-source-type-selector.tsx index 8f49acf7e2c7..3843c2812405 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/feedback-source-type-selector.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/feedback-source-type-selector.tsx @@ -59,7 +59,7 @@ export function FeedbackSourceTypeSelector({ )}`}>
- {option.name} + {option.name} {option.badge && }

{option.description}

diff --git a/apps/web/modules/survey/editor/components/recontact-options-card.tsx b/apps/web/modules/survey/editor/components/recontact-options-card.tsx index 9f314751dbfb..5e09d830c712 100644 --- a/apps/web/modules/survey/editor/components/recontact-options-card.tsx +++ b/apps/web/modules/survey/editor/components/recontact-options-card.tsx @@ -35,20 +35,20 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO { id: "respect", value: null, - name: t("workspace.surveys.edit.respect_global_waiting_time"), - description: t("workspace.surveys.edit.respect_global_waiting_time_description"), + name: t("workspace.surveys.edit.respect_global_cooldown_period"), + description: t("workspace.surveys.edit.respect_global_cooldown_period_description"), }, { id: "ignore", value: 0, - name: t("workspace.surveys.edit.ignore_global_waiting_time"), - description: t("workspace.surveys.edit.ignore_global_waiting_time_description"), + name: t("workspace.surveys.edit.ignore_global_cooldown_period"), + description: t("workspace.surveys.edit.ignore_global_cooldown_period_description"), }, { id: "overwrite", value: 1, - name: t("workspace.surveys.edit.overwrite_global_waiting_time"), - description: t("workspace.surveys.edit.overwrite_global_waiting_time_description"), + name: t("workspace.surveys.edit.overwrite_global_cooldown_period"), + description: t("workspace.surveys.edit.overwrite_global_cooldown_period_description"), }, ], [t] @@ -178,10 +178,10 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO {/* Waiting Time Section */}

- {t("workspace.surveys.edit.waiting_time_across_surveys")} + {t("workspace.surveys.edit.cooldown_period_across_surveys")}

- {t("workspace.surveys.edit.waiting_time_across_surveys_description")} + {t("workspace.surveys.edit.cooldown_period_across_surveys_description")}

@@ -192,12 +192,12 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO {waitingTimeOptions.map((option) => (