Compare commits

..

159 Commits

Author SHA1 Message Date
Dhruwang ceabaf0aff tweaks 2026-04-29 11:21:54 +05:30
Johannes 52dc64ffd2 fix: add missing analysis translation keys in PR5
Made-with: Cursor
2026-04-26 20:16:47 +02:00
Johannes bec3fa2dbd feat: make Add charts a primary dashboard action
Promote Add charts to a labeled primary button in the dashboard control bar and keep secondary actions in the icon toolbar for clearer chart-creation affordance.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 18a3c4f0f7 fix: pass directories into dashboard detail chart dialogs
Load feedback record directories in dashboard detail page and thread them through dashboard detail and control bar components so chart dialogs no longer crash with an undefined directories reference.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 6c61afec2f feat: refresh analysis charts and dashboard feedback gating
Unify chart create and edit flows, update dashboard chart interactions, and add feedback-record availability checks with dedicated empty-state handling across analysis entry points.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes ce4d9350e2 fix: add missing feedback record translation keys in PR4
Made-with: Cursor
2026-04-26 20:05:49 +02:00
Johannes 3e6f81268d fix: require frdId when refreshing feedback records
Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes 8137de3c80 feat: integrate hub feedback records into unify workspace
Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience.

Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes bf0ad45697 fix: add missing feedback-directory and unify translations
Made-with: Cursor
2026-04-26 19:54:23 +02:00
Johannes b1a4277ca8 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-26 19:37:33 +02:00
Johannes 1876c13f52 fix: align unify locale keys and regenerate translations
Made-with: Cursor
2026-04-26 19:37:27 +02:00
Johannes 0623bb9ff5 fix: make feedback sources settings card compatible in PR2
Made-with: Cursor
2026-04-26 19:03:23 +02:00
Johannes d37cddaa7e feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-26 18:55:25 +02:00
Johannes 24f632f9ce fix: align unify connector type literals with schema
Replace legacy formbricks type checks with formbricks_survey across source setup flows so TypeScript and connector creation paths stay consistent.

Made-with: Cursor
2026-04-26 17:14:13 +02:00
Johannes b041e3da86 fix: normalize connector response timestamps for Cube
Normalize imported response timestamps to ISO and fall back from createdAt to updatedAt so collected_at is always valid for time-series charts.

Made-with: Cursor
2026-04-24 12:57:00 +02:00
Johannes 8d91a3db62 fix: map csat and ces connector question types
Add missing hub field mappings for csat and ces survey elements and guard against unmappable selected elements so connector setup fails with a clear validation error instead of a Prisma crash.

Made-with: Cursor
2026-04-24 12:02:51 +02:00
Johannes c05e3f192d fix: accept legacy connector type in create action
Normalize legacy formbricks connector payloads to formbricks_survey during connector creation so client flows continue working while UI migrations roll out.

Made-with: Cursor
2026-04-24 11:41:34 +02:00
Johannes 5b61e00560 refactor: align connector enum with formbricks_survey
Rename connector type usage from formbricks to formbricks_survey across Prisma schema, shared types, and connector service logic to keep enum contracts consistent.

Made-with: Cursor
2026-04-24 10:56:57 +02:00
Dhruwang Jariwala bf592937f4 feat: AI-powered survey translation (#7793) 2026-04-24 12:55:36 +05:30
Bhagya Amarasinghe 7ed7101ac1 feat: adds feedback record directory auth to api keys (#7804) 2026-04-23 18:04:17 +05:30
pandeymangg 2e926936fb addressed feedback 2026-04-23 17:46:09 +05:30
Dhruwang a386451e6e refactor: consolidate imports in actions.ts
Merged the import statements for ZAITranslationField and translateFields from the translate-fields module to streamline the code structure.
2026-04-23 15:17:31 +05:30
Dhruwang f0bf111e7b fix: test 2026-04-23 15:08:18 +05:30
Dhruwang 8a57a5b74b addressed feedback 2026-04-23 15:05:16 +05:30
Dhruwang 434cb1d0d0 refactor: remove BullMQ background jobs from AI translation
Replace the async job queue + Redis polling pattern with a direct
server action call. The translation now runs synchronously inside
translateSurveyFieldsAction, removing the need for BullMQ job
definitions, processors, cache keys, and client-side polling logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:11:28 +05:30
pandeymangg 8bde75a9ff chore: merge with epic/v5 2026-04-23 11:06:54 +05:30
pandeymangg 6b880f29cb chore: merge with epic/v5 2026-04-23 11:00:25 +05:30
Dhruwang Jariwala 969c9834e5 fix: (Depr Env QA) update lang keys (#7786) 2026-04-23 10:57:47 +05:30
pandeymangg 5e33b7c9a4 fixes and e2e fixes 2026-04-23 10:55:20 +05:30
Dhruwang 230ea744fa fix: simplify workspace permissions assignment in ViewPermissionModal 2026-04-23 10:38:18 +05:30
Anshuman Pandey fae1fb8f96 fix: cleans up environmentId references (#7792) 2026-04-23 09:05:00 +04:00
Dhruwang eac35daed9 fix: transllations 2026-04-23 10:34:29 +05:30
Johannes 45accc1edb fix: align workspace naming in setup and email preview
Replace remaining environment wording with workspace terminology across setup flows, API key permissions, and email preview text, and switch the email Tailwind config to ESM so formatting hooks run under the current Node runtime.

Made-with: Cursor
2026-04-23 10:30:30 +05:30
Dhruwang Jariwala 02ebe8e9f8 fix: (Depr Env QA) update docs (#7784) 2026-04-23 10:23:23 +05:30
pandeymangg cae859e326 sonarcloud fixes 2026-04-22 17:15:19 +05:30
pandeymangg 5352d563b6 fixes and consistency 2026-04-22 17:03:18 +05:30
Anshuman Pandey 711f2bfe67 chore: restores feedback record directory changes to epic/v5 (#7806) 2026-04-22 15:22:15 +04:00
Dhruwang 6fcb5d39a2 fix: address code review feedback for AI translation
- Reuse ZAITranslationField from @formbricks/jobs instead of duplicating
  the schema locally in actions.ts; tighten sourceLanguage/targetLanguage
  validators with .min(1) to match the downstream job schema

- Guard against undefined translations in getAITranslationResultAction
  instead of using the unsafe `translations!` assertion — return
  "pending" status for malformed cache entries

- Use createCacheKey.custom("ai-translation", jobId) instead of raw
  template strings to follow cache key conventions

- Improve JSON parsing robustness: strip markdown code fences before
  attempting JSON.parse, log raw response on parse failures

- Add stale-request guard and error handling to the AI availability
  useEffect in language-view.tsx

- Replace shared pollingCancelledRef boolean with per-invocation Symbol
  token to prevent stale polling loops from clobbering state when the
  modal is reopened

- Track timeout explicitly with a timedOut flag so the "timed out" toast
  doesn't fire when polling was actually cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:01:57 +05:30
pandeymangg 1ed9859ee7 fixes tests 2026-04-22 15:58:02 +05:30
pandeymangg cd72a0a78d adds translations 2026-04-22 15:44:45 +05:30
pandeymangg 2b09795787 feat: adds frd auth to api keys 2026-04-22 15:43:33 +05:30
Dhruwang 2451acb9bd fix: AI translation security, error handling, and test coverage
- Add userId verification in getAITranslationResultAction (security)
- Use OperationNotAllowedError for auth failures
- Store failure marker in cache on last BullMQ attempt
- Make JSON parsing more robust (extract first {...} block)
- Add "keep modal open" hint to translating toast
- Add test coverage for process-ai-translation-job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:18:54 +05:30
Dhruwang 14dcded91b Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into feat/ai-survey-translation 2026-04-22 15:01:12 +05:30
Dhruwang 46062f91cd refactors 2026-04-22 14:50:46 +05:30
Dhruwang Jariwala ffd4478184 chore: merge epic/dashboards into epic/v5 (#7798)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:05 +04:00
Dhruwang 69da1862fa Merge latest epic/v5 (survey scheduling) into ai-translation branch
Resolve merge conflicts to combine AI translation and survey scheduling
features in jobs package, instrumentation, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 13:39:10 +05:30
Dhruwang c11d3241ab fix: test and translations 2026-04-22 12:59:27 +05:30
Tiago 3fb09a1a26 feat: survey scheduling (#7766) 2026-04-21 15:00:51 +00:00
Dhruwang 6efa449c10 feat: add AI-powered survey translation via BullMQ background jobs
Add a "Translate with AI" button to the Manage Translations modal that
auto-populates empty translation fields using the configured AI provider.
Translation runs as a BullMQ background job with results cached in Redis
and polled by the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:38:47 +05:30
Dhruwang 34b94689ca fix: address CodeRabbit review feedback on docs
- Fix broken API URLs in docs-feedback.mdx (remove /workspaces segment, fix https://, remove stray };)
- Add missing workspaceId path params to v2 spec (responses, displays, user)
- Remove environmentId from required arrays in v2 request schemas
- Fix stale terminology: environment→workspace in database-model, tenant-separation, tags, actions
- Fix broken link to removed test-environment page in webhooks.mdx
- Fix redundant "codebase" in naming-conventions description
- Use neutral hostname in audit-logging example
- Hyphenate "open-source" in license.mdx
- Consistent workspaceId formatting in wordpress.mdx
- Update link text to match anchor in actions.mdx
- Remove dual environmentId/workspaceId in headless-surveys example
- Fix stale <project_id> placeholder in headless-surveys
- Fix awkward Next.js card copy in framework-guides.mdx
- Clarify BC wording in v2 introduction.mdx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:35:26 +05:30
Tiago Farto 901fac7e08 chore: fix env vars 2026-04-21 09:56:32 +00:00
Dhruwang 739c662863 chore: merge epic/v5 and resolve openapi.json conflicts
Accept epic/v5 removals (attribute-classes, people endpoints) and
re-apply workspaceId rename + deprecation notes + endpoint cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:22:17 +05:30
Dhruwang 535974ff8a fix: align API specs with actual routes and add environment deprecation notes
- Restore v1 openapi.json from main with environmentId→workspaceId rename
- Remove 5 non-existent v1 endpoints and their orphaned MDX pages
- Update v1 descriptions from "environment" to "workspace" terminology
- Add environment deprecation notes to all v1 client API endpoints
- Remove 2 non-existent v2 client endpoints (contacts attributes, identify)
- Rename v2 project-teams → workspace-teams (path, operationIds, schema)
- Preserve environment deprecation notes in v2 spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:15:52 +05:30
Tiago Farto a8b97abe9a Merge remote-tracking branch 'origin/epic/v5' into feat/survey-scheduling
# Conflicts:
#	pnpm-lock.yaml
2026-04-21 09:37:41 +00:00
Johannes 28103604b4 fix: (Depr Env QA) api v1/me regression (#7761)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-21 13:26:01 +04:00
Tiago Farto b5a7e15386 chore: fix build 2026-04-21 09:07:45 +00:00
Tiago Farto fec4746d5d chore: address PR comments 2026-04-21 08:51:32 +00:00
pandeymangg 175323e7d9 chore: merge with main 2026-04-21 14:07:14 +05:30
Tiago Farto 6130737d51 chore: fix DST bug 2026-04-20 16:57:48 +00:00
Tiago Farto bf10a8d0b2 Merge branch 'epic/v5' into feat/survey-scheduling 2026-04-20 16:09:16 +00:00
Tiago Farto 612f8dceb8 chore: fix test 2026-04-20 15:50:38 +00:00
Tiago 0303f16db4 feat: BullMQ background jobs + response pipeline (#7779) 2026-04-20 15:30:20 +00:00
Tiago Farto 07635b160e chore: fix test; migration 2026-04-20 15:13:27 +00:00
Tiago Farto 9cfcffdb5e chore: bug fix; tests 2026-04-20 14:22:06 +00:00
Tiago Farto 02264ffc5f chore: build fix 2026-04-20 13:25:01 +00:00
Serhat e489c6a346 feat: Add Turkish (tr) translations (#7645)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-20 12:51:25 +00:00
Tiago Farto 7dde3edd8d chore: fix tests 2026-04-20 12:30:03 +00:00
Johannes 4304a7efd6 Rename Projects to Workspaces in docs 2026-04-20 13:56:27 +02:00
Tiago Farto a89d598f8d Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:46:13 +00:00
Tiago Farto 6ff5af712f chore: clean tests 2026-04-20 11:35:47 +00:00
Anshuman Pandey 398ba79e7e feat: ces and csat questions (#7688)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-20 15:33:26 +04:00
Tiago Farto 5127de9de0 chore: revert CI action 2026-04-20 11:11:09 +00:00
Tiago Farto 2bf7788a1b Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:07:33 +00:00
Tiago Farto ee8122778b chore: address PR comments 2026-04-20 10:43:32 +00:00
Tiago Farto 8aaa7ed9c0 chore: build fix 2026-04-20 10:00:06 +00:00
Johannes bc7c8c5715 remove environment ID andenv references 2026-04-20 11:40:33 +02:00
Dhruwang Jariwala ab1ea7a5ce fix: remove legacy API rewrites from next.config.mjs (#7764)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-20 13:38:30 +04:00
Tiago Farto 4f749355e0 chore: fix coverage test 2026-04-20 09:14:06 +00:00
Tiago Farto 18b60ddd35 chore: fix build 2026-04-20 08:52:59 +00:00
Tiago Farto 87f1b01c7a chore: fix broken tests 2026-04-20 08:40:44 +00:00
Tiago Farto 851ea0deb2 chore: fix broken lock 2026-04-20 08:32:45 +00:00
pandeymangg 9abbbfdd35 chore: merge with main 2026-04-20 13:07:36 +05:30
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Johannes 990c0eee31 refined UX 2026-04-19 16:05:29 +02:00
Tiago Farto 07f16b8a43 chore: fix build 2026-04-17 23:23:51 +00:00
Tiago Farto bf556b0608 chore: fix linting 2026-04-17 22:26:57 +00:00
Tiago Farto 8b0766a46e chore: bix fuild 2026-04-17 22:16:17 +00:00
Tiago Farto 1f995d6e25 chore: build fix 2026-04-17 20:05:11 +00:00
Tiago Farto 975a4d57f8 chore: fix build 2026-04-17 19:50:23 +00:00
Tiago Farto 69bd576fc5 chore: fix build 2026-04-17 16:37:22 +00:00
Tiago Farto a2e4a3bbd7 chore: fix build 2026-04-17 16:27:18 +00:00
Tiago Farto 281f854332 chore: address PR comments 2026-04-17 15:36:12 +00:00
Tiago Farto 24496774a5 chore: fix build 2026-04-17 14:57:55 +00:00
Tiago Farto aeaf3215b4 chore: fix 2026-04-17 14:51:51 +00:00
Tiago Farto f4c5162590 Merge epic/bullmq into feat/survey-scheduling 2026-04-17 14:47:05 +00:00
Tiago Farto dedb7389f0 Merge origin/epic/v5 into epic/bullmq 2026-04-17 14:33:21 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Tiago Farto 7aed1b84de chore: translations, fixes 2026-04-17 11:59:17 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
Dhruwang Jariwala 60bd5cbeff fix: prevent environment ID leak in API error responses (#7753)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:38:32 +00:00
Dhruwang Jariwala b6a3a15379 fix: make other option input field mandatory when sole selection (#7751)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:06:00 +00:00
Johannes c68f214eff fix: keep sidebar switcher icons round with long labels (#7756)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 08:04:10 +00:00
Harsh Bhat c90ee84483 chore: Add survey to formbricks docs (#7746) 2026-04-16 12:13:55 +00:00
Dhruwang Jariwala dc1ee72594 chore: translation management revamp (scope 1) (#7733)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-16 11:18:48 +00:00
Dhruwang Jariwala 924132287e fix: connect rating/NPS scale labels to label styling settings (#7738)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:59:59 +00:00
Dhruwang Jariwala e6f347aa07 fix: remove dark: variant classes from survey-ui to prevent host page style leakage (#7747) 2026-04-16 05:50:46 +00:00
Dhruwang Jariwala 367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
Marius 0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
Tiago Farto d361c334d3 chore: fixed management snapshot gap 2026-04-13 14:28:31 +03:00
Tiago Farto a4d808b479 chore: build fix 2026-04-13 13:10:33 +03:00
Tiago Farto 18ae1748d3 chore: address PR comments 2026-04-13 12:50:21 +03:00
Tiago Farto 3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto 83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto 2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto 54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto 1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto 197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
Tiago 7ca52a7a93 feat: Introduce BullMQ setup to Formbricks (#7684) 2026-04-09 11:47:58 +03:00
Tiago Farto 4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto 92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Tiago Farto ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Tiago Farto 8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Tiago Farto 03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Tiago Farto e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto 581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto 5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Tiago Farto ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto 597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto 3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto 84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto 784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto 5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto 65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto 22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto 9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto 29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
515 changed files with 30801 additions and 10606 deletions
+25 -6
View File
@@ -32,6 +32,24 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
##############
# DATABASE #
##############
@@ -287,14 +305,15 @@ REDIS_URL=redis://localhost:6379
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
# CUBEJS_DB_HOST=postgres
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
#
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
+1 -1
View File
@@ -23,7 +23,7 @@
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
feedbackRecordDirectoryId: ZId.optional(),
});
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZWorkspaceUpdateInput,
data: ZCreateWorkspaceInput,
});
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [
{
data: parsedInput.data,
schema: ZWorkspaceUpdateInput,
schema: ZCreateWorkspaceInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -10,12 +10,12 @@ import {
Loader2,
LogOutIcon,
MessageCircle,
MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
Shapes,
UserCircleIcon,
UserIcon,
} from "lucide-react";
@@ -146,58 +146,77 @@ export const MainNavigation = ({
}
}, [pathname]);
const mainNavigation = useMemo(
const mainNavigationSections = useMemo(
() => [
{
name: t("common.surveys"),
href: `/workspaces/${workspace.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/workspaces/${workspace.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
{
name: t("common.dashboards"),
href: `/workspaces/${workspace.id}/dashboards`,
icon: BarChart3Icon,
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
isHidden: false,
disabled: isMembershipPending || isBilling,
id: "ask",
name: "Ask",
items: [
{
name: t("common.surveys"),
href: `/workspaces/${workspace.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/workspaces/${workspace.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
],
},
{
id: "unify-feedback",
name: t("workspace.unify.unify_feedback"),
href: `/workspaces/${workspace.id}/unify/sources`,
icon: Shapes,
isActive: pathname?.includes("/unify"),
},
{
name: t("common.configuration"),
href: `/workspaces/${workspace.id}/general`,
icon: Cog,
isActive:
pathname?.includes("/general") ||
pathname?.includes("/look") ||
pathname?.includes("/app-connection") ||
pathname?.includes("/integrations") ||
pathname?.includes("/teams") ||
pathname?.includes("/languages") ||
pathname?.includes("/tags"),
disabled: isMembershipPending || isBilling,
items: [
{
name: t("workspace.unify.feedback_records"),
href: `/workspaces/${workspace.id}/unify/feedback-records`,
icon: MessageSquareTextIcon,
isActive: pathname?.includes("/unify/feedback-records"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
name: t("common.dashboards"),
href: `/workspaces/${workspace.id}/dashboards`,
icon: BarChart3Icon,
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const configurationNavigationItem = useMemo(
() => ({
name: t("common.configuration"),
href: `/workspaces/${workspace.id}/general`,
icon: Cog,
isActive:
pathname?.includes("/general") ||
pathname?.includes("/look") ||
pathname?.includes("/app-connection") ||
pathname?.includes("/feedback-sources") ||
pathname?.includes("/integrations") ||
pathname?.includes("/teams") ||
pathname?.includes("/languages") ||
pathname?.includes("/tags"),
disabled: isMembershipPending || isBilling,
}),
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const dropdownNavigation = [
{
label: t("common.account"),
@@ -256,6 +275,11 @@ export const MainNavigation = ({
label: t("common.website_and_app_connection"),
href: `/workspaces/${workspace.id}/app-connection`,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `/workspaces/${workspace.id}/feedback-sources`,
},
{
id: "integrations",
label: t("common.integrations"),
@@ -313,6 +337,15 @@ export const MainNavigation = ({
href: `/workspaces/${workspace.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
const loadWorkspaces = useCallback(async () => {
@@ -429,16 +462,22 @@ export const MainNavigation = ({
: `/workspaces/${workspace.id}/surveys/`;
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === workspace.id) return;
const targetPath =
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
startTransition(() => {
router.push(`/workspaces/${workspaceId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
const targetPath =
organizationId === organization.id
? `/workspaces/${workspace.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
@@ -495,7 +534,7 @@ export const MainNavigation = ({
);
const switcherIconClasses =
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialWorkspacesLoading =
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
@@ -537,23 +576,52 @@ export const MainNavigation = ({
</div>
{/* Main Nav Switch */}
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
<ul className="space-y-2">
{mainNavigationSections.map((section) => (
<li key={section.id}>
{!isCollapsed && !isTextVisible && (
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
{section.name}
</p>
)}
<ul>
{section.items.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
</li>
))}
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
<ul>
<NavigationLink
href={configurationNavigationItem.href}
isActive={configurationNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={configurationNavigationItem.disabled}
disabledMessage={
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={configurationNavigationItem.name}>
<configurationNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</ul>
</li>
</ul>
</div>
@@ -117,8 +117,12 @@ export const OrganizationBreadcrumb = ({
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -144,12 +148,6 @@ export const OrganizationBreadcrumb = ({
label: t("common.members_and_teams"),
href: `${workspaceBasePath}/settings/teams`,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.nav_label"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
@@ -181,6 +179,15 @@ export const OrganizationBreadcrumb = ({
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
return (
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
label: t("common.website_and_app_connection"),
href: `${workspaceBasePath}/app-connection`,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `${workspaceBasePath}/feedback-sources`,
},
{
id: "integrations",
label: t("common.integrations"),
@@ -158,9 +163,13 @@ export const WorkspaceBreadcrumb = ({
}
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === currentWorkspaceId) return;
const targetPath =
workspaceId === currentWorkspaceId
? `/workspaces/${currentWorkspaceId}/surveys`
: `/workspaces/${workspaceId}/`;
startTransition(() => {
router.push(`/workspaces/${workspaceId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
@@ -8,7 +8,7 @@ import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/type
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
@@ -61,6 +61,16 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
labelKey: t("workspace.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "aiSmartTools",
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "aiDataAnalysis",
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
},
{
key: "auditLogs",
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
@@ -6,24 +6,32 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { updateOrganizationAISettingsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { type ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
hasAIPermission: boolean;
isFormbricksCloud: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
hasAIPermission,
isFormbricksCloud,
}: Readonly<AISettingsToggleProps>) => {
const { workspace } = useWorkspace();
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -78,6 +86,32 @@ export const AISettingsToggle = ({
}
};
const upgradeButtons: [ModalButton, ModalButton] = [
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
];
if (!hasAIPermission) {
return (
<UpgradePrompt
title={t("workspace.settings.general.unlock_ai_features_with_a_higher_plan")}
description={t("workspace.settings.general.unlock_ai_features_description")}
buttons={upgradeButtons}
feature="ai_features"
/>
);
}
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
@@ -3,7 +3,12 @@ import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import {
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsMultiOrgEnabled,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -27,8 +32,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
await Promise.all([
getIsMultiOrgEnabled(),
getWhiteLabelPermission(organization.id),
getIsAISmartToolsEnabled(organization.id),
getIsAIDataAnalysisEnabled(organization.id),
]);
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -64,6 +75,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
hasAIPermission={hasAIPermission}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
</SettingsCard>
<EmailCustomizationSettings
@@ -21,6 +21,7 @@ export const SettingsCard = ({
beta,
className,
buttonInfo,
cta,
}: {
title: string;
description: string;
@@ -30,6 +31,7 @@ export const SettingsCard = ({
beta?: boolean;
className?: string;
buttonInfo?: ButtonInfo;
cta?: React.ReactNode;
}) => {
const { t } = useTranslation();
return (
@@ -52,11 +54,12 @@ export const SettingsCard = ({
{description}
</Small>
</div>
{buttonInfo && (
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
{buttonInfo?.text}
</Button>
)}
{cta ??
(buttonInfo && (
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
{buttonInfo?.text}
</Button>
))}
</div>
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
</div>
@@ -0,0 +1,51 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCes } from "@formbricks/types/surveys/types";
import { RatingLikeSummary } from "./RatingLikeSummary";
interface CESSummaryProps {
elementSummary: TSurveyElementSummaryCes;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
{elementSummary.element.range}
</div>
</div>
</div>
}
/>
);
};
@@ -0,0 +1,72 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCsat } from "@formbricks/types/surveys/types";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { RatingLikeSummary } from "./RatingLikeSummary";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface CSATSummaryProps {
elementSummary: TSurveyElementSummaryCsat;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
{t("workspace.surveys.summary.csat_satisfied", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.csat_satisfied_tooltip", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
);
};
@@ -8,7 +8,7 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
@@ -39,6 +39,7 @@ const calculateNPSOpacity = (rating: number): number => {
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const promotersPercentage = convertFloatToNDecimal(elementSummary.promoters.percentage, 2);
const applyFilter = (group: string) => {
const filters = {
@@ -81,13 +82,23 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
</div>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.nps_promoters_tooltip", {
percentage: promotersPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
/>
@@ -0,0 +1,214 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryCes,
TSurveyElementSummaryCsat,
TSurveyElementSummaryRating,
} from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
type RatingLikeElementSummary =
| TSurveyElementSummaryCes
| TSurveyElementSummaryCsat
| TSurveyElementSummaryRating;
interface RatingLikeSummaryProps {
elementSummary: RatingLikeElementSummary;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
additionalInfo: JSX.Element;
}
export const RatingLikeSummary = ({
elementSummary,
survey,
setFilter,
additionalInfo,
}: RatingLikeSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} additionalInfo={additionalInfo} />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.7;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
</div>
</div>
</div>
)}
</div>
);
};
@@ -1,21 +1,12 @@
"use client";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
import { RatingLikeSummary } from "./RatingLikeSummary";
interface RatingSummaryProps {
elementSummary: TSurveyElementSummaryRating;
@@ -31,196 +22,29 @@ interface RatingSummaryProps {
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary]);
}, [elementSummary.element.scale]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("workspace.surveys.summary.satisfied")}
</div>
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
</div>
)}
</div>
}
/>
);
};
@@ -22,8 +22,23 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
const appSetupCompleted = workspace.appSetupCompleted;
useEffect(() => {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && workspace) {
const publishSuccessParam = searchParams.get("success");
const scheduledSuccessParam = searchParams.get("scheduled");
if (scheduledSuccessParam) {
toast.success(t("workspace.surveys.summary.survey_scheduled_successfully"), {
id: "survey-schedule-success-toast",
duration: 5000,
position: "bottom-right",
});
const url = new URL(globalThis.location.href);
url.searchParams.delete("scheduled");
globalThis.history.replaceState({}, "", url.toString());
return;
}
if (publishSuccessParam) {
setConfetti(true);
toast.success(
isAppSurvey && !appSetupCompleted
@@ -38,16 +53,16 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
);
// Remove success param from url
const url = new URL(window.location.href);
const url = new URL(globalThis.location.href);
url.searchParams.delete("success");
if (survey.type === "link") {
// Add share param to url to open share embed modal
url.searchParams.set("share", "true");
}
window.history.replaceState({}, "", url.toString());
globalThis.history.replaceState({}, "", url.toString());
}
}, [workspace, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
}, [appSetupCompleted, isAppSurvey, searchParams, survey.type, t]);
return <>{confetti && <Confetti />}</>;
};
@@ -13,6 +13,8 @@ import {
SelectedFilterValue,
useResponseFilter,
} from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { CESSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CESSummary";
import { CSATSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CSATSummary";
import { CTASummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
import { CalSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
import { ConsentSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
@@ -156,6 +158,26 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CSAT) {
return (
<CSATSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CES) {
return (
<CESSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
return (
<ConsentSummary
@@ -4578,3 +4578,611 @@ describe("Cal question type tests", () => {
expect(summary[0].skipped.count).toBe(1); // Counted as skipped
});
});
describe("CSAT question type tests", () => {
test("getElementSummary correctly processes CSAT question with valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "csat-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(4);
// Average = (5 + 4 + 2 + 1) / 4 = 3.0
expect(summary[0].average).toBe(3);
// CSAT: satisfied = ratings 4 + 5 = 2 out of 4
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(50);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
const rating1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(rating1.count).toBe(1);
expect(rating1.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
});
test("getElementSummary handles CSAT question with dismissed responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: false,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 3 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 2 },
finished: true,
},
{
id: "response-3",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 4 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "csat-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(5);
expect(summary[0].dismissed.count).toBe(2);
expect(summary[0].csat.satisfiedCount).toBe(1);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getElementSummary handles CSAT question with no valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
test("getElementSummary CSAT correctly identifies satisfied ratings (4 and 5 only)", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
// 3 satisfied (4,5,5), 2 not satisfied (1,3)
const responses = [
{
id: "r1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "csat-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r5",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// satisfied = ratings 4 and 5 = 3 out of 5
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(60);
});
});
describe("CES question type tests", () => {
test("getElementSummary correctly processes CES question with valid responses (range 5)", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "ces-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "ces-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(4);
// CES = average = (5 + 4 + 2 + 3) / 4 = 3.5
expect(summary[0].average).toBe(3.5);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating3 = summary[0].choices.find((c: any) => c.rating === 3);
expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
// CES has no csat field
expect(summary[0].csat).toBeUndefined();
});
test("getElementSummary correctly processes CES question with range 7", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "ces-q1": 7 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "ces-q1": 6 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "ces-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(3);
// CES average = (7 + 6 + 1) / 3 = 4.67
expect(summary[0].average).toBe(4.67);
// Verify 7 choices exist (range 7)
expect(summary[0].choices).toHaveLength(7);
});
test("getElementSummary handles CES question with dismissed responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: false,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 5 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 2 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "ces-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(3);
expect(summary[0].dismissed.count).toBe(1);
});
test("getElementSummary handles CES question with no valid responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
});
@@ -25,7 +25,6 @@ import {
TSurveyElementSummaryOpenText,
TSurveyElementSummaryPictureSelection,
TSurveyElementSummaryRanking,
TSurveyElementSummaryRating,
TSurveyLanguage,
TSurveySummary,
} from "@formbricks/types/surveys/types";
@@ -272,6 +271,49 @@ const checkForI18n = (
return responseData[id];
};
const computeNumericScaleStats = (
elementId: string,
range: number,
responses: TSurveySummaryResponse[]
): {
choices: { rating: number; count: number; percentage: number }[];
choiceCountMap: Record<number, number>;
totalResponseCount: number;
totalRating: number;
dismissed: number;
average: number;
} => {
const choiceCountMap: Record<number, number> = {};
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[elementId];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[elementId] > 0) {
dismissed++;
}
});
const choices = Object.entries(choiceCountMap).map(([label, count]) => ({
rating: Number.parseInt(label),
count,
percentage: totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
}));
const average = totalResponseCount > 0 ? convertFloatTo2Decimal(totalRating / totalResponseCount) : 0;
return { choices, choiceCountMap, totalResponseCount, totalRating, dismissed, average };
};
export const getElementSummary = async (
survey: TSurvey,
elements: TSurveyElement[],
@@ -472,72 +514,16 @@ export const getElementSummary = async (
break;
}
case TSurveyElementTypeEnum.Rating: {
let values: TSurveyElementSummaryRating["choices"] = [];
const choiceCountMap: Record<number, number> = {};
const range = element.range;
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[element.id];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[element.id] > 0) {
dismissed++;
}
});
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: Number.parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
const stats = computeNumericScaleStats(element.id, element.range, responses);
summary.push({
type: element.type,
element,
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
responseCount: totalResponseCount,
choices: values,
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
});
values = [];
break;
}
case TSurveyElementTypeEnum.NPS: {
@@ -612,6 +598,40 @@ export const getElementSummary = async (
});
break;
}
case TSurveyElementTypeEnum.CSAT: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
// CSAT: top 2 ratings out of 5 are "satisfied"
const satisfiedCount = (stats.choiceCountMap[4] || 0) + (stats.choiceCountMap[5] || 0);
const satisfiedPercentage =
stats.totalResponseCount > 0
? convertFloatTo2Decimal((satisfiedCount / stats.totalResponseCount) * 100)
: 0;
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
csat: { satisfiedCount, satisfiedPercentage },
});
break;
}
case TSurveyElementTypeEnum.CES: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
});
break;
}
case TSurveyElementTypeEnum.CTA: {
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
if (!element.buttonExternal) {
@@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -22,18 +21,19 @@ interface SurveyStatusDropdownProps {
}
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
const { workspace } = useWorkspaceContext();
const { t } = useTranslation();
const router = useRouter();
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
const handleStatusChange = async (status: TSurvey["status"]) => {
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) {
const resultingStatus = updateSurveyActionResponse.data.status;
const { publishOn, status: resultingStatus } = updateSurveyActionResponse.data;
const isResultScheduled = resultingStatus === "paused" && publishOn !== null;
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
inProgress: t("common.survey_live"),
paused: t("common.survey_paused"),
paused: isResultScheduled ? t("common.survey_scheduled") : t("common.survey_paused"),
completed: t("common.survey_completed"),
};
@@ -68,12 +68,10 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
<SelectTrigger className="w-[170px] bg-white md:w-[200px]">
<SelectValue>
<div className="flex items-center">
{(survey.type === "link" || workspace.appSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<SurveyStatusIndicator status={survey.status} isScheduled={isScheduled} />
<span className="ml-2 text-sm text-slate-700">
{survey.status === "inProgress" && t("common.in_progress")}
{survey.status === "paused" && t("common.paused")}
{survey.status === "paused" && (isScheduled ? t("common.scheduled") : t("common.paused"))}
{survey.status === "completed" && t("common.completed")}
</span>
</div>
@@ -88,8 +86,8 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
<div className="flex w-full items-center justify-center gap-2">
<SurveyStatusIndicator status={"paused"} />
{t("common.paused")}
<SurveyStatusIndicator status={"paused"} isScheduled={isScheduled} />
{isScheduled ? t("common.scheduled") : t("common.paused")}
</div>
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
@@ -0,0 +1,8 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -0,0 +1,10 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -1,6 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Badge } from "@/modules/ui/components/badge";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
@@ -17,15 +18,24 @@ export const UnifyConfigNavigation = ({
const { t } = useTranslation();
const baseHref = `/workspaces/${workspaceId}/unify`;
const activeId = activeIdProp ?? "sources";
const activeId = activeIdProp ?? "feedback-records";
const navigation = [
{ id: "sources", label: t("workspace.unify.sources"), href: `${baseHref}/sources` },
{
id: "feedback-records",
label: t("workspace.unify.feedback_records"),
href: `${baseHref}/feedback-records`,
},
{
id: "topics-subtopics",
label: (
<span className="inline-flex items-center gap-2">
{t("workspace.unify.topics_and_subtopics")}
<Badge text={t("common.soon")} type="gray" size="tiny" />
</span>
),
disabled: true,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -0,0 +1,197 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
const ZFeedbackRecordId = z.uuid();
const ZFeedbackRecordFieldType = z.enum([
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
]);
const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
const ZFeedbackRecordCreateInput = z.object({
submission_id: z.string().min(1),
tenant_id: ZId,
source_type: z.string().min(1),
field_id: z.string().min(1),
field_type: ZFeedbackRecordFieldType,
collected_at: z.iso.datetime().optional(),
source_id: z.string().optional().nullable(),
source_name: z.string().optional().nullable(),
field_label: z.string().optional().nullable(),
field_group_id: z.string().optional(),
field_group_label: z.string().optional().nullable(),
value_text: z.string().optional().nullable(),
value_number: z.number().optional(),
value_boolean: z.boolean().optional(),
value_date: z.iso.datetime().optional(),
metadata: ZFeedbackRecordMetadata.optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
});
const ZFeedbackRecordUpdateInput = z
.object({
value_text: z.string().optional().nullable(),
value_number: z.number().optional().nullable(),
value_boolean: z.boolean().optional().nullable(),
value_date: z.iso.datetime().optional().nullable(),
language: z.string().optional().nullable(),
metadata: ZFeedbackRecordMetadata.optional(),
user_identifier: z.string().optional().nullable(),
})
.refine(
(value) => Object.values(value).some((entry) => entry !== undefined),
"At least one field must be provided for update"
);
const ZRetrieveFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
});
const ZCreateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordInput: ZFeedbackRecordCreateInput,
});
const ZUpdateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
updateInput: ZFeedbackRecordUpdateInput,
});
const ensureAccess = async (
userId: string,
workspaceId: string,
minPermission: "read" | "readWrite"
): Promise<void> => {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission,
workspaceId,
},
],
});
};
const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string>> => {
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return new Set(directories.map((directory) => directory.id));
};
const assertWorkspaceDirectoryAccess = (directoryIds: Set<string>, tenantId: string): void => {
if (!directoryIds.has(tenantId)) {
throw new AuthorizationError("Invalid feedback record directory for this workspace");
}
};
export const retrieveFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZRetrieveFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!recordResult.data || recordResult.error) {
throw new Error(recordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
return recordResult.data;
}
);
export const createFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZCreateFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
const createResult = await createFeedbackRecord(
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
);
if (!createResult.data || createResult.error) {
throw new Error(createResult.error?.message || "Failed to create feedback record");
}
return createResult.data;
}
);
export const updateFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZUpdateFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
const updatePayload = Object.fromEntries(
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
) as unknown as FeedbackRecordUpdateParams;
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
if (!updateResult.data || updateResult.error) {
throw new Error(updateResult.error?.message || "Failed to update feedback record");
}
return updateResult.data;
}
);
@@ -0,0 +1,988 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { v7 as uuidv7 } from "uuid";
import { z } from "zod";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/modules/ui/components/sheet";
import { Switch } from "@/modules/ui/components/switch";
import {
createFeedbackRecordAction,
retrieveFeedbackRecordAction,
updateFeedbackRecordAction,
} from "./actions";
type FeedbackRecordDrawerMode = "create" | "edit";
interface FeedbackRecordFormDrawerProps {
mode: FeedbackRecordDrawerMode;
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
directories: { id: string; name: string }[];
canWrite: boolean;
recordId?: string;
onSuccess: () => Promise<void> | void;
}
const FIELD_TYPE_OPTIONS = [
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
] as const;
const SOURCE_TYPE_PRESET_OPTIONS = [
"survey",
"review",
"feedback_form",
"support",
"social",
"interview",
"usability_test",
"nps_campaign",
] as const;
const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
const ZMetadataEntry = z.object({
key: z.string().trim().min(1),
value: z.string(),
});
const ZFeedbackRecordFormValues = z.object({
id: z.string().optional(),
tenant_id: z.string().min(1),
submission_id: z.string().min(1),
collected_at: z.string().min(1),
created_at: z.string().optional(),
updated_at: z.string().optional(),
source_type: z.string().min(1),
source_id: z.string().optional(),
source_name: z.string().optional(),
field_id: z.string().min(1),
field_label: z.string().optional(),
field_type: z.enum(FIELD_TYPE_OPTIONS),
field_group_id: z.string().optional(),
field_group_label: z.string().optional(),
value_text: z.string().optional(),
value_number: z.string().optional(),
value_boolean: z.boolean().optional(),
value_date: z.string().optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
metadataEntries: z.array(ZMetadataEntry),
});
type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
const getValueFieldByType = (
fieldType: TFeedbackRecordFormValues["field_type"]
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
switch (fieldType) {
case "boolean":
return "value_boolean";
case "date":
return "value_date";
case "nps":
case "csat":
case "ces":
case "rating":
case "number":
return "value_number";
default:
return "value_text";
}
};
const toLocalDateTimeInput = (isoDate: string): string => {
const date = new Date(isoDate);
if (!Number.isFinite(date.getTime())) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => {
if (!dateTimeValue) {
return undefined;
}
const parsed = new Date(dateTimeValue);
if (!Number.isFinite(parsed.getTime())) {
return undefined;
}
return parsed.toISOString();
};
const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => {
const now = new Date();
const defaultDirectoryId = directories[0]?.id ?? "";
return {
id: "",
tenant_id: defaultDirectoryId,
submission_id: uuidv7(),
collected_at: toLocalDateTimeInput(now.toISOString()),
created_at: "",
updated_at: "",
source_type: "survey",
source_id: "",
source_name: "",
field_id: "",
field_label: "",
field_type: "text",
field_group_id: "",
field_group_label: "",
value_text: "",
value_number: "",
value_boolean: undefined,
value_date: "",
language: "",
user_identifier: "",
metadataEntries: [],
};
};
const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => {
const metadataEntries = Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value === "string")
.map(([key, value]) => ({
key,
value: value as string,
}));
return {
id: record.id,
tenant_id: record.tenant_id,
submission_id: record.submission_id,
collected_at: toLocalDateTimeInput(record.collected_at),
created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "",
updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "",
source_type: record.source_type,
source_id: record.source_id ?? "",
source_name: record.source_name ?? "",
field_id: record.field_id,
field_label: record.field_label ?? "",
field_type: record.field_type,
field_group_id: record.field_group_id ?? "",
field_group_label: record.field_group_label ?? "",
value_text: record.value_text ?? "",
value_number: record.value_number == null ? "" : String(record.value_number),
value_boolean: record.value_boolean,
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
language: record.language ?? "",
user_identifier: record.user_identifier ?? "",
metadataEntries,
};
};
const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => {
return Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value !== "string")
.map(([key, value]) => ({
key,
value: JSON.stringify(value),
}));
};
const parseNumberValue = (value: string): number | null => {
if (value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const formatSourceType = (sourceType: string, t: (key: string) => string): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return sourceType;
}
};
export const FeedbackRecordFormDrawer = ({
mode,
open,
onOpenChange,
workspaceId,
directories,
canWrite,
recordId,
onSuccess,
}: Readonly<FeedbackRecordFormDrawerProps>) => {
const { t } = useTranslation();
const [record, setRecord] = useState<FeedbackRecordData | null>(null);
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
const form = useForm<TFeedbackRecordFormValues>({
resolver: zodResolver(ZFeedbackRecordFormValues),
defaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "metadataEntries",
});
const fieldType = form.watch("field_type");
const selectedValueField = getValueFieldByType(fieldType);
const isEditMode = mode === "edit";
const isReadOnly = isEditMode && !canWrite;
const [sourceTypeMode, setSourceTypeMode] = useState<string>("survey");
const [customSourceType, setCustomSourceType] = useState("");
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
const resetForCreate = useCallback(() => {
const nextDefaults = getCreateDefaults(directories);
form.reset(nextDefaults);
setRecord(null);
setSourceTypeMode(nextDefaults.source_type);
setCustomSourceType("");
}, [directories, form]);
useEffect(() => {
if (!open) return;
if (mode === "create") {
resetForCreate();
return;
}
if (!recordId) return;
const loadRecord = async () => {
setIsLoadingRecord(true);
const result = await retrieveFeedbackRecordAction({ workspaceId, recordId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records"));
setIsLoadingRecord(false);
return;
}
setRecord(result.data);
form.reset(mapRecordToValues(result.data));
setSourceTypeMode(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never)
? result.data.source_type
: SOURCE_TYPE_CUSTOM_VALUE
);
setCustomSourceType(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type
);
setIsLoadingRecord(false);
};
void loadRecord();
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
const requestClose = useCallback(() => {
if (form.formState.isDirty && !isSubmitting) {
setIsDiscardDialogOpen(true);
return;
}
onOpenChange(false);
}, [form.formState.isDirty, isSubmitting, onOpenChange]);
const handleDrawerOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
onOpenChange(true);
return;
}
requestClose();
},
[onOpenChange, requestClose]
);
const handleDiscardChanges = () => {
setIsDiscardDialogOpen(false);
onOpenChange(false);
};
const setStrictValueValidationError = (message: string) => {
form.setError(selectedValueField, { type: "manual", message });
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
if (mode === "create") {
const requiredValueError = t("workspace.unify.feedback_record_value_required");
if (selectedValueField === "value_text" && !values.value_text?.trim()) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_boolean" && values.value_boolean === undefined) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) {
setStrictValueValidationError(requiredValueError);
return;
}
}
const metadata = Object.fromEntries(
values.metadataEntries
.map((entry) => ({
key: entry.key.trim(),
value: entry.value,
}))
.filter((entry) => entry.key.length > 0)
.map((entry) => [entry.key, entry.value])
);
setIsSubmitting(true);
try {
if (mode === "create") {
const sourceTypeValue =
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
const createResult = await createFeedbackRecordAction({
workspaceId,
recordInput: {
submission_id: values.submission_id.trim(),
tenant_id: values.tenant_id,
source_type: sourceTypeValue,
source_id: values.source_id?.trim() ? values.source_id.trim() : null,
source_name: values.source_name?.trim() ? values.source_name.trim() : null,
field_id: values.field_id.trim(),
field_label: values.field_label?.trim() ? values.field_label.trim() : null,
field_type: values.field_type,
field_group_id: values.field_group_id?.trim() || undefined,
field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null,
collected_at: toISOOrUndefined(values.collected_at),
value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null,
value_number:
selectedValueField === "value_number"
? (parseNumberValue(values.value_number ?? "") ?? undefined)
: undefined,
value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined,
value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
language: values.language?.trim() || undefined,
user_identifier: values.user_identifier?.trim() || undefined,
},
});
if (!createResult?.data) {
toast.error(getFormattedErrorMessage(createResult));
setIsSubmitting(false);
return;
}
} else {
if (!recordId) {
setIsSubmitting(false);
return;
}
const preservedMetadata = Object.fromEntries(
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
);
const updatePayload: Record<string, unknown> = {
language: values.language?.trim() || null,
user_identifier: values.user_identifier?.trim() || null,
metadata: { ...preservedMetadata, ...metadata },
};
if (selectedValueField === "value_text") {
updatePayload.value_text = values.value_text?.trim() ?? "";
} else if (selectedValueField === "value_number") {
updatePayload.value_number = parseNumberValue(values.value_number ?? "");
} else if (selectedValueField === "value_boolean") {
updatePayload.value_boolean = values.value_boolean ?? null;
} else if (selectedValueField === "value_date") {
updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null;
}
const updateResult = await updateFeedbackRecordAction({
workspaceId,
recordId,
updateInput: updatePayload as never,
});
if (!updateResult?.data) {
toast.error(getFormattedErrorMessage(updateResult));
setIsSubmitting(false);
return;
}
}
toast.success(
mode === "create"
? t("workspace.unify.feedback_record_created_successfully")
: t("workspace.unify.feedback_record_updated_successfully")
);
await onSuccess();
onOpenChange(false);
} finally {
setIsSubmitting(false);
}
});
const drawerTitle =
mode === "create"
? t("workspace.unify.add_feedback_record")
: t("workspace.unify.feedback_record_details");
const drawerDescription =
mode === "create"
? t("workspace.unify.add_feedback_record_description")
: t("workspace.unify.feedback_record_details_description");
const valueBooleanStatus = form.watch("value_boolean");
let valueBooleanLabel = t("common.not_set");
if (valueBooleanStatus === true) {
valueBooleanLabel = t("common.yes");
} else if (valueBooleanStatus === false) {
valueBooleanLabel = t("common.no");
}
return (
<>
<Sheet open={open} onOpenChange={handleDrawerOpenChange}>
<SheetContent className="w-full overflow-y-auto bg-white px-5 sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{drawerTitle}</SheetTitle>
<SheetDescription>{drawerDescription}</SheetDescription>
</SheetHeader>
{isLoadingRecord ? (
<div className="py-8 text-sm text-slate-500">{t("common.loading")}</div>
) : (
<FormProvider {...form}>
<form className="space-y-4 py-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.id")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tenant_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled>
<SelectTrigger>
<SelectValue
placeholder={t("workspace.unify.select_feedback_record_directory")}
/>
</SelectTrigger>
<SelectContent>
{directories.map((directory) => (
<SelectItem key={directory.id} value={directory.id}>
{directory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="submission_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.submission_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="collected_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.collected_at")}</FormLabel>
<FormControl>
<Input {...field} type="datetime-local" disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="created_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.created_at")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="updated_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.updated_at")}</FormLabel>
<FormControl>
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
</FormControl>
</FormItem>
)}
/>
</div>
{isEditMode ? (
<FormField
control={form.control}
name="source_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
<FormControl>
<Input {...field} value={formatSourceType(field.value, t)} disabled />
</FormControl>
</FormItem>
)}
/>
) : (
<div className="space-y-2">
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
<Select
value={sourceTypeMode}
onValueChange={(value) => {
setSourceTypeMode(value);
if (value !== SOURCE_TYPE_CUSTOM_VALUE) {
form.setValue("source_type", value, { shouldDirty: true });
}
}}
disabled={!canWrite}>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_feedback_record_source_type")} />
</SelectTrigger>
<SelectContent>
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
{t("workspace.unify.custom_source_type")}
</SelectItem>
</SelectContent>
</Select>
{sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && (
<Input
value={customSourceType}
onChange={(event) => {
setCustomSourceType(event.target.value);
form.setValue("source_type", event.target.value, { shouldDirty: true });
}}
placeholder={t("workspace.unify.custom_source_type_placeholder")}
disabled={!canWrite}
/>
)}
<FormError>{form.formState.errors.source_type?.message}</FormError>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="source_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="source_name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="field_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="field_label"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_label")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="field_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_type")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as TFeedbackRecordFormValues["field_type"])
}
disabled={isEditMode || !canWrite}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="field_group_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_group_id")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="field_group_label"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.field_group_label")}</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode || !canWrite} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="value_text"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_text")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
disabled={selectedValueField !== "value_text" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="value_number"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_number")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="number"
step="any"
disabled={selectedValueField !== "value_number" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="value_date"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_date")}</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
type="datetime-local"
disabled={selectedValueField !== "value_date" || isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="value_boolean"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.value_boolean")}</FormLabel>
<FormControl>
<div className="flex items-center gap-3 rounded-md border border-slate-200 px-3 py-2">
<Switch
checked={field.value ?? false}
onCheckedChange={(checked) => field.onChange(checked)}
disabled={selectedValueField !== "value_boolean" || isReadOnly || !canWrite}
/>
<span className="text-sm text-slate-600">{valueBooleanLabel}</span>
</div>
</FormControl>
</FormItem>
)}
/>
<FormError>{form.formState.errors[selectedValueField]?.message}</FormError>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<Input {...field} disabled={!canWrite || isReadOnly} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_identifier"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.user_identifier")}</FormLabel>
<FormControl>
<Input {...field} disabled={!canWrite || isReadOnly} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>{t("workspace.unify.metadata")}</FormLabel>
{canWrite && !isReadOnly && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => append({ key: "", value: "" })}>
<PlusIcon className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</div>
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
<FormField
control={form.control}
name={`metadataEntries.${index}.key`}
render={({ field: entryField }) => (
<FormItem>
<FormControl>
<Input
{...entryField}
placeholder={t("workspace.unify.metadata_key")}
disabled={isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`metadataEntries.${index}.value`}
render={({ field: entryField }) => (
<FormItem>
<FormControl>
<Input
{...entryField}
placeholder={t("workspace.unify.metadata_value")}
disabled={isReadOnly || !canWrite}
/>
</FormControl>
</FormItem>
)}
/>
{canWrite && !isReadOnly && (
<Button type="button" variant="outline" onClick={() => remove(index)}>
{t("common.delete")}
</Button>
)}
</div>
))}
</div>
{readOnlyMetadataEntries.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-slate-500">
{t("workspace.unify.metadata_read_only_entries")}
</p>
{readOnlyMetadataEntries.map((entry) => (
<div
key={entry.key}
className="grid grid-cols-2 gap-2 rounded-md bg-slate-50 p-2 text-xs">
<span className="font-medium text-slate-700">{entry.key}</span>
<span className="truncate text-slate-600" title={entry.value}>
{entry.value}
</span>
</div>
))}
</div>
)}
</div>
</form>
</FormProvider>
)}
<SheetFooter className="mt-2">
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
{canWrite && (
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog
open={isDiscardDialogOpen}
setOpen={setIsDiscardDialogOpen}
headerText={t("workspace.unify.discard_feedback_record_changes_title")}
mainText={t("workspace.unify.discard_feedback_record_changes_description")}
confirmBtnLabel={t("common.discard")}
declineBtnLabel={t("common.cancel")}
declineBtnVariant="outline"
onDecline={() => setIsDiscardDialogOpen(false)}
onConfirm={handleDiscardChanges}
/>
</>
);
};
@@ -11,22 +11,32 @@ interface FeedbackRecordsPageClientProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
export function FeedbackRecordsPageClient({
workspaceId,
initialRecords,
frdMap,
}: FeedbackRecordsPageClientProps) {
csvSources,
canWrite,
}: Readonly<FeedbackRecordsPageClientProps>) {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.unify.unify_feedback")}>
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
</PageHeader>
<FeedbackRecordsTable workspaceId={workspaceId} initialRecords={initialRecords} frdMap={frdMap} />
<FeedbackRecordsTable
workspaceId={workspaceId}
initialRecords={initialRecords}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
</PageContentWrapper>
);
}
@@ -3,13 +3,16 @@
import { TFunction } from "i18next";
import {
CalendarIcon,
ChevronDownIcon,
HashIcon,
MessageSquareTextIcon,
PlusIcon,
RefreshCwIcon,
ToggleLeftIcon,
TypeIcon,
} from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
@@ -18,7 +21,16 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { CsvImportModal } from "../sources/components/csv-import-modal";
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
const RECORDS_PER_PAGE = 50;
@@ -42,6 +54,18 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string):
return "—";
};
const formatSourceType = (sourceType: string, t: TFunction): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return sourceType;
}
};
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "…";
@@ -51,13 +75,48 @@ interface FeedbackRecordsTableProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
export const FeedbackRecordsTable = ({ workspaceId, initialRecords, frdMap }: FeedbackRecordsTableProps) => {
export const FeedbackRecordsTable = ({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsTableProps>) => {
const { t, i18n } = useTranslation();
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
const directories = useMemo(
() =>
Object.entries(frdMap)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name)),
[frdMap]
);
const feedbackDirectoryName = useMemo(() => {
const directoryNames = Array.from(
new Set(
records
.map((record) => frdMap[record.tenant_id])
.filter((directoryName): directoryName is string => Boolean(directoryName))
)
);
if (directoryNames.length > 0) {
return directoryNames.join(", ");
}
return directories[0]?.name ?? "—";
}, [directories, frdMap, records]);
const handleRefresh = async () => {
if (isRefreshing) return;
@@ -65,21 +124,33 @@ export const FeedbackRecordsTable = ({ workspaceId, initialRecords, frdMap }: Fe
setError(null);
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
const directoryIds = Object.keys(frdMap);
const results = await Promise.all(
directoryIds.map((frdId) =>
listFeedbackRecordsAction({
workspaceId,
frdId,
limit: RECORDS_PER_PAGE,
})
)
);
const result = await listFeedbackRecordsAction({
workspaceId,
limit: RECORDS_PER_PAGE,
});
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
if (!result?.data) {
toast.error(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"), {
if (directoryIds.length > 0 && successfulRecords.length === 0) {
const firstErrorResult = results.find((result) => !result?.data);
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
id: toastId,
});
setIsRefreshing(false);
return;
}
setRecords(result.data.data);
const mergedRecords = successfulRecords
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, RECORDS_PER_PAGE);
setRecords(mergedRecords);
setIsRefreshing(false);
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
};
@@ -100,98 +171,193 @@ export const FeedbackRecordsTable = ({ workspaceId, initialRecords, frdMap }: Fe
const isEmpty = records.length === 0 && !isRefreshing;
return (
<div className="space-y-3">
{!isEmpty && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", { count: records.length })}
</p>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={t("workspace.unify.refresh_feedback_records")}>
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
)}
const openEditDrawer = (recordId: string) => {
setDrawerMode("edit");
setDrawerRecordId(recordId);
setIsDrawerOpen(true);
};
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px]">
<thead>
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">
{t("workspace.unify.feedback_record_directory")}
</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
</tr>
</thead>
{isEmpty ? (
<tbody>
<tr>
<td colSpan={8}>
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
</div>
</td>
const openCreateDrawer = () => {
setDrawerMode("create");
setDrawerRecordId(undefined);
setIsDrawerOpen(true);
};
const hasCsvSources = csvSources.length > 0;
return (
<>
<div className="space-y-3">
<div className="flex items-center justify-between">
{isEmpty ? (
<span />
) : (
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", {
count: records.length,
directoryName: feedbackDirectoryName,
})}
</p>
)}
<div className="flex items-center gap-2">
{canWrite &&
(hasCsvSources ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary">
<PlusIcon className="h-4 w-4" />
{t("workspace.unify.add_feedback_record")}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openCreateDrawer}>
{t("workspace.unify.add_feedback_record")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{csvSources.map((source) => (
<DropdownMenuItem
key={source.id}
onClick={() => {
setCsvImportSource(source);
}}>
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
<PlusIcon className="h-4 w-4" />
{t("workspace.unify.add_feedback_record")}
</Button>
))}
<Button size="sm" asChild>
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
{t("workspace.unify.manage_feedback_sources")}
</Link>
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={t("workspace.unify.refresh_feedback_records")}>
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px]">
<thead>
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
</tr>
</tbody>
) : (
<tbody className="divide-y divide-slate-100">
{records.map((record) => (
<FeedbackRecordRow
key={record.id}
record={record}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
frdName={record.tenant_id ? (frdMap[record.tenant_id] ?? "—") : "—"}
t={t}
/>
))}
</tbody>
)}
</table>
</thead>
{isEmpty ? (
<tbody>
<tr>
<td colSpan={7}>
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
</div>
</td>
</tr>
</tbody>
) : (
<tbody className="divide-y divide-slate-100">
{records.map((record) => (
<FeedbackRecordRow
key={record.id}
record={record}
workspaceId={workspaceId}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
t={t}
onClick={() => openEditDrawer(record.id)}
/>
))}
</tbody>
)}
</table>
</div>
</div>
</div>
</div>
<FeedbackRecordFormDrawer
mode={drawerMode}
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
workspaceId={workspaceId}
directories={directories}
canWrite={canWrite}
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
onSuccess={handleRefresh}
/>
{csvImportSource && (
<CsvImportModal
open={csvImportSource !== null}
onOpenChange={(open) => {
if (!open) {
setCsvImportSource(null);
}
}}
connectorId={csvImportSource.id}
workspaceId={workspaceId}
/>
)}
</>
);
};
const FeedbackRecordRow = ({
record,
workspaceId,
locale,
frdName,
t,
onClick,
}: {
record: FeedbackRecordData;
workspaceId: string;
locale: string;
frdName: string;
t: TFunction;
onClick: () => void;
}) => {
const value = formatValue(record, t, locale);
const isLongValue = value.length > 60;
const isFormbricksSurveySource =
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
return (
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
<tr
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
onClick={onClick}>
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
</td>
<td className="max-w-[200px] truncate px-4 py-3 text-slate-600" title={frdName}>
{frdName}
</td>
<td className="whitespace-nowrap px-4 py-3">
<Badge text={record.source_type} type="gray" size="tiny" />
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
</td>
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
{record.source_name ?? "—"}
{isFormbricksSurveySource ? (
<Link
href={surveySummaryHref}
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
onClick={(event) => event.stopPropagation()}>
{record.source_name ?? "—"}
</Link>
) : (
<span>{record.source_name ?? "—"}</span>
)}
</td>
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
{record.field_label ?? record.field_id}
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
@@ -7,7 +8,9 @@ import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
const INITIAL_PAGE_SIZE = 50;
export default async function UnifyFeedbackRecordsPage(props: { params: Promise<{ workspaceId: string }> }) {
export default async function UnifyFeedbackRecordsPage(
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) {
const t = await getTranslate();
const params = await props.params;
@@ -19,11 +22,15 @@ export default async function UnifyFeedbackRecordsPage(props: { params: Promise<
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId);
const [frds, connectors] = await Promise.all([
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
getConnectorsWithMappings(params.workspaceId),
]);
const results = await Promise.all(
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
@@ -38,8 +45,17 @@ export default async function UnifyFeedbackRecordsPage(props: { params: Promise<
.slice(0, INITIAL_PAGE_SIZE);
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
const csvSources = connectors
.filter((connector) => connector.type === "csv")
.map((connector) => ({ id: connector.id, name: connector.name }));
return (
<FeedbackRecordsPageClient workspaceId={params.workspaceId} initialRecords={merged} frdMap={frdMap} />
<FeedbackRecordsPageClient
workspaceId={params.workspaceId}
initialRecords={merged}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
);
}
@@ -2,5 +2,5 @@ import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
const params = await props.params;
redirect(`/workspaces/${params.workspaceId}/unify/sources`);
redirect(`/workspaces/${params.workspaceId}/unify/feedback-records`);
}
@@ -173,16 +173,11 @@ export function ConnectorsSection({
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
cta={
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
workspaceId={workspaceId}
directories={directories}
/>
}>
buttonInfo={{
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}}>
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
@@ -204,6 +199,16 @@ export function ConnectorsSection({
)}
</SettingsCard>
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
workspaceId={workspaceId}
directories={directories}
showTrigger={false}
/>
<EditConnectorModal
connector={editingConnector}
open={editingConnector !== null}
@@ -57,6 +57,7 @@ import { FormbricksQuestionList } from "./formbricks-question-list";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showTrigger?: boolean;
onCreateConnector: (data: {
name: string;
type: TConnectorType;
@@ -116,6 +117,7 @@ const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
export const CreateConnectorModal = ({
open,
onOpenChange,
showTrigger = true,
onCreateConnector,
surveys,
workspaceId,
@@ -391,10 +393,12 @@ export const CreateConnectorModal = ({
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
{t("workspace.unify.add_source")}
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
{showTrigger && (
<Button onClick={() => onOpenChange(true)} size="sm">
{t("workspace.unify.add_source")}
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
)}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
@@ -4,7 +4,6 @@ import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -22,11 +21,12 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { handleIntegrations } from "@/modules/response-pipeline/lib/handle-integrations";
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
import { sendTelemetryEvents } from "@/modules/response-pipeline/lib/telemetry";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -0,0 +1,98 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getResponseIdByDisplayId } from "./response";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findFirst: vi.fn(),
},
},
}));
describe("getResponseIdByDisplayId", () => {
const workspaceId = "ws1234567890123456789012";
const displayId = "display1234567890123456789";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the linked responseId when a response exists", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: {
id: "response123456789012345678",
},
} as any);
const result = await getResponseIdByDisplayId(workspaceId, displayId);
expect(validateInputs).toHaveBeenCalledWith(
[workspaceId, expect.any(Object)],
[displayId, expect.any(Object)]
);
expect(prisma.display.findFirst).toHaveBeenCalledWith({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
expect(result).toEqual({ responseId: "response123456789012345678" });
});
test("returns null when the display exists but has no response", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: null,
} as any);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).resolves.toEqual({
responseId: null,
});
});
test("throws ResourceNotFoundError when the display does not exist in the workspace", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(
new ResourceNotFoundError("Display", displayId)
);
});
test("throws ValidationError when input validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(ValidationError);
expect(prisma.display.findFirst).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(DatabaseError);
});
});
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
workspaceId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([workspaceId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,49 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ workspaceId: string; displayId: string }> }>) => {
const params = await props.params;
const resolved = await resolveClientApiIds(params.workspaceId);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", params.workspaceId, true),
};
}
const { workspaceId } = resolved;
try {
const response = await getResponseIdByDisplayId(workspaceId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, workspaceId, displayId: params.displayId },
"Error in GET /api/v1/client/[workspaceId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -33,10 +33,13 @@ vi.mock("@formbricks/logger", () => ({
vi.mock("./data");
vi.mock("@/app/lib/api/api-backwards-compat", () => ({
addLegacyProjectOverwritesToList: vi.fn((surveys: unknown[]) =>
surveys.map((s: Record<string, unknown>) => ({
...s,
projectOverwrites: s.workspaceOverwrites ?? null,
}))
surveys.map((survey) => {
const typedSurvey = survey as Record<string, unknown>;
return {
...typedSurvey,
projectOverwrites: typedSurvey.workspaceOverwrites ?? null,
};
})
),
addLegacyProjectToEnvironmentState: vi.fn((data: Record<string, unknown>) => ({
...data,
@@ -129,7 +132,7 @@ const mockActionClasses = [
description: null,
type: "code",
noCodeConfig: null,
environmentId: workspaceId,
workspaceId,
key: "action1",
},
] as unknown as TActionClass[];
@@ -126,7 +126,6 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
+22 -31
View File
@@ -15,20 +15,14 @@ const apiKeySelect = {
lastUsedAt: true,
apiKeyWorkspaces: {
select: {
environment: {
workspace: {
select: {
id: true,
type: true,
legacyEnvironmentId: true,
createdAt: true,
updatedAt: true,
workspaceId: true,
name: true,
appSetupCompleted: true,
workspace: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
@@ -44,17 +38,13 @@ type ApiKeyData = {
lastUsedAt: Date | null;
apiKeyWorkspaces: Array<{
permission: string;
environment: {
workspace: {
id: string;
type: string;
legacyEnvironmentId: string | null;
createdAt: Date;
updatedAt: Date;
workspaceId: string;
name: string;
appSetupCompleted: boolean;
workspace: {
id: string;
name: string;
};
};
}>;
};
@@ -116,21 +106,24 @@ const updateApiKeyUsage = async (apiKeyId: string) => {
});
};
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyWorkspaces[0].environment;
const buildWorkspaceResponse = (apiKeyData: ApiKeyData) => {
const workspace = apiKeyData.apiKeyWorkspaces[0].workspace;
return Response.json({
id: env.id,
type: env.type,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
// Keep v1 payload shape stable while sourcing data from workspace.
id: workspace.legacyEnvironmentId ?? workspace.id,
type: "production",
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
appSetupCompleted: workspace.appSetupCompleted,
workspace: {
id: env.workspaceId,
name: env.workspace.name,
id: workspace.id,
name: workspace.name,
},
// Backwards compat: old consumers expect project fields
projectId: env.workspaceId,
projectName: env.workspace.name,
project: {
id: workspace.id,
name: workspace.name,
},
});
};
@@ -157,14 +150,12 @@ const handleApiKeyAuthentication = async (apiKey: string) => {
});
}
const rateLimitError = await checkRateLimit(apiKeyData.id);
if (rateLimitError) return rateLimitError;
// Rate limiting for apiKey auth is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
if (!isValidApiKeyEnvironment(apiKeyData)) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return buildEnvironmentResponse(apiKeyData);
return buildWorkspaceResponse(apiKeyData);
};
const handleSessionAuthentication = async () => {
@@ -73,7 +73,6 @@ const validateSurvey = async (responseInput: TResponseInput, workspaceId: string
error: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -1,43 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const workspaceId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
workspaceId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
@@ -56,66 +29,20 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
});
test("should handle generic errors during deletion", async () => {
test("rethrows shared delete service errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
mockDeleteSharedSurvey.mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
});
});
@@ -1,43 +1,3 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
@@ -1,5 +1,6 @@
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
@@ -78,6 +79,12 @@ export const GET = withV1ApiWrapper({
),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return {
response: handleErrorResponse(error),
};
@@ -1,141 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getWorkspaceState: vi.fn(),
resolveClientApiIds: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[workspaceId]/environment/lib/environmentState", () => ({
getWorkspaceState: mocks.getWorkspaceState,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: "ck12345678901234567890123" });
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getWorkspaceState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("../../../../v1/client/[workspaceId]/environment/route");
const response = await GET(request, {
params: Promise.resolve({
workspaceId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -1,132 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
reportApiError: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromWorkspaceId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -178,10 +178,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -131,6 +131,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}
@@ -92,6 +92,7 @@ const mockSurvey: TSurvey = {
isCaptureIpEnabled: false,
metadata: {},
slug: null,
isAutoProgressingEnabled: true,
};
const mockResponseInput: TResponseInputV2 = {
@@ -126,7 +127,6 @@ describe("checkSurveyValidity", () => {
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another workspace",
{
"survey.workspaceId": "ws-2",
workspaceId: "ws-1",
},
true
@@ -20,7 +20,6 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -1,150 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[workspaceId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ workspaceId: environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,5 @@
import { UAParser } from "ua-parser-js";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[workspaceId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -164,6 +164,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
+132
View File
@@ -9,6 +9,22 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
@@ -25,6 +41,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
@@ -45,6 +69,114 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks();
});
test("passes an audit log to the handler and queues success after the response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ auditLog }) => {
expect(auditLog).toEqual(
expect.objectContaining({
action: "deleted",
targetType: "survey",
userId: "user_1",
userType: "user",
status: "failure",
})
);
if (auditLog) {
auditLog.targetId = "survey_1";
auditLog.organizationId = "org_1";
auditLog.oldObject = { id: "survey_1" };
}
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
method: "DELETE",
headers: { "x-request-id": "req-audit" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: { id: "survey_1" },
})
);
});
test("queues a failure audit log when the handler returns a non-ok response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.targetId = "survey_2";
}
return new Response("forbidden", { status: 403 });
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
method: "DELETE",
headers: {
"x-request-id": "req-failure-audit",
"x-api-key": "fbk_test",
},
}),
{} as never
);
expect(response.status).toBe(403);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_2",
organizationId: "org_1",
userId: "key_1",
userType: "api",
status: "failure",
eventId: "req-failure-audit",
})
);
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+76 -2
View File
@@ -4,10 +4,13 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -15,7 +18,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -48,6 +52,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -293,10 +299,61 @@ async function applyV3RateLimitOrRespond(params: {
return null;
}
function buildV3AuditLog(
authentication: TV3Authentication,
action?: TAuditAction,
targetType?: TAuditTarget,
apiUrl?: string
): TV3AuditLog | undefined {
if (!authentication || !action || !targetType || !apiUrl) {
return undefined;
}
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
} else if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
return auditLog;
}
async function queueV3AuditLog(
auditLog: TV3AuditLog | undefined,
requestId: string,
log: ReturnType<typeof logger.withContext>
): Promise<void> {
if (!auditLog) {
return;
}
try {
await queueAuditEvent({
...auditLog,
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
});
} catch (error) {
log.error({ error }, "Failed to queue V3 audit event");
}
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -306,6 +363,7 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
method: req.method,
path: instance,
});
let auditLog: TV3AuditLog | undefined;
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -331,17 +389,33 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse;
}
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({
req,
props,
authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId);
} catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
+25
View File
@@ -7,6 +7,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -93,3 +94,27 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
describe("successResponse", () => {
test("wraps the payload in a data envelope", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("allows custom status and cache headers", async () => {
const res = successResponse(
{ ok: true },
{
cache: "private, max-age=60",
status: 202,
}
);
expect(res.status).toBe(202);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
+24
View File
@@ -147,3 +147,27 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
}
return Response.json({ data, meta }, { status: 200, headers });
}
export function successResponse<T>(
data: T,
options?: { requestId?: string; cache?: string; status?: number }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: options?.status ?? 200,
headers,
}
);
}
+2
View File
@@ -1,4 +1,6 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -0,0 +1,318 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
@@ -0,0 +1,72 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.organizationId = authResult.organizationId;
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+1 -1
View File
@@ -314,8 +314,8 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("ws_1");
});
@@ -93,17 +93,17 @@ describe("parseAndValidateJsonBody", () => {
request,
schema: z.object({
finished: z.boolean(),
environmentId: z.string(),
workspaceId: z.string(),
}),
buildInput: (jsonInput) => ({
...(jsonInput as Record<string, unknown>),
environmentId: "env_123",
workspaceId: "ws_123",
}),
});
expect(result).toEqual({
data: {
environmentId: "env_123",
workspaceId: "ws_123",
finished: true,
},
});
+50
View File
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
+27 -1
View File
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests";
| "too_many_requests"
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};
+140 -19
View File
@@ -3,9 +3,16 @@ import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import type { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
const AuthMethod = {
ApiKey: "apiKey" as AuthenticationMethod,
Session: "session" as AuthenticationMethod,
Both: "both" as AuthenticationMethod,
None: "none" as AuthenticationMethod,
} as const;
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -122,7 +129,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -198,7 +205,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -244,7 +251,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -318,7 +325,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -370,7 +377,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -425,7 +432,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -449,7 +456,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -473,6 +480,90 @@ describe("withV1ApiWrapper", () => {
});
});
test("skips app rate limiting for Envoy-covered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "POST", url: "/api/v1/client/env_123/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "GET", url: "/api/v2/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered verbs on otherwise covered client paths", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "PATCH", url: "/api/v1/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -481,7 +572,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -504,7 +595,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
authenticationMethod: AuthMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
@@ -528,7 +619,36 @@ describe("withV1ApiWrapper", () => {
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
test("keeps app rate limiting for uncovered session-authenticated management routes", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.Both,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } } as any);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ method: "POST", url: "https://api.test/api/v1/management/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const customRateLimitConfig = { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" };
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
expect(applyRateLimit).toHaveBeenCalledWith(customRateLimitConfig, "user-1");
});
test("skips app rate limiting for Envoy-covered API-key management routes", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -538,21 +658,22 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const handler = vi.fn();
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(applyRateLimit).not.toHaveBeenCalled();
});
test("skips audit log creation when no action/targetType provided", async () => {
@@ -566,7 +687,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
+44 -6
View File
@@ -13,6 +13,10 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
TEnvoyRateLimitAuthType,
isRouteRateLimitedByEnvoy,
} from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
@@ -61,29 +65,58 @@ const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): P
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
};
const getEnvoyRateLimitAuthType = (
authentication: TApiV1Authentication
): TEnvoyRateLimitAuthType | "unknown" => {
if (!authentication) {
return "none";
}
if ("user" in authentication) {
return "session";
}
if ("apiKeyId" in authentication) {
return "apiKey";
}
return "unknown";
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
req: NextRequest,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => {
const authType = getEnvoyRateLimitAuthType(authentication);
if (authType === "unknown") {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
const isEnvoyManagedRateLimit = isRouteRateLimitedByEnvoy({
pathname: req.nextUrl.pathname,
method: req.method,
authType,
});
try {
if (authentication) {
if (authentication && !isEnvoyManagedRateLimit) {
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
} else if ("apiKeyId" in authentication) {
// API key authentication for general routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
}
if (routeType === ApiV1RouteTypeEnum.Client) {
if (routeType === ApiV1RouteTypeEnum.Client && !isEnvoyManagedRateLimit) {
await applyClientRateLimit(customRateLimitConfig);
}
} catch (error) {
@@ -286,7 +319,12 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}
+64 -15
View File
@@ -3,7 +3,9 @@ import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyCesElement,
TSurveyConsentElement,
TSurveyCsatElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
@@ -96,7 +98,8 @@ export const buildOpenTextElement = ({
};
};
export const buildRatingElement = ({
const buildScaleElement = <T extends TSurveyRatingElement | TSurveyCsatElement | TSurveyCesElement>({
type,
id,
headline,
subheader,
@@ -107,6 +110,32 @@ export const buildRatingElement = ({
required,
isColorCodingEnabled = false,
}: {
type: T["type"];
id?: string;
headline: string;
scale: T["scale"];
range: T["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): T => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
} as T;
};
export const buildRatingElement = (params: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
@@ -116,20 +145,8 @@ export const buildRatingElement = ({
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
}): TSurveyRatingElement =>
buildScaleElement<TSurveyRatingElement>({ ...params, type: TSurveyElementTypeEnum.Rating });
export const buildConsentElement = ({
id,
@@ -212,6 +229,38 @@ export const buildNPSElement = ({
};
};
export const buildCsatElement = ({
scale = "smiley",
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCsatElement["scale"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCsatElement =>
buildScaleElement<TSurveyCsatElement>({ ...params, scale, range: 5, type: TSurveyElementTypeEnum.CSAT });
export const buildCesElement = ({
scale = "number",
range = 5,
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCesElement["scale"];
range?: TSurveyCesElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCesElement =>
buildScaleElement<TSurveyCesElement>({ ...params, scale, range, type: TSurveyElementTypeEnum.CES });
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
+6
View File
@@ -30,6 +30,8 @@ const conditionOptions: Record<string, string[]> = {
multipleChoiceMulti: ["Includes all", "Includes either"],
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped", "Includes either"],
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
csat: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
ces: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
cta: ["is"],
tags: ["is"],
languages: ["Equals", "Not equals"],
@@ -45,6 +47,8 @@ const filterOptions: Record<string, string[]> = {
openText: ["Filled out", "Skipped"],
rating: ["1", "2", "3", "4", "5"],
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
csat: ["1", "2", "3", "4", "5"],
ces: ["1", "2", "3", "4", "5", "6", "7"],
cta: ["Clicked", "Dismissed"],
tags: ["Applied", "Not applied"],
consent: ["Accepted", "Dismissed"],
@@ -436,6 +440,8 @@ const processElementFilters = (
break;
case TSurveyElementTypeEnum.NPS:
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.CSAT:
case TSurveyElementTypeEnum.CES:
processNPSRatingFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.CTA:
+15 -14
View File
@@ -7,7 +7,9 @@ import type { TTemplate } from "@formbricks/types/templates";
import {
buildBlock,
buildCTAElement,
buildCesElement,
buildConsentElement,
buildCsatElement,
buildMultipleChoiceElement,
buildNPSElement,
buildOpenTextElement,
@@ -971,13 +973,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.improve_trial_conversion_question_2_headline"),
headline: t("templates.improve_trial_conversion_question_3_headline"),
required: true,
inputType: "text",
}),
],
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")],
buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"),
buttonLabel: t("templates.improve_trial_conversion_question_3_button_label"),
t,
}),
buildBlock({
@@ -1319,8 +1321,7 @@ const employeeSatisfaction = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
range: 5,
buildCsatElement({
scale: "star",
headline: t("templates.employee_satisfaction_question_1_headline"),
required: true,
@@ -1647,14 +1648,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [
buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: "What's your primary goal for using $[workspaceName]?",
headline: t("templates.identify_customer_goals_question_1_headline"),
required: true,
shuffleOption: "none",
choices: [
"Understand my user base deeply",
"Identify upselling opportunities",
"Build the best possible product",
"Rule the world to make everyone breakfast brussels sprouts.",
t("templates.identify_customer_goals_question_1_choice_1"),
t("templates.identify_customer_goals_question_1_choice_2"),
t("templates.identify_customer_goals_question_1_choice_3"),
t("templates.identify_customer_goals_question_1_choice_4"),
],
}),
],
@@ -2723,7 +2724,7 @@ const customerEffortScore = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
buildCesElement({
range: 5,
scale: "number",
headline: t("templates.customer_effort_score_question_1_headline"),
@@ -3828,9 +3829,8 @@ const improveNewsletterContent = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
buildCsatElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.improve_newsletter_content_question_1_headline"),
required: true,
@@ -4409,8 +4409,7 @@ const longTermRetentionCheckIn = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_9"),
elements: [
buildRatingElement({
range: 5,
buildCsatElement({
scale: "smiley",
headline: t("templates.long_term_retention_check_in_question_9_headline"),
required: true,
@@ -4825,6 +4824,8 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
workspaceId: "cmnh38nzx00003b6r3svd9pv2",
createdBy: "cltwumfbz0000echxysz6ptvq",
status: "inProgress" as const,
publishOn: null,
closeOn: null,
welcomeCard: {
enabled: false,
headline: createI18nString(t("templates.preview_survey_welcome_card_headline"), []),
@@ -121,13 +121,10 @@ export const DELETE = async (
: responses.notAuthenticatedResponse();
}
if (authResult.ok) {
// Rate limiting for apiKey DELETE is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
if (authResult.ok && authResult.data.authType !== "apiKey") {
try {
if (authResult.data.authType === "apiKey") {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
} else {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
}
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Unknown error occurred"
@@ -142,20 +139,20 @@ export const DELETE = async (
idParam
);
const isSuccess = deleteResult.ok;
if (!deleteResult.ok) {
const { error } = deleteResult;
if (!isSuccess) {
logger.error({ error: deleteResult.error }, "Error deleting file");
logger.error({ error }, "Error deleting file");
await logFileDeletion({
failureReason: deleteResult.error.code,
failureReason: error.code,
accessType,
userId: session?.user?.id,
workspaceId: resolved.workspaceId,
apiUrl: request.url,
});
const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
const errorResponse = getErrorResponseFromStorageError(error, { fileName });
return errorResponse;
}
+1
View File
@@ -19,6 +19,7 @@
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW"
]
+325 -54
View File
@@ -98,6 +98,9 @@ checksums:
common/activity: 1948763de8e531483a798b68195e297e
common/add: 87c4a663507f2bcbbf79934af8164e13
common/add_action: 66fefc4dd6a7b939c2224272cf0d2669
common/add_chart: 0c8539d3ccc83fce87bb1e0dc3e30005
common/add_charts: c377a42e165e8ab67bfbb8ad72026dd8
common/add_existing_chart_description: b1292a1d6df2e03ad7b399689312c37f
common/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
common/add_logo: c8665aa9afd0d5a13528bdc96daefa53
common/add_member: 11979625770516ca287e929381778e02
@@ -109,6 +112,7 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
common/api_keys: f961b547cd312cc8b9b79f0c9e0b2cc3
@@ -127,6 +131,9 @@ checksums:
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choice_n: a6965b8fb3e479e94175b3826839d9ae
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
common/choose_workspace: f9ed22d76c69cc75aa56cf3da3fa6320
@@ -139,8 +146,9 @@ checksums:
common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/column_n: b98315f0e504fad7e784d77f153a7d9d
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configuration: 923ec0502721489202f6222dd4107163
common/configuration: e3ab18ebb36c218cd4897c620f5809ac
common/confirm: 90930b51154032f119fa75c1bd422d8b
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
@@ -169,6 +177,8 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -196,7 +206,6 @@ checksums:
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
@@ -208,6 +217,7 @@ checksums:
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
common/field_placeholder: 1fedb1aab1a4d42ad49ddece6d8df372
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
@@ -219,10 +229,13 @@ checksums:
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
common/go_back: b917ea82facb90c88c523b255d29f84b
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
common/headline: 0023cbe059bbadcc77312825cbbce5ac
common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide: a6088b934651055bb27314d111be510b
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/html: f750870203043349d570d8f5865ca0f8
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
@@ -268,9 +281,9 @@ checksums:
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
common/months: da74749fbe80394fa0f72973d7b0964a
common/more_options: 53d90eae6a9b0243b5bc043b3d9de169
common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
common/my_product: ad022177062f9ef6e9acf33b13e889aa
common/name: 9368b5a047572b6051f334af5aa76819
common/new: 126d036fae5fb6b629728ecb97e6195b
@@ -279,6 +292,7 @@ checksums:
common/no: 8c708225830b06df2d1141c536f2a0d6
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_changes: 17709e3e2fbd133ddb8b3291d13de7f6
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
common/no_overlay: 03cde9e91f08e4dd539d788e1e01407f
@@ -286,10 +300,12 @@ checksums:
common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
common/no_surveys_found: 7b74706fe4f4aacd7d858e19e444fe85
common/no_text_found: 27350f35bdd57b3701c7ec578a1a0e11
common/none_of_the_above: e007f0b1e046d5ddbbcfbd87940456ee
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
common/not_connected: 91ebf07fff6b2ead94d85bd17212e0ba
common/not_set: 380482630d60ee2d1531b31246caa467
common/note: e0337f202c911423275f834edeffc54b
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
common/number: 2789f8391f63e7200a5521078aab017d
@@ -300,6 +316,7 @@ checksums:
common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
common/optional: 396fb9a0472daf401c392bdc3e248943
@@ -309,7 +326,7 @@ checksums:
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
@@ -327,10 +344,8 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
common/profile: d7878693f91303a438852d617f6d35df
common/profile_id: 0ef1286cce9d47b148e9a09deccb6655
common/progress: dd0200d5849ebb7d64c15098ae91d229
@@ -342,6 +357,7 @@ checksums:
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/refresh: c0aec3f31be4c984bae9a482572d2857
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
@@ -349,19 +365,24 @@ checksums:
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
common/reset_to_default: 68ee98b46677392f44b505b268053b26
common/resize: 20887e5af5294f08bc72cdedeee6e7a8
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
common/response_id: 73375099cc976dc7203b8e27f5f709e0
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/retry: 6e44d18639560596569a1278f9c83676
common/role: 53743bbb6ca938f5b893552e839d067f
common/row_n: f90f7018a69f2d7025ad99a90bd23dc9
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
common/save_as_draft: b1b38812110113627d141db981fb1b12
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
common/save_without_scheduling: c6595873d611e6d1786fb15281158467
common/saving: 27ad05746d65e2f3f17d327eb181725d
common/scheduled: 1283929a2810dcf6110765f387dc118e
common/search: 49dd6c21604b5e8d4153ff1aff2177e1
common/search_charts: 51c3934f12f050fb2476d62da335a65c
common/security: 4b34923fef858a2b9a4a914c3e822889
common/segment: e8908115453de180bbda7478ba4c2d50
common/segments: 271db72d5b973fbc5fadab216177eaae
@@ -386,6 +407,7 @@ checksums:
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/soon: b12e79beb0aef9414a445a1b95dd4322
common/sort_by: 8adf3dbc5668379558957662f0c43563
common/start_free_trial: e346e4ed7d138dcc873db187922369da
common/status: 4e1fcce15854d824919b4a582c697c90
@@ -393,6 +415,7 @@ checksums:
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
common/string: 4ddccc1974775ed7357f9beaf9361cec
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
common/subheader: 73a37d57cb9807e574a42bd0c7e334ed
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
common/summary: 13eb7b8a239fb4702dfdaee69100a220
common/survey: b659d270a53dada994d926e0cc6e9a54
@@ -401,6 +424,7 @@ checksums:
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
common/survey_live: d1f370505c67509e7b2759952daba20d
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
common/survey_scheduled: 704c5e76b90ea2972ad6cae50f68dcdd
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
common/surveys: 33f68ad4111b32a6361beb9d5c184533
common/table_items_deleted_successfully: 46f29d20b26ecfbaf34d4e7291e88b05
@@ -463,7 +487,6 @@ checksums:
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/yes: ec580fd11a45779b039466f1e35eed2a
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 506a6ee315d9754da7ea26929bc40f52
@@ -479,11 +502,11 @@ checksums:
emails/email_footer_text_2: 6fd719fe916a7155e1b0b88a72420717
emails/email_template_text_1: 86b06d249ef069e0fe2457fe3889c416
emails/embed_survey_preview_email_didnt_request: 895b4463965beac0df080ccdef847824
emails/embed_survey_preview_email_environment_id: 4dc6803f9b79c8a4be35a4126a582331
emails/embed_survey_preview_email_fight_spam: 71f345df8d483d5b6c2c6dbab458d0b2
emails/embed_survey_preview_email_heading: 2a1982b3aeb91476cefb62554031394a
emails/embed_survey_preview_email_subject: 3240ad34aac02860909e10187745070e
emails/embed_survey_preview_email_text: 2eceb6fdedef104db8cfad4de7fdb9aa
emails/embed_survey_preview_email_workspace_id: bafef925e1b57b52a69844fdf47aac3c
emails/forgot_password_email_change_password: fe6d4ba303b82f4833b3293f0c4e88c0
emails/forgot_password_email_did_not_request: 79d35c3800e23e9d4c95bf33f250104f
emails/forgot_password_email_heading: fe6d4ba303b82f4833b3293f0c4e88c0
@@ -570,6 +593,7 @@ checksums:
s/question_preview: 9d8fbc0150fc10ba851beba2d4f4d9f3
s/response_already_received: 8e7b1a7d6e01a1939bca95285af77a69
s/response_submitted: d5df62e1db6012bd1283126a8dd7bad6
s/scheduled: 0ec63111fd5efe2ac240912105c5f518
s/survey_already_answered_heading: 4783c9c36ad0d4f8eec5dfeb14a04545
s/survey_already_answered_subheading: 40cfe7e8680cd4fbb7318a05d3491c78
s/survey_sent_to: 192c2b0d27e01c35953b851d3875722e
@@ -695,6 +719,10 @@ checksums:
templates/career_development_survey_question_6_choice_6: 79acaa6cd481262bea4e743a422529d2
templates/career_development_survey_question_6_headline: 88d2a87cbf2ec21882798890990c2225
templates/career_development_survey_question_6_subheader: b9b478e967930358b0c74324a7c18fc8
templates/ces: 49fc8d0ae7b82f3e7d49922ada7ab7a1
templates/ces_description: 66f4aaa7e76fd87d19c4ec3bf71481e0
templates/ces_lower_label: c2f05d3610d8879ae503a61d49e32e80
templates/ces_upper_label: b88eaddaea17a4f285209c2529a9b8f8
templates/cess_survey_name: dd706043a56d66f2895cad743935c5b4
templates/cess_survey_question_1_headline: 70115a7960746a05acef03f815652fc3
templates/cess_survey_question_1_lower_label: 586eedbc7b53319775e42c7cd4cef4de
@@ -758,7 +786,9 @@ checksums:
templates/consent_description: d76e48fb1e8c291b51e783eaf7fc910d
templates/contact_info: 73913230e8988f5f423e54e0fd43f368
templates/contact_info_description: 0e8962e628bb0a072a4217ae172db43b
templates/csat_description: 4dd35d7fecfa9fdf47765c7108c3d535
templates/csat: 6864fe0caad3b052a4ec0837e7b71cee
templates/csat_description: 0e64d5594f961e5070a95f715594549e
templates/csat_lower_label: 206c68e770b90abd737c8c4cb99aa695
templates/csat_name: f216066cef52693bbaa842a3305377c7
templates/csat_question_10_headline: b6a9ca9c6c20dced146d817c9a1e9be7
templates/csat_question_10_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
@@ -834,6 +864,7 @@ checksums:
templates/csat_survey_question_2_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/csat_upper_label: a3a49eb9cc86972bce6dc41a107f472d
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
@@ -999,6 +1030,11 @@ checksums:
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
templates/identify_customer_goals_question_1_headline: 45a7347cf3ae2d498a30ca1266898cf8
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
@@ -1073,6 +1109,8 @@ checksums:
templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_2_headline: 54324cd652667183dd3cf647ba72dd07
templates/improve_trial_conversion_question_3_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_3_headline: 8dfe1f843c8de64de7e3fa619b961152
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca
@@ -1540,7 +1578,6 @@ checksums:
workspace/actions/this_action_will_be_triggered_when_the_page_is_loaded: 8d28f30cf56f50ea79aef8dd2b02185d
workspace/actions/this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page: 60230ad129853377af1066e10f3b3c22
workspace/actions/this_action_will_be_triggered_when_the_user_tries_to_leave_the_page: 504fa734659524933e3489823d820265
workspace/actions/this_is_a_code_action_please_make_changes_in_your_code_base: 27e64863fdebca791ca129ac0c3da3d5
workspace/actions/time_in_seconds: 822be76950e5f614ed23f52ba1d4825f
workspace/actions/time_in_seconds_placeholder: e54a4c40e0c6b43fb2e97bd32cab8da8
workspace/actions/time_in_seconds_with_unit: a743b7844c71ddad364a93872682ae9e
@@ -1555,6 +1592,182 @@ checksums:
workspace/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
workspace/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
workspace/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
workspace/analysis/charts/OR: 0208d355f231c386b19390f0bea41b95
workspace/analysis/charts/add_chart_to_dashboard: c2a517ada86cdda60e49bec655ca9a6d
workspace/analysis/charts/add_chart_to_dashboard_description: 08980a1849757e9aec21fca5881c6be4
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
workspace/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
workspace/analysis/charts/chart_builder_choose_chart_type: 1376de2dcafac573a2df9e4c007b0ec8
workspace/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
workspace/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
workspace/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
workspace/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
workspace/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
workspace/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
workspace/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc
workspace/analysis/charts/chart_name_placeholder: 7370d4f88f27aea337ba1c36465c3f8b
workspace/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413
workspace/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb
workspace/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61
workspace/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
workspace/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
workspace/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
workspace/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
workspace/analysis/charts/chart_type_not_supported: c25334de42fd6192ff8355158865a3e8
workspace/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
workspace/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357
workspace/analysis/charts/configure_description: 2939321f78e4ffbc57b4259ddaddb09d
workspace/analysis/charts/configure_title: ab767b11da1d386b98b3f634f79d3abe
workspace/analysis/charts/configure_type_label: cd13e4b37fb2021af55903e7690a9856
workspace/analysis/charts/contains: 06dd606c0a8f81f9a03b414e9ae89440
workspace/analysis/charts/create_chart: 636969b904e88bef5c93e859dd8a1656
workspace/analysis/charts/create_chart_description: b9680bd8905dea180fa59a86f61de34e
workspace/analysis/charts/create_chart_with_ai: b0092b5616015a29dd51fbab49bcd4c4
workspace/analysis/charts/custom_range: 99f4d72b64621406acc162cceeb1fed7
workspace/analysis/charts/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/charts/dashboard_select_placeholder: 9b875f2f10050d650ae63be53fe0d4e8
workspace/analysis/charts/data_label: b7b46ab6ce9606032c8f81f6f6afbb9b
workspace/analysis/charts/data_source: c29cdd1967a3d1b1a39e91e14469b047
workspace/analysis/charts/date_preset_last_30_days: a738894cfc5e592052f1e16787744568
workspace/analysis/charts/date_preset_last_7_days: 3631df3109bfecfe358ba15dcf8bd6f5
workspace/analysis/charts/date_preset_last_month: 848086395b28875c050d56e3933dae61
workspace/analysis/charts/date_preset_this_month: 50845a38865204a97773c44dcd2ebb90
workspace/analysis/charts/date_preset_this_quarter: 9c77d94783dff2269c069389122cd7bd
workspace/analysis/charts/date_preset_this_year: 1e69651c2ac722f8ce138f43cf2e02f9
workspace/analysis/charts/date_preset_today: 142173f9752e18e92109623a3ee68cad
workspace/analysis/charts/date_preset_yesterday: eeb58908e68ff96c1b7e8f90e389afb7
workspace/analysis/charts/date_range: 9b3aa5954144de586931f60ef9594e99
workspace/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
workspace/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595
workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
workspace/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8
workspace/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
workspace/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50
workspace/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d
workspace/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a
workspace/analysis/charts/failed_to_add_chart_to_dashboard: 355a5606399edcbb3e6d0ba0b66f12a6
workspace/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
workspace/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
workspace/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
workspace/analysis/charts/go_to_feedback_record_directories: 1aa0516beef8adbd330cffdcab8b521f
workspace/analysis/charts/granularity: 9eb09aef092e7803ce4acb7965cbbaa9
workspace/analysis/charts/granularity_day: 47648cd60fc313bc3f05b70357a1d675
workspace/analysis/charts/granularity_hour: ec3113f22fc51d01f0c615c5496f8f87
workspace/analysis/charts/granularity_month: ae7bef950efc406ff0980affabc1a64c
workspace/analysis/charts/granularity_quarter: 7a68ec90d7c90b92b7bb873834a00381
workspace/analysis/charts/granularity_week: 436fdd694160827dd6ea4644cdd0a8f8
workspace/analysis/charts/granularity_year: ed86f5f60583f9d8ffdbeed306aa0ec7
workspace/analysis/charts/greater_than: a4c18b3b45fcaf7c83bf489cf2b506d4
workspace/analysis/charts/greater_than_or_equal: d453e26d136847560148168797fece51
workspace/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
workspace/analysis/charts/group_by_description: a4a85baaca87c172023cbe87e620118b
workspace/analysis/charts/group_data: 55c0035773d8c6b7f4d96363a61cda82
workspace/analysis/charts/is_not_set: 906801489132487ef457652af4835142
workspace/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
workspace/analysis/charts/less_than: fb41255dd44bb6de78617b078610c91b
workspace/analysis/charts/less_than_or_equal: da4a2816aadf788d33efcdcc3c61802e
workspace/analysis/charts/measures: b1e6cf0f356dda0052c4fef4ad4957a2
workspace/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
workspace/analysis/charts/no_dashboards_available: f88389b6c5278cfc4d5b360031205dfe
workspace/analysis/charts/no_dashboards_create_first: 28ded0d72247191eb23f6f77925df539
workspace/analysis/charts/no_data_available: fe1d34a45e22b5611d255b84b2d67232
workspace/analysis/charts/no_data_returned: 683acf7b4f3b32aa85fa26f1bb948d4f
workspace/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046
workspace/analysis/charts/no_data_source_available: 48179160e288de4a9e00f0bf110a5ced
workspace/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d
workspace/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9
workspace/analysis/charts/not_contains: 5894f5474271b8902d7892e43500d227
workspace/analysis/charts/not_equals: 427715f1ea349965c36f5c628784eb08
workspace/analysis/charts/open_chart: 729a54bbc4bcb3f431865af5e5a50dd4
workspace/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
workspace/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
workspace/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
workspace/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
workspace/analysis/charts/please_enter_filter_values: ca79dfab463a3836863618fd92f82b3e
workspace/analysis/charts/please_select_at_least_one_dimension: 32ea97a02bb6826947bb70389d1a6231
workspace/analysis/charts/please_select_at_least_one_measure: d4163ede267f71ee65945f453e14ff7b
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
workspace/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
workspace/analysis/charts/preview_chart: 8db30f87ba44165401f340a1ee7f549b
workspace/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
workspace/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
workspace/analysis/charts/save_and_add_to_dashboard: a76ed91c62dae10c5f8a9d48cbacd566
workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302
workspace/analysis/charts/select_data_source_first: 82a02846de9d6351595c97a0929f3b9a
workspace/analysis/charts/select_dimensions: 6d0d038d027ef9e641bf9b7700edac9f
workspace/analysis/charts/select_field: 45665a44f7d5707506364f17f28db3bf
workspace/analysis/charts/select_measures: c9f101aeb53bf0d4abdd652aaf60a1bf
workspace/analysis/charts/select_preset: e68bad9a209a6ca35c62184f1f1d829c
workspace/analysis/charts/showing_first_n_of: e9c1e76a46d0635f775a5b86bddbe1c3
workspace/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
workspace/analysis/dashboards/charts_load_failed: 190bf9c13d3c3cf18126a263591d6757
workspace/analysis/dashboards/create_dashboard: bedb308708fe9c576e161a2fa16d3439
workspace/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
workspace/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
workspace/analysis/dashboards/create_new_chart: e03c0fdf4b861454c09707d66fb9bf4c
workspace/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
workspace/analysis/dashboards/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/dashboards/dashboard_delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
workspace/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
workspace/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
workspace/analysis/dashboards/dashboard_name_required: 4a56c3ce1d73ad915815f5de4bcff566
workspace/analysis/dashboards/dashboard_save_failed: 2b6c7be7947bc7ebb0389b71b5922ba6
workspace/analysis/dashboards/dashboard_saved: 6eb27743b6b12d3d0a20b430319890b8
workspace/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
workspace/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
workspace/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
workspace/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
workspace/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
workspace/analysis/dashboards/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
workspace/analysis/dashboards/no_data_message: 464d50cf30281a5b6af2726846eb14b4
workspace/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
workspace/analysis/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
workspace/analysis/no_feedback_records_message: 67d6ebb9c040304789017d795ca474fc
workspace/analysis/no_feedback_records_with_sources_message: 4b72636a55afb4dcf977161ad5a15467
workspace/analysis/setup_feedback_source: 7cc5855a2b0c762fe2ae13b4921f3e92
workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
@@ -1565,20 +1778,24 @@ checksums:
workspace/api_keys/api_key_updated: 0e03754eb33742b4ee8d5fdad64c9b3f
workspace/api_keys/delete_api_key_confirmation: b2f0342d4e55f0cb244fe121eeeb10a3
workspace/api_keys/duplicate_access: 7ac7ac5ba755ce94e6fc81afa5a21997
workspace/api_keys/duplicate_directory_access: e112b756627f0b5e2551451274c3781f
workspace/api_keys/feedback_record_directory_access: 51babe735cb94388f68108555814b4f6
workspace/api_keys/no_api_keys_yet: 58593ed9f7e507dcd7ca7fe069add599
workspace/api_keys/no_env_permissions_found: 97ef49946f3ce15c2ad44dcfd2bce507
workspace/api_keys/no_directory_permissions_found: ae55b273dd4c71d8431d64d99b39c59f
workspace/api_keys/no_workspace_permissions_found: 1d719624828a9d3e433cdf6b387549f3
workspace/api_keys/organization_access: 96a92fa907b15e0c0e47e33cac15be88
workspace/api_keys/organization_access_description: 773dfeaf6ffbf34dd9a0a3d656a6d83c
workspace/api_keys/permissions: 2160be68b1d6b6577e64634e9feba2ed
workspace/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
workspace/api_keys/unable_to_copy_api_key: 148506832e31d033fa3569ce292d2120
workspace/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
workspace/api_keys/unknown_directory: ed07f55f5dba1f451a45f2cf6e01c9a9
workspace/api_keys/unknown_workspace: 4b0df2d07ebc9ab084158b1b9525ae5e
workspace/api_keys/workspace_access: b38cb73197ef5f5fa6653b88c68aa0bd
workspace/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
workspace/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
workspace/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
workspace/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
workspace/app-connection/environment_id: 49141af65970ea79e22ecedb97ceb2e4
workspace/app-connection/environment_id_description: d611755139dbe9865f1436acf6f679be
workspace/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
workspace/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
workspace/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
@@ -1587,10 +1804,12 @@ checksums:
workspace/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
workspace/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
workspace/app-connection/sdk_connection_details: 89f2c169fd1604c1df5a834517f1eae1
workspace/app-connection/sdk_connection_details_description: 8e6d79678736819bf2f2940404ba5c3e
workspace/app-connection/sdk_connection_details_description: 2d6824466039672fa002d72da95b4637
workspace/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259
workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
workspace/app-connection/workspace_id: 49141af65970ea79e22ecedb97ceb2e4
workspace/app-connection/workspace_id_description: 77e5219be241e9973741f138787ccbb8
workspace/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
workspace/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
workspace/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
@@ -1617,10 +1836,10 @@ checksums:
workspace/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
workspace/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
workspace/contacts/attributes_msg_attribute_type_validation_error: bd70f9773ae873240d4cdb26a662334c
workspace/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
workspace/contacts/attributes_msg_email_already_exists: 308cf739f7b98a6b2707acf3b658f220
workspace/contacts/attributes_msg_email_or_userid_required: febc8b0cda4dd45d2c3cdb1ac2d45dcb
workspace/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
workspace/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
workspace/contacts/attributes_msg_userid_already_exists: 94851fa7f17ffd0da323658dbf6bdd31
workspace/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
workspace/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
workspace/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
@@ -1865,7 +2084,6 @@ checksums:
workspace/languages/duplicate_language_or_language_id: 0e17e3794b24e2428ca6ffadae0d08f3
workspace/languages/edit_languages: c9d36f6b28557cc7d54e87c37dc18fdd
workspace/languages/identifier: 7d8ade6b85e96216bcd73adeeeeecd8c
workspace/languages/incomplete_translations: d82908b5725f18f5849c7876ad497ebc
workspace/languages/language: 277fd1a41cc237a437cd1d5e4a80463b
workspace/languages/language_deleted_successfully: 4a805d030491f3fe608d2371b0cfcd83
workspace/languages/languages_updated_successfully: 60de474c99c5059c0458cddd0b016c15
@@ -1876,7 +2094,6 @@ checksums:
workspace/languages/remove_language: 1a64563b0f37109f97b78eddd493e381
workspace/languages/remove_language_from_surveys_to_remove_it_from_workspace: 61bc96f9db31a29a649cc9ecd684bc39
workspace/languages/search_items: b54b751c8b075200be579d6c8e58096b
workspace/languages/translate: 59f9803b27e2030ba7323ed239116cf7
workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9
workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
@@ -2081,7 +2298,7 @@ checksums:
workspace/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
workspace/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
workspace/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
workspace/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
workspace/settings/billing/pending_plan_change_description: 6923bada769d33cadcad557521362c1f
workspace/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
workspace/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
workspace/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
@@ -2234,19 +2451,20 @@ checksums:
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
workspace/settings/feedback_record_directories/pause_connectors_confirmation_description: a3c2c56daed9f2a9e6a853cb8b924bad
workspace/settings/feedback_record_directories/pause_connectors_confirmation_title: 09041363c55fb2686f8115df6fa2afc1
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
workspace/settings/general/ai_data_analysis_disabled_for_organization: 2066fe71ecf8994ba738c79b63a1934b
workspace/settings/feedback_record_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
workspace/settings/feedback_record_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
workspace/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
workspace/settings/general/ai_features_not_enabled_for_organization: e344473bd813fc43f69c51138f74bc8e
workspace/settings/general/ai_instance_not_configured: 37a80753eb22b5bfc985d0e1f2145e3f
workspace/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
workspace/settings/general/ai_smart_tools_disabled_for_organization: 13df84ae47d35dfa6e86ffa62f29c75d
workspace/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
workspace/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
workspace/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
@@ -2305,6 +2523,8 @@ checksums:
workspace/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
workspace/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
workspace/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
workspace/settings/general/unlock_ai_features_description: c15c8c050a4a16d99dc595d9c6419bc4
workspace/settings/general/unlock_ai_features_with_a_higher_plan: e0140d3ffd07524fb8f1fec637c4149a
workspace/settings/notifications/auto_subscribe_to_new_surveys: 8102c9ce2fbcae53bd8d979c42932fa9
workspace/settings/notifications/email_alerts_surveys: 12be5a073d74453a531167debd947bd6
workspace/settings/notifications/every_response: 526988e9015f37bc2d32414d7dc05c7c
@@ -2393,16 +2613,9 @@ checksums:
workspace/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
workspace/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
workspace/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
workspace/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
workspace/surveys/copy_survey_description: b78f714a4a4baae883210b13fb196bd5
workspace/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
workspace/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
workspace/surveys/copy_survey_no_workspaces: 6f4547d91b2c14dad83c44b01df365eb
workspace/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
workspace/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
workspace/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
workspace/surveys/edit/1_choose_the_default_language_for_this_survey: d22759857c1bb3d6b337e8e9d501dad7
workspace/surveys/edit/2_activate_translation_for_specific_languages: 9f23cb81ad301073df45ae36f0d94f9e
workspace/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
workspace/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
workspace/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
workspace/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
@@ -2439,6 +2652,18 @@ checksums:
workspace/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
workspace/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
workspace/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
workspace/surveys/edit/ai_data_analysis_disabled: 2066fe71ecf8994ba738c79b63a1934b
workspace/surveys/edit/ai_features_not_enabled: e344473bd813fc43f69c51138f74bc8e
workspace/surveys/edit/ai_instance_not_configured: 939ad7c3240fa8de98a325239f1b36bc
workspace/surveys/edit/ai_smart_tools_disabled: 13df84ae47d35dfa6e86ffa62f29c75d
workspace/surveys/edit/ai_translate: f25943cdeffe155ee524428f4daa5da2
workspace/surveys/edit/ai_translating: 098a2293b39f9f258d67f926cf03df37
workspace/surveys/edit/ai_translation_all_fields_populated: d78f6a663ea19ce77045970179bd200f
workspace/surveys/edit/ai_translation_complete: f443d0801404f728e68000b46ca67598
workspace/surveys/edit/ai_translation_failed: fd356a173d0abde7a0fc660394954cc7
workspace/surveys/edit/ai_translation_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
workspace/surveys/edit/ai_translation_not_available: 2f060bf93a558e6d12ec90988fdd162e
workspace/surveys/edit/ai_translation_not_enabled: 9066bc85f62ea0e96620c058a4004388
workspace/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
workspace/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
workspace/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
@@ -2452,7 +2677,7 @@ checksums:
workspace/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
workspace/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
workspace/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
workspace/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
workspace/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
workspace/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
workspace/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
workspace/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -2498,6 +2723,7 @@ checksums:
workspace/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
workspace/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
workspace/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
workspace/surveys/edit/change_default: 6236a6c8a28489ba7c4cad7426806859
workspace/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
workspace/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93
workspace/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836
@@ -2509,7 +2735,11 @@ checksums:
workspace/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
workspace/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
workspace/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
workspace/surveys/edit/clear_close_on_date: 673ed6940f36b02cf871ffacf034e114
workspace/surveys/edit/clear_publish_on_date: fe1ffa08c7b95d1dbd6bb8f89d22760f
workspace/surveys/edit/close_survey_on_date: 5588ecb41d245dacfb5f7e1b10a97a3e
workspace/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
workspace/surveys/edit/code: 343bc5386149b97cece2b093c39034b2
workspace/surveys/edit/color: 9d53d1d120e8b8954bcae9a322573748
workspace/surveys/edit/column_used_in_logic_error: deffbd3e8f4bd71a5e522682e8ee60dd
workspace/surveys/edit/columns: 14896556dc1535d70198854757f704ec
@@ -2534,6 +2764,7 @@ checksums:
workspace/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
workspace/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
workspace/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
workspace/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
workspace/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
workspace/surveys/edit/delete_block: c00617cb0724557e486304276063807a
workspace/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
@@ -2553,7 +2784,6 @@ checksums:
workspace/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
workspace/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
workspace/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
workspace/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
workspace/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
workspace/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
workspace/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
@@ -2689,11 +2919,13 @@ checksums:
workspace/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
workspace/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
workspace/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
workspace/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
workspace/surveys/edit/manage_languages: fe82303bc27b55ccfc076b527b185e39
workspace/surveys/edit/manage_translations: 09b01c5c251e6dbc3dc6cd8b33fb6301
workspace/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
workspace/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
workspace/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
workspace/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
workspace/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
workspace/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
workspace/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
workspace/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
@@ -2701,7 +2933,7 @@ checksums:
workspace/surveys/edit/next_button_label: 39f1e82ae1dea5e400e8ed7c98c6ad9c
workspace/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
workspace/surveys/edit/no_images_found_for: 7dabcbcc7084f59c6ec0971895dfcd29
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 4e66397232da6a463708220dc020bf42
workspace/surveys/edit/no_option_found: a1a3aa7e6c13b6bb8df20a1a104c7c04
workspace/surveys/edit/no_recall_items_found: 729e2b02e412cdc79f5ad94b1918620c
workspace/surveys/edit/no_variables_yet_add_first_one_below: c8704b9ebc9c26c0e9dd50c099ba88cd
@@ -2728,12 +2960,14 @@ checksums:
workspace/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
workspace/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
workspace/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
workspace/surveys/edit/present_your_survey_in_multiple_languages: 37f28b0a092d68322fedbc2e0c221ef3
workspace/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
workspace/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
workspace/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
workspace/surveys/edit/protect_survey_with_pin: 16d1925b6a5770f7423772d6d9a8291a
workspace/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
workspace/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
workspace/surveys/edit/publish_survey_on_date: 3167951ef991b911d2e2abb102842452
workspace/surveys/edit/question: 0576462ce60d4263d7c482463fcc9547
workspace/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
workspace/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
@@ -2802,6 +3036,7 @@ checksums:
workspace/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
workspace/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
workspace/surveys/edit/scale: 5f55a30a5bdf8f331b56bad9c073473c
workspace/surveys/edit/schedule_survey: aa9d0a74a96c325e3202bf1efbc4611a
workspace/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
workspace/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
workspace/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
@@ -2817,6 +3052,7 @@ checksums:
workspace/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
workspace/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
workspace/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
workspace/surveys/edit/show_in_order: 15784a59572eb8a6dba6b918c31a9493
workspace/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
workspace/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
workspace/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
@@ -2848,7 +3084,8 @@ checksums:
workspace/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
workspace/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
workspace/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
workspace/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
workspace/surveys/edit/survey_will_be_closed_at_midnight_cet: d0a6307e352b3b54fb30d6166201db1f
workspace/surveys/edit/survey_will_be_published_at_midnight_cet: d6906ffe76f24b13dab15c50bc24e1a7
workspace/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
workspace/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
workspace/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
@@ -2856,9 +3093,11 @@ checksums:
workspace/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: e45beba7ae126775f4966776c982a3b4
workspace/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
workspace/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
workspace/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
workspace/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
workspace/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
workspace/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
workspace/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
workspace/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
workspace/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
workspace/surveys/edit/type_field_id: 714b845806236bb8a9d6a09933b836e9
@@ -2931,6 +3170,7 @@ checksums:
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
workspace/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
workspace/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
workspace/surveys/edit/wait: 014d18ade977bf08d75b995076596708
workspace/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
workspace/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
@@ -3106,6 +3346,8 @@ checksums:
workspace/surveys/summary/configure_alerts: 05cc642cd9398034d7e68589d22d97cf
workspace/surveys/summary/congrats: 378f06fe96289e527153f8201088ff74
workspace/surveys/summary/connect_your_website_or_app_with_formbricks_to_get_started: d24183c86d08b16d58daa8ad887b2837
workspace/surveys/summary/csat_satisfied: 4d6121afdc705a70465a230d6d1f6217
workspace/surveys/summary/csat_satisfied_tooltip: 3a69a76559a40fbbdff14525d83b459c
workspace/surveys/summary/current_count: 6a3e59de8559e88e991e0aeafa9cfeec
workspace/surveys/summary/custom_range: 9bc7e02a890644b13b5c0b0bdd96c165
workspace/surveys/summary/delete_all_existing_responses_and_displays: e346bcbdb1e0dfbce5925e19fdf0cc78
@@ -3113,7 +3355,7 @@ checksums:
workspace/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
workspace/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
workspace/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
workspace/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
workspace/surveys/summary/effort_score: b79157d02a8ead85459c158272951ab5
workspace/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
workspace/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
workspace/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -3163,6 +3405,7 @@ checksums:
workspace/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
@@ -3175,7 +3418,6 @@ checksums:
workspace/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
workspace/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
workspace/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
workspace/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
workspace/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
workspace/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
workspace/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
@@ -3185,6 +3427,7 @@ checksums:
workspace/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
workspace/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
workspace/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
workspace/surveys/summary/survey_scheduled_successfully: b5f39d8dc0c203466a0ffb5fa60163e8
workspace/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
workspace/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
workspace/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
@@ -3198,7 +3441,6 @@ checksums:
workspace/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
workspace/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
workspace/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
workspace/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
workspace/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
workspace/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
workspace/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
@@ -3225,16 +3467,21 @@ checksums:
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0
workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
workspace/unify/api_ingestion_manage_api_keys: 116786a004fb7b16ead8a5b7a6a2debe
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
workspace/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
workspace/unify/connection: 421e709602c92ffbe04a266f6a092089
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
@@ -3253,9 +3500,12 @@ checksums:
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
workspace/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
@@ -3265,47 +3515,64 @@ checksums:
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
workspace/unify/feedback_sources_directory_access_single: c9da6b30d410a0ca6302a00a5747dc19
workspace/unify/feedback_sources_settings_description: 45f162f2f81cd195c23cb3ec490bb3df
workspace/unify/field_group_id: 17024bb46ff1e088afb6a279dc85aad4
workspace/unify/field_group_label: 3df09c3b6fd22310359cf955ecff5c8e
workspace/unify/field_id: 7791b5d581b7a525dcadf11ec73c6ab7
workspace/unify/field_label: 6384505ca0e40010c666b712511132a6
workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08
workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
workspace/unify/frd_cannot_be_changed: 265c12529f540d8309811f4e0090272f
workspace/unify/go_to_feedback_record_directories: 16b66b62f85e7be311778f39315d118a
workspace/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
workspace/unify/import_csv_data: f05e1d1ed88d528256efe5702df46646
workspace/unify/import_feedback: f05e1d1ed88d528256efe5702df46646
workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4
workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
workspace/unify/import_via_source_name: eae32ae2fc87f925ca016fe8283bcbfd
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
workspace/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
workspace/unify/manage_directories: 460e00e1cbf1f51de57a2548546e33d7
workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028
workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6
workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602
workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216
workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5
workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
workspace/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
workspace/unify/question_selected: b9ff13b6212874258da911867932dc7d
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
workspace/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
workspace/unify/records_will_go_to: 6a3f5a6580857a931bab389ad354831c
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
workspace/unify/request_feedback_source: 51045caa2c81dee971d23a1841d19a7e
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
workspace/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d
workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf
workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
workspace/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
workspace/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
workspace/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
@@ -3315,20 +3582,20 @@ checksums:
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
workspace/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
workspace/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
workspace/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48
workspace/unify/submission_id: 02edf76883b47079dbe20f3f36b7c1a7
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
workspace/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
workspace/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
workspace/unify/topics_and_subtopics: 1148eca01a1993fadca932efcdea7641
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
@@ -3336,6 +3603,10 @@ checksums:
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab
workspace/unify/value_date: c8d705d1975affc01c002324725fec3f
workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675
workspace/unify/value_text: e097a597cc507c716401ad18255de578
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
+370
View File
@@ -0,0 +1,370 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const mockStartJobsRuntime = vi.fn();
const mockRemoveRecurringSurveySchedulingJobSchedule = vi.fn();
const mockUpsertRecurringSurveySchedulingJobSchedule = vi.fn();
const mockDebug = vi.fn();
const mockError = vi.fn();
const mockWarn = vi.fn();
const mockGetJobsQueueingConfig = vi.fn();
const mockGetJobsWorkerBootstrapConfig = vi.fn();
const mockProcessResponsePipelineJob = vi.fn();
const mockProcessSurveySchedulingJob = vi.fn();
const TEST_TIMEOUT_MS = 15_000;
const slowTest = (name: string, fn: () => Promise<void>): void => {
test(name, fn, TEST_TIMEOUT_MS);
};
vi.mock("@formbricks/jobs", () => ({
removeRecurringSurveySchedulingJobSchedule: mockRemoveRecurringSurveySchedulingJobSchedule,
startJobsRuntime: mockStartJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule: mockUpsertRecurringSurveySchedulingJobSchedule,
}));
vi.mock("@/lib/jobs/config", () => ({
getJobsQueueingConfig: mockGetJobsQueueingConfig,
getJobsWorkerBootstrapConfig: mockGetJobsWorkerBootstrapConfig,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
debug: mockDebug,
error: mockError,
info: vi.fn(),
warn: mockWarn,
},
}));
vi.mock("@/modules/response-pipeline/lib/process-response-pipeline-job", () => ({
processResponsePipelineJob: mockProcessResponsePipelineJob,
}));
vi.mock("@/modules/survey/scheduling/lib/process-survey-scheduling-job", () => ({
processSurveySchedulingJob: mockProcessSurveySchedulingJob,
}));
describe("instrumentation-jobs", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.useFakeTimers();
mockRemoveRecurringSurveySchedulingJobSchedule.mockResolvedValue(true);
mockGetJobsQueueingConfig.mockReturnValue({
enabled: false,
redisUrl: null,
});
});
afterEach(async () => {
const { resetJobsWorkerRegistrationForTests } = await import("./instrumentation-jobs");
await resetJobsWorkerRegistrationForTests();
vi.useRealTimers();
});
slowTest("skips worker startup when disabled", async () => {
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: false,
runtimeOptions: null,
});
const { registerJobsWorker } = await import("./instrumentation-jobs");
const result = await registerJobsWorker();
expect(result).toBeNull();
expect(mockStartJobsRuntime).not.toHaveBeenCalled();
expect(mockUpsertRecurringSurveySchedulingJobSchedule).not.toHaveBeenCalled();
expect(mockDebug).toHaveBeenCalledWith("BullMQ worker startup skipped");
});
slowTest("starts the worker once and registers handlers", async () => {
const mockRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
const mockExistingOverride = vi.fn();
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 4,
jobHandlerOverrides: {
"test-log.process": mockExistingOverride,
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
},
});
mockStartJobsRuntime.mockResolvedValue(mockRuntime);
const { registerJobsWorker } = await import("./instrumentation-jobs");
const first = await registerJobsWorker();
const second = await registerJobsWorker();
expect(first).toBe(mockRuntime);
expect(second).toBe(mockRuntime);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
expect(mockStartJobsRuntime).toHaveBeenCalledWith({
concurrency: 4,
jobHandlerOverrides: {
"response-pipeline.process": expect.any(Function),
"survey-scheduling.reconcile": expect.any(Function),
"test-log.process": mockExistingOverride,
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
});
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
const responsePipelineOverride = overrides?.["response-pipeline.process"];
const surveySchedulingOverride = overrides?.["survey-scheduling.reconcile"];
await responsePipelineOverride?.(
{
workspaceId: "ws_123",
event: "responseCreated",
response: { id: "res_123" },
surveyId: "survey_123",
},
{
attempt: 1,
jobId: "job_123",
jobName: "response-pipeline.process",
maxAttempts: 3,
queueName: "background-jobs",
}
);
await surveySchedulingOverride?.(
{
scope: "global",
},
{
attempt: 1,
jobId: "job_456",
jobName: "survey-scheduling.reconcile",
maxAttempts: 3,
queueName: "background-jobs",
}
);
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
{
workspaceId: "ws_123",
event: "responseCreated",
response: { id: "res_123" },
surveyId: "survey_123",
},
{
attempt: 1,
jobId: "job_123",
jobName: "response-pipeline.process",
maxAttempts: 3,
queueName: "background-jobs",
}
);
expect(mockProcessSurveySchedulingJob).toHaveBeenCalledWith(
{
scope: "global",
},
{
attempt: 1,
jobId: "job_456",
jobName: "survey-scheduling.reconcile",
maxAttempts: 3,
queueName: "background-jobs",
}
);
});
slowTest("reuses the in-flight startup promise", async () => {
const mockRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 2,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
let resolveRuntime: ((value: typeof mockRuntime) => void) | undefined;
mockStartJobsRuntime.mockReturnValue(
new Promise((resolve) => {
resolveRuntime = resolve;
})
);
const { registerJobsWorker } = await import("./instrumentation-jobs");
const firstPromise = registerJobsWorker();
const secondPromise = registerJobsWorker();
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
resolveRuntime?.(mockRuntime);
await expect(firstPromise).resolves.toBe(mockRuntime);
await expect(secondPromise).resolves.toBe(mockRuntime);
});
slowTest("logs and rethrows startup failures", async () => {
const startupError = new Error("startup failed");
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockRejectedValue(startupError);
const { registerJobsWorker } = await import("./instrumentation-jobs");
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
expect(mockError).toHaveBeenCalledWith({ err: startupError }, "BullMQ worker registration failed");
expect(mockWarn).toHaveBeenCalledWith(
{ retryDelayMs: 30_000 },
"BullMQ worker registration retry scheduled"
);
});
slowTest("retries worker startup after a transient failure", async () => {
const startupError = new Error("startup failed");
const recoveredRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockRejectedValueOnce(startupError).mockResolvedValueOnce(recoveredRuntime);
const { registerJobsWorker } = await import("./instrumentation-jobs");
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
await vi.advanceTimersByTimeAsync(30_000);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
await expect(registerJobsWorker()).resolves.toBe(recoveredRuntime);
});
slowTest(
"registers recurring schedules once when queueing is enabled without an in-process worker",
async () => {
mockGetJobsQueueingConfig.mockReturnValue({
enabled: true,
redisUrl: "redis://localhost:6379",
});
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: false,
runtimeOptions: null,
});
mockUpsertRecurringSurveySchedulingJobSchedule.mockResolvedValue({
id: "schedule-job-1",
name: "survey-scheduling.reconcile",
queueName: "background-jobs",
});
const { registerRecurringJobs } = await import("./instrumentation-jobs");
const { SURVEY_SCHEDULING_DAILY_CRON_PATTERN, SURVEY_SCHEDULING_TIME_ZONE } =
await import("@/modules/survey/scheduling/lib/constants");
await registerRecurringJobs();
await registerRecurringJobs();
expect(mockStartJobsRuntime).not.toHaveBeenCalled();
expect(mockRemoveRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(1);
expect(mockRemoveRecurringSurveySchedulingJobSchedule).toHaveBeenCalledWith({
scheduleId: "daily-survey-scheduling",
scope: "global",
});
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(1);
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledWith(
{
scheduleId: "daily-survey-scheduling",
scope: "global",
},
{
cronPattern: SURVEY_SCHEDULING_DAILY_CRON_PATTERN,
kind: "cron",
timeZone: SURVEY_SCHEDULING_TIME_ZONE,
},
{
scope: "global",
}
);
}
);
slowTest("retries recurring schedule registration after a transient failure", async () => {
const scheduleError = new Error("schedule failed");
mockGetJobsQueueingConfig.mockReturnValue({
enabled: true,
redisUrl: "redis://localhost:6379",
});
mockUpsertRecurringSurveySchedulingJobSchedule
.mockRejectedValueOnce(scheduleError)
.mockResolvedValueOnce({
id: "schedule-job-1",
name: "survey-scheduling.reconcile",
queueName: "background-jobs",
});
const { registerRecurringJobs } = await import("./instrumentation-jobs");
await expect(registerRecurringJobs()).rejects.toThrow("schedule failed");
expect(mockError).toHaveBeenCalledWith(
{ err: scheduleError },
"BullMQ recurring job registration failed"
);
expect(mockWarn).toHaveBeenCalledWith(
{ retryDelayMs: 30_000 },
"BullMQ recurring job registration retry scheduled"
);
await vi.advanceTimersByTimeAsync(30_000);
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(2);
});
slowTest("clears registration state even when reset close fails", async () => {
const failingRuntime = {
close: vi.fn().mockRejectedValue(new Error("close failed")),
};
const nextRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockResolvedValueOnce(failingRuntime).mockResolvedValueOnce(nextRuntime);
const { registerJobsWorker, resetJobsWorkerRegistrationForTests } =
await import("./instrumentation-jobs");
await expect(registerJobsWorker()).resolves.toBe(failingRuntime);
await expect(resetJobsWorkerRegistrationForTests()).resolves.toBeUndefined();
await expect(registerJobsWorker()).resolves.toBe(nextRuntime);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
expect(mockError).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"BullMQ worker test reset close failed"
);
});
});
+242
View File
@@ -0,0 +1,242 @@
import {
type JobHandlerOverrides,
type JobsRuntimeHandle,
type TResponsePipelineJobData,
type TSurveySchedulingJobData,
removeRecurringSurveySchedulingJobSchedule,
startJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule,
} from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } from "@/lib/jobs/config";
import { processResponsePipelineJob } from "@/modules/response-pipeline/lib/process-response-pipeline-job";
import {
SURVEY_SCHEDULING_DAILY_CRON_PATTERN,
SURVEY_SCHEDULING_DAILY_SCHEDULE_ID,
SURVEY_SCHEDULING_GLOBAL_SCOPE,
SURVEY_SCHEDULING_TIME_ZONE,
} from "@/modules/survey/scheduling/lib/constants";
import { processSurveySchedulingJob } from "@/modules/survey/scheduling/lib/process-survey-scheduling-job";
const WORKER_STARTUP_RETRY_DELAY_MS = 30_000;
type TJobsRuntimeGlobal = typeof globalThis & {
formbricksJobsRecurringRegistration: Promise<void> | undefined;
formbricksJobsRecurringRegistered: boolean | undefined;
formbricksJobsRecurringRetryTimeout: ReturnType<typeof setTimeout> | undefined;
formbricksJobsRuntime: JobsRuntimeHandle | undefined;
formbricksJobsRuntimeInitializing: Promise<JobsRuntimeHandle> | undefined;
formbricksJobsRuntimeRetryTimeout: ReturnType<typeof setTimeout> | undefined;
};
const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile";
const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
};
const surveySchedulingJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processSurveySchedulingJob(data as TSurveySchedulingJobData, context);
};
const registerSurveySchedulingSchedule = async (): Promise<void> => {
await removeRecurringSurveySchedulingJobSchedule({
scheduleId: SURVEY_SCHEDULING_DAILY_SCHEDULE_ID,
scope: SURVEY_SCHEDULING_GLOBAL_SCOPE,
});
await upsertRecurringSurveySchedulingJobSchedule(
{
scheduleId: SURVEY_SCHEDULING_DAILY_SCHEDULE_ID,
scope: SURVEY_SCHEDULING_GLOBAL_SCOPE,
},
{
cronPattern: SURVEY_SCHEDULING_DAILY_CRON_PATTERN,
kind: "cron",
timeZone: SURVEY_SCHEDULING_TIME_ZONE,
},
{
scope: SURVEY_SCHEDULING_GLOBAL_SCOPE,
}
);
};
const clearRecurringJobsRetryTimeout = (): void => {
if (globalForJobsRuntime.formbricksJobsRecurringRetryTimeout) {
clearTimeout(globalForJobsRuntime.formbricksJobsRecurringRetryTimeout);
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout = undefined;
}
};
const scheduleRecurringJobsRetry = (): void => {
if (
globalForJobsRuntime.formbricksJobsRecurringRegistered ||
globalForJobsRuntime.formbricksJobsRecurringRegistration ||
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout
) {
return;
}
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout = setTimeout(() => {
globalForJobsRuntime.formbricksJobsRecurringRetryTimeout = undefined;
void registerRecurringJobs().catch(() => undefined);
}, WORKER_STARTUP_RETRY_DELAY_MS);
logger.warn(
{ retryDelayMs: WORKER_STARTUP_RETRY_DELAY_MS },
"BullMQ recurring job registration retry scheduled"
);
};
const clearJobsWorkerRetryTimeout = (): void => {
if (globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout) {
clearTimeout(globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout);
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = undefined;
}
};
const scheduleJobsWorkerRetry = (): void => {
if (
globalForJobsRuntime.formbricksJobsRuntime ||
globalForJobsRuntime.formbricksJobsRuntimeInitializing ||
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout
) {
return;
}
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = setTimeout(() => {
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = undefined;
void registerJobsWorker().catch(() => undefined);
}, WORKER_STARTUP_RETRY_DELAY_MS);
logger.warn({ retryDelayMs: WORKER_STARTUP_RETRY_DELAY_MS }, "BullMQ worker registration retry scheduled");
};
export const registerRecurringJobs = async (): Promise<void> => {
const jobsQueueingConfig = getJobsQueueingConfig();
if (!jobsQueueingConfig.enabled || !jobsQueueingConfig.redisUrl) {
clearRecurringJobsRetryTimeout();
logger.debug("BullMQ recurring job registration skipped");
return;
}
if (globalForJobsRuntime.formbricksJobsRecurringRegistered) {
return;
}
if (globalForJobsRuntime.formbricksJobsRecurringRegistration) {
return await globalForJobsRuntime.formbricksJobsRecurringRegistration;
}
globalForJobsRuntime.formbricksJobsRecurringRegistration = (async () => {
await registerSurveySchedulingSchedule();
clearRecurringJobsRetryTimeout();
globalForJobsRuntime.formbricksJobsRecurringRegistered = true;
globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined;
})();
try {
return await globalForJobsRuntime.formbricksJobsRecurringRegistration;
} catch (error) {
globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined;
logger.error({ err: error }, "BullMQ recurring job registration failed");
scheduleRecurringJobsRetry();
throw error;
}
};
export const registerJobsWorker = async (): Promise<JobsRuntimeHandle | null> => {
const jobsWorkerBootstrapConfig = getJobsWorkerBootstrapConfig();
if (!jobsWorkerBootstrapConfig.enabled || !jobsWorkerBootstrapConfig.runtimeOptions) {
clearJobsWorkerRetryTimeout();
logger.debug("BullMQ worker startup skipped");
return null;
}
if (globalForJobsRuntime.formbricksJobsRuntime) {
return globalForJobsRuntime.formbricksJobsRuntime;
}
if (globalForJobsRuntime.formbricksJobsRuntimeInitializing) {
return await globalForJobsRuntime.formbricksJobsRuntimeInitializing;
}
const runtimeOptions = jobsWorkerBootstrapConfig.runtimeOptions;
const jobHandlerOverrides: JobHandlerOverrides = runtimeOptions.jobHandlerOverrides
? {
...runtimeOptions.jobHandlerOverrides,
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
}
: {
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
};
globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => {
const runtime = await startJobsRuntime({
...runtimeOptions,
jobHandlerOverrides,
});
clearJobsWorkerRetryTimeout();
globalForJobsRuntime.formbricksJobsRuntime = runtime;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
return runtime;
})();
try {
return await globalForJobsRuntime.formbricksJobsRuntimeInitializing;
} catch (error) {
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
logger.error({ err: error }, "BullMQ worker registration failed");
scheduleJobsWorkerRetry();
throw error;
}
};
export const resetJobsWorkerRegistrationForTests = async (): Promise<void> => {
const runtime = globalForJobsRuntime.formbricksJobsRuntime;
const initializing = globalForJobsRuntime.formbricksJobsRuntimeInitializing;
clearRecurringJobsRetryTimeout();
clearJobsWorkerRetryTimeout();
globalForJobsRuntime.formbricksJobsRecurringRegistered = undefined;
globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined;
globalForJobsRuntime.formbricksJobsRuntime = undefined;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
const runtimesToClose = new Set<JobsRuntimeHandle>();
if (runtime) {
runtimesToClose.add(runtime);
}
if (initializing) {
try {
const initializedRuntime = await initializing;
runtimesToClose.add(initializedRuntime);
} catch {
// Startup failures are already surfaced by the test that triggered them.
}
}
if (globalForJobsRuntime.formbricksJobsRuntime) {
runtimesToClose.add(globalForJobsRuntime.formbricksJobsRuntime);
}
globalForJobsRuntime.formbricksJobsRuntime = undefined;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
await Promise.all(
[...runtimesToClose].map(async (runtimeHandle) => {
try {
await runtimeHandle.close();
} catch (error) {
logger.error({ err: error }, "BullMQ worker test reset close failed");
}
})
);
};
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockRegisterJobsWorker = vi.fn();
const mockRegisterRecurringJobs = vi.fn();
vi.mock("@sentry/nextjs", () => ({
captureRequestError: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
PROMETHEUS_ENABLED: false,
SENTRY_DSN: undefined,
}));
vi.mock("./instrumentation-jobs", () => ({
registerRecurringJobs: mockRegisterRecurringJobs,
registerJobsWorker: mockRegisterJobsWorker,
}));
describe("instrumentation register", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
process.env.NEXT_RUNTIME = "nodejs";
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
});
test("does not block Next.js boot on BullMQ worker startup", async () => {
mockRegisterRecurringJobs.mockReturnValue(new Promise(() => undefined));
mockRegisterJobsWorker.mockReturnValue(new Promise(() => undefined));
const { register } = await import("./instrumentation");
await expect(register()).resolves.toBeUndefined();
expect(mockRegisterRecurringJobs).toHaveBeenCalledTimes(1);
expect(mockRegisterJobsWorker).toHaveBeenCalledTimes(1);
});
test("swallows BullMQ worker startup rejections after triggering background registration", async () => {
mockRegisterRecurringJobs.mockRejectedValue(new Error("schedule failed"));
mockRegisterJobsWorker.mockRejectedValue(new Error("startup failed"));
const { register } = await import("./instrumentation");
await expect(register()).resolves.toBeUndefined();
await Promise.resolve();
expect(mockRegisterRecurringJobs).toHaveBeenCalledTimes(1);
expect(mockRegisterJobsWorker).toHaveBeenCalledTimes(1);
});
});
+20
View File
@@ -1,5 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { logger } from "@formbricks/logger";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
@@ -21,6 +22,25 @@ export const register = async () => {
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
await import("./instrumentation-node");
}
// Skip runtime-only BullMQ bootstrapping during production builds.
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
if (process.env.NEXT_PHASE !== "phase-production-build") {
try {
const { registerJobsWorker, registerRecurringJobs } = await import("./instrumentation-jobs");
void registerRecurringJobs().catch((error: unknown) => {
logger.error(
{ err: error },
"BullMQ recurring job registration failed during Next.js instrumentation"
);
});
void registerJobsWorker().catch((error: unknown) => {
logger.error({ err: error }, "BullMQ worker registration failed during Next.js instrumentation");
});
} catch (error) {
logger.error({ err: error }, "BullMQ instrumentation import failed during Next.js instrumentation");
}
}
}
// Sentry init loads after OTEL to avoid TracerProvider conflicts
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
+11 -11
View File
@@ -30,7 +30,7 @@ describe("ActionClass Service", () => {
});
describe("getActionClasses", () => {
test("should return action classes for environment", async () => {
test("should return action classes for workspace", async () => {
const mockActionClasses: TActionClass[] = [
{
id: "id1",
@@ -41,15 +41,15 @@ describe("ActionClass Service", () => {
type: "code",
key: "key1",
noCodeConfig: null,
workspaceId: "env1",
workspaceId: "ws1",
},
];
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
const result = await getActionClasses("env1");
const result = await getActionClasses("ws1");
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { workspaceId: "env1" },
where: { workspaceId: "ws1" },
select: expect.any(Object),
take: undefined,
skip: undefined,
@@ -59,7 +59,7 @@ describe("ActionClass Service", () => {
test("should throw DatabaseError when prisma throws", async () => {
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("fail"));
await expect(getActionClasses("env1")).rejects.toThrow(DatabaseError);
await expect(getActionClasses("ws1")).rejects.toThrow(DatabaseError);
});
});
@@ -78,15 +78,15 @@ describe("ActionClass Service", () => {
elementSelector: { cssSelector: "button" },
urlFilters: [],
},
workspaceId: "env2",
workspaceId: "ws2",
};
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
const result = await getActionClassByWorkspaceIdAndName("env2", "Action 2");
const result = await getActionClassByWorkspaceIdAndName("ws2", "Action 2");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({
where: { name: "Action 2", workspaceId: "env2" },
where: { name: "Action 2", workspaceId: "ws2" },
select: expect.any(Object),
});
});
@@ -94,14 +94,14 @@ describe("ActionClass Service", () => {
test("should return null when not found", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
const result = await getActionClassByWorkspaceIdAndName("env2", "Action 2");
const result = await getActionClassByWorkspaceIdAndName("ws2", "Action 2");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
await expect(getActionClassByWorkspaceIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
await expect(getActionClassByWorkspaceIdAndName("ws2", "Action 2")).rejects.toThrow(DatabaseError);
});
});
@@ -116,7 +116,7 @@ describe("ActionClass Service", () => {
type: "code",
key: "key3",
noCodeConfig: null,
workspaceId: "env3",
workspaceId: "ws3",
};
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
-8
View File
@@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({
getOrganization: vi.fn(),
getIsAIDataAnalysisEnabled: vi.fn(),
getIsAISmartToolsEnabled: vi.fn(),
getTranslate: vi.fn(),
loggerError: vi.fn(),
}));
@@ -66,10 +65,6 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: mocks.getTranslate,
}));
describe("AI organization service", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -82,9 +77,6 @@ describe("AI organization service", () => {
});
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key
);
});
test("returns the instance AI status and organization settings", async () => {
+13 -12
View File
@@ -4,9 +4,17 @@ import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
export const AI_ERROR_CODES = {
FEATURES_NOT_ENABLED: "ai_features_not_enabled",
SMART_TOOLS_DISABLED: "ai_smart_tools_disabled",
DATA_ANALYSIS_DISABLED: "ai_data_analysis_disabled",
INSTANCE_NOT_CONFIGURED: "ai_instance_not_configured",
} as const;
export type TAIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES];
export interface TOrganizationAIConfig {
organizationId: string;
isAISmartToolsEnabled: boolean;
@@ -44,31 +52,24 @@ export const assertOrganizationAIConfigured = async (
organizationId: string,
capability: "smartTools" | "dataAnalysis"
): Promise<TOrganizationAIConfig> => {
const t = await getTranslate();
const aiConfig = await getOrganizationAIConfig(organizationId);
const isCapabilityEntitled =
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
if (!isCapabilityEntitled) {
throw new OperationNotAllowedError(
t("workspace.settings.general.ai_features_not_enabled_for_organization")
);
throw new OperationNotAllowedError(AI_ERROR_CODES.FEATURES_NOT_ENABLED);
}
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
throw new OperationNotAllowedError(
t("workspace.settings.general.ai_smart_tools_disabled_for_organization")
);
throw new OperationNotAllowedError(AI_ERROR_CODES.SMART_TOOLS_DISABLED);
}
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
throw new OperationNotAllowedError(
t("workspace.settings.general.ai_data_analysis_disabled_for_organization")
);
throw new OperationNotAllowedError(AI_ERROR_CODES.DATA_ANALYSIS_DISABLED);
}
if (!aiConfig.isInstanceConfigured) {
throw new OperationNotAllowedError(t("workspace.settings.general.ai_instance_not_configured"));
throw new OperationNotAllowedError(AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED);
}
return aiConfig;
+32 -18
View File
@@ -11,6 +11,8 @@ import {
verifyPassword,
} from "./auth";
const PASSWORD_TEST_TIMEOUT_MS = 30_000;
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -21,26 +23,38 @@ vi.mock("@formbricks/database", () => ({
}));
describe("Password Management", () => {
test("hashPassword should hash a password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
});
test(
"hashPassword should hash a password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
},
PASSWORD_TEST_TIMEOUT_MS
);
test("verifyPassword should verify a correct password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
test(
"verifyPassword should verify a correct password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
},
PASSWORD_TEST_TIMEOUT_MS
);
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
test(
"verifyPassword should reject an incorrect password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
},
PASSWORD_TEST_TIMEOUT_MS
);
});
describe("Organization Access", () => {
+62 -46
View File
@@ -12,7 +12,7 @@ import {
ZConnectorUpdateInput,
getHubFieldTypeFromElementType,
} from "@formbricks/types/connector";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthorizationError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
@@ -86,20 +86,24 @@ const resolveSurveyMappings = async (
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
return elementIds
.filter((elementId) => {
if (elementMap.has(elementId)) return true;
return elementIds.flatMap((elementId) => {
const element = elementMap.get(elementId);
if (!element) {
logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings");
return false;
})
.map((elementId) => {
const element = elementMap.get(elementId)!;
return {
surveyId,
elementId,
hubFieldType: getHubFieldTypeFromElementType(element.type),
};
});
return [];
}
const hubFieldType = getHubFieldTypeFromElementType(element.type);
if (!hubFieldType) {
logger.warn(
{ surveyId, elementId, elementType: element.type },
"Skipping unmappable element type when building connector mappings"
);
return [];
}
return [{ surveyId, elementId, hubFieldType }];
});
};
const resolveFormbricksMappingsInput = async (
@@ -108,7 +112,12 @@ const resolveFormbricksMappingsInput = async (
const allMappings = await Promise.all(
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
);
return { type: "formbricks_survey", mappings: allMappings.flat() };
const flattenedMappings = allMappings.flat();
if (flattenedMappings.length === 0) {
throw new InvalidInputError("No supported survey questions selected for connector mapping");
}
return { type: "formbricks_survey", mappings: flattenedMappings };
};
const ZFormbricksSurveyMapping = z.object({
@@ -116,15 +125,23 @@ const ZFormbricksSurveyMapping = z.object({
elementIds: z.array(z.string()).min(1),
});
// Temporary compatibility to support legacy client payloads using `formbricks`.
const ZConnectorCreateInputWithLegacyType = ZConnectorCreateInput.extend({
type: z.enum(["formbricks_survey", "csv", "formbricks"]),
});
const ZCreateConnectorWithMappingsAction = z
.object({
workspaceId: ZId,
connectorInput: ZConnectorCreateInput,
connectorInput: ZConnectorCreateInputWithLegacyType,
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
})
.superRefine((data, ctx) => {
if (data.connectorInput.type === "formbricks_survey") {
const normalizedType =
data.connectorInput.type === "formbricks" ? "formbricks_survey" : data.connectorInput.type;
if (normalizedType === "formbricks_survey") {
if (!data.formbricksMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -132,7 +149,7 @@ const ZCreateConnectorWithMappingsAction = z
message: "At least one survey mapping is required for Formbricks connectors",
});
}
} else if (data.connectorInput.type === "csv") {
} else if (normalizedType === "csv") {
if (!data.fieldMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -146,6 +163,14 @@ const ZCreateConnectorWithMappingsAction = z
export const createConnectorWithMappingsAction = authenticatedActionClient
.inputSchema(ZCreateConnectorWithMappingsAction)
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
const connectorInput = ZConnectorCreateInput.parse({
...parsedInput.connectorInput,
type:
parsedInput.connectorInput.type === "formbricks"
? "formbricks_survey"
: parsedInput.connectorInput.type,
});
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -165,7 +190,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
// Verify FRD belongs to same org
const frd = await prisma.feedbackRecordDirectory.findUnique({
where: { id: parsedInput.connectorInput.feedbackRecordDirectoryId },
where: { id: connectorInput.feedbackRecordDirectoryId },
select: { organizationId: true },
});
if (frd?.organizationId !== organizationId) {
@@ -193,7 +218,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
return createConnectorWithMappings(
parsedInput.workspaceId,
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
{ ...connectorInput, createdBy: ctx.user.id },
mappingsInput
);
});
@@ -467,6 +492,7 @@ export const importCsvDataAction = authenticatedActionClient
const ZListFeedbackRecordsAction = z.object({
workspaceId: ZId,
frdId: ZId,
limit: z.number().min(1).max(1000).optional(),
cursor: z.string().optional(),
sourceType: z.string().optional(),
@@ -504,38 +530,28 @@ export const listFeedbackRecordsAction = authenticatedActionClient
],
});
// tenant_id = FRD id. Fan out across all FRDs assigned to this workspace, merge + sort desc.
// Verify FRD belongs to workspace's accessible FRDs
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(parsedInput.workspaceId);
if (frds.length === 0) {
return { data: [], limit: parsedInput.limit ?? 50 };
if (!frds.some((f) => f.id === parsedInput.frdId)) {
throw new Error("Feedback record directory not accessible");
}
const perFrdLimit = parsedInput.limit ?? 50;
const baseParams = {
limit: perFrdLimit,
...(parsedInput.sourceType ? { source_type: parsedInput.sourceType } : {}),
...(parsedInput.fieldType ? { field_type: parsedInput.fieldType } : {}),
...(parsedInput.since ? { since: parsedInput.since } : {}),
...(parsedInput.until ? { until: parsedInput.until } : {}),
const params: FeedbackRecordListParams = {
tenant_id: parsedInput.frdId,
limit: parsedInput.limit ?? 50,
};
if (parsedInput.cursor) params.cursor = parsedInput.cursor;
if (parsedInput.sourceType) params.source_type = parsedInput.sourceType;
if (parsedInput.fieldType) params.field_type = parsedInput.fieldType;
if (parsedInput.since) params.since = parsedInput.since;
if (parsedInput.until) params.until = parsedInput.until;
const results = await Promise.all(
frds.map((frd) =>
listFeedbackRecords({ ...baseParams, tenant_id: frd.id } as FeedbackRecordListParams)
)
);
const errored = results.find((r) => r.error);
if (errored?.error) {
logger.warn({ error: errored.error }, "Failed to list feedback records");
throw new Error(errored.error.message);
const result = await listFeedbackRecords(params);
if (result.error || !result.data) {
logger.warn({ error: result.error }, "Failed to list feedback records");
throw new Error(result.error?.message ?? "Failed to load feedback records");
}
const merged = results
.flatMap((r) => r.data?.data ?? [])
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, perFrdLimit);
return { data: merged, limit: perFrdLimit };
return result.data;
}
);
+1
View File
@@ -185,6 +185,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW",
];
+48 -30
View File
@@ -14,6 +14,8 @@ import {
verifySecret,
} from "./crypto";
const SECRET_HASH_TEST_TIMEOUT_MS = 45_000;
// Unmock crypto for these tests since we want to test the actual crypto functions
vi.unmock("crypto");
@@ -26,45 +28,61 @@ vi.mock("@formbricks/logger", () => ({
describe("Crypto Utils", () => {
describe("hashSecret and verifySecret", () => {
test("should hash and verify secrets correctly", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret);
test(
"should hash and verify secrets correctly",
async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret);
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
});
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should reject wrong secrets", async () => {
const secret = "test-secret-123";
const wrongSecret = "wrong-secret";
const hash = await hashSecret(secret);
test(
"should reject wrong secrets",
async () => {
const secret = "test-secret-123";
const wrongSecret = "wrong-secret";
const hash = await hashSecret(secret);
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
});
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should generate different hashes for the same secret (due to salt)", async () => {
const secret = "test-secret-123";
const hash1 = await hashSecret(secret);
const hash2 = await hashSecret(secret);
test(
"should generate different hashes for the same secret (due to salt)",
async () => {
const secret = "test-secret-123";
const hash1 = await hashSecret(secret);
const hash2 = await hashSecret(secret);
expect(hash1).not.toBe(hash2);
expect(hash1).not.toBe(hash2);
// But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true);
}, 15000);
// But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should use custom cost factor", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret, 10);
test(
"should use custom cost factor",
async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret, 10);
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
});
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should return false for invalid hash format", async () => {
const secret = "test-secret-123";
+63 -1
View File
@@ -8,6 +8,7 @@ const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
NODE_ENV: "test",
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
HUB_API_URL: "https://hub.formbricks.local",
...overrides,
};
};
@@ -21,13 +22,22 @@ describe("env", () => {
process.env = ORIGINAL_ENV;
});
test("allows ambient DEBUG values from external tooling", async () => {
setTestEnv({
DEBUG: "pnpm:*",
});
const { env } = await import("./env");
expect(env.DEBUG).toBe("pnpm:*");
});
test("uses the default password reset token lifetime when env var is not set", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
});
@@ -67,6 +77,58 @@ describe("env", () => {
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
});
test("uses the default survey scheduling configuration when env vars are not set", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: undefined,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: undefined,
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: undefined,
});
const { env } = await import("./env");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE).toBe("Europe/Berlin");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR).toBe(0);
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(0);
});
test("uses the configured survey scheduling configuration", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: "18",
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: "45",
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: "America/New_York",
});
const { env } = await import("./env");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE).toBe("America/New_York");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR).toBe(18);
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(45);
});
test("fails to load when the survey scheduling timezone is invalid", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: "Mars/OlympusMons",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the survey scheduling hour is out of range", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: "24",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the survey scheduling minute is out of range", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: "60",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when DEBUG_SHOW_RESET_LINK is invalid", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "true",
+35 -1
View File
@@ -107,6 +107,22 @@ const validateActiveAIProviderConfiguration = (values: TAIConfigurationEnv, ctx:
providerValidators[values.AI_PROVIDER](values, ctx);
};
const isValidIanaTimeZone = (value: string): boolean => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value });
return true;
} catch {
return false;
}
};
const ZSurveySchedulingTimeZone = z.string().trim().min(1).refine(isValidIanaTimeZone, {
message: "NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE must be a valid IANA time zone",
});
const ZSurveySchedulingLocalHour = z.coerce.number().int().min(0).max(23);
const ZSurveySchedulingLocalMinute = z.coerce.number().int().min(0).max(59);
const parsedEnv = createEnv({
/*
* Serverside Environment variables, not available on the client.
@@ -124,10 +140,16 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
// DEBUG is a common ambient env var in CI/tooling, so we accept arbitrary strings here
// and only treat "1" as enabling Formbricks-specific debug behavior downstream.
DEBUG: z.string().optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
BULLMQ_WORKER_CONCURRENCY: z.coerce.number().int().min(1).optional(),
BULLMQ_WORKER_COUNT: z.coerce.number().int().min(1).optional(),
BULLMQ_EXTERNAL_WORKER_ENABLED: z.enum(["1", "0"]).optional(),
BULLMQ_WORKER_ENABLED: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
@@ -251,6 +273,11 @@ const parsedEnv = createEnv({
.optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
},
client: {
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: ZSurveySchedulingTimeZone.optional().default("Europe/Berlin"),
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: ZSurveySchedulingLocalHour.optional().default(0),
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: ZSurveySchedulingLocalMinute.optional().default(0),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -274,6 +301,10 @@ const parsedEnv = createEnv({
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
BULLMQ_EXTERNAL_WORKER_ENABLED: process.env.BULLMQ_EXTERNAL_WORKER_ENABLED,
BULLMQ_WORKER_CONCURRENCY: process.env.BULLMQ_WORKER_CONCURRENCY,
BULLMQ_WORKER_COUNT: process.env.BULLMQ_WORKER_COUNT,
BULLMQ_WORKER_ENABLED: process.env.BULLMQ_WORKER_ENABLED,
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
@@ -315,6 +346,9 @@ const parsedEnv = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE,
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE,
SENTRY_DSN: process.env.SENTRY_DSN,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
+7
View File
@@ -213,6 +213,13 @@ export const appLanguages = [
native: "Svenska",
},
},
{
code: "tr-TR",
label: {
"en-US": "Turkish",
native: "Türkçe",
},
},
{
code: "zh-Hans-CN",
label: {
+1 -1
View File
@@ -122,7 +122,7 @@ describe("Integration Service", () => {
},
];
test("should get all integrations for an environment", async () => {
test("should get all integrations for a workspace", async () => {
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
const result = await getIntegrations(mockWorkspaceId);
+180
View File
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const TEST_TIMEOUT_MS = 15_000;
describe("jobs runtime config", () => {
beforeEach(() => {
vi.resetModules();
});
test(
"defaults to one worker with concurrency one outside tests",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: undefined,
BULLMQ_WORKER_COUNT: undefined,
BULLMQ_WORKER_ENABLED: undefined,
NODE_ENV: "development",
REDIS_URL: "redis://localhost:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://localhost:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"disables the worker by default in tests",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: undefined,
BULLMQ_WORKER_COUNT: undefined,
BULLMQ_WORKER_ENABLED: undefined,
NODE_ENV: "test",
REDIS_URL: undefined,
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: false,
redisUrl: null,
});
},
TEST_TIMEOUT_MS
);
test(
"uses explicit worker tuning overrides",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "1",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: true,
runtimeOptions: {
concurrency: 6,
redisUrl: "redis://cache.internal:6379",
workerCount: 3,
},
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://cache.internal:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"disables queueing when no BullMQ consumer is configured",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "0",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: false,
redisUrl: null,
});
},
TEST_TIMEOUT_MS
);
test(
"keeps queueing enabled when an external BullMQ worker is configured",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: "1",
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "0",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://cache.internal:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"throws when the worker is enabled without a redis url",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 2,
BULLMQ_WORKER_COUNT: 1,
BULLMQ_WORKER_ENABLED: "1",
NODE_ENV: "production",
REDIS_URL: undefined,
},
}));
const { getJobsWorkerBootstrapConfig } = await import("./config");
expect(() => getJobsWorkerBootstrapConfig()).toThrow(
"REDIS_URL is required to start the BullMQ worker"
);
},
TEST_TIMEOUT_MS
);
});
+68
View File
@@ -0,0 +1,68 @@
import "server-only";
import type { JobsRuntimeOptions } from "@formbricks/jobs";
import { env } from "@/lib/env";
const DEFAULT_BULLMQ_WORKER_CONCURRENCY = 1;
const DEFAULT_BULLMQ_WORKER_COUNT = 1;
export interface JobsWorkerBootstrapConfig {
enabled: boolean;
runtimeOptions: JobsRuntimeOptions | null;
}
export interface JobsQueueingConfig {
enabled: boolean;
redisUrl: string | null;
}
export const BULLMQ_WORKER_CONCURRENCY = env.BULLMQ_WORKER_CONCURRENCY ?? DEFAULT_BULLMQ_WORKER_CONCURRENCY;
export const BULLMQ_WORKER_COUNT = env.BULLMQ_WORKER_COUNT ?? DEFAULT_BULLMQ_WORKER_COUNT;
const getBullMqWorkerEnabled = (): boolean => {
if (env.BULLMQ_WORKER_ENABLED !== undefined) {
return env.BULLMQ_WORKER_ENABLED === "1";
}
return env.NODE_ENV !== "test";
};
export const BULLMQ_WORKER_ENABLED = getBullMqWorkerEnabled();
export const BULLMQ_EXTERNAL_WORKER_ENABLED = env.BULLMQ_EXTERNAL_WORKER_ENABLED === "1";
const hasBullMqConsumer = (): boolean => BULLMQ_WORKER_ENABLED || BULLMQ_EXTERNAL_WORKER_ENABLED;
export const getJobsQueueingConfig = (): JobsQueueingConfig => {
if (!env.REDIS_URL || !hasBullMqConsumer()) {
return {
enabled: false,
redisUrl: null,
};
}
return {
enabled: true,
redisUrl: env.REDIS_URL,
};
};
export const getJobsWorkerBootstrapConfig = (): JobsWorkerBootstrapConfig => {
if (!BULLMQ_WORKER_ENABLED) {
return {
enabled: false,
runtimeOptions: null,
};
}
if (!env.REDIS_URL) {
throw new Error("REDIS_URL is required to start the BullMQ worker");
}
return {
enabled: true,
runtimeOptions: {
concurrency: BULLMQ_WORKER_CONCURRENCY,
redisUrl: env.REDIS_URL,
workerCount: BULLMQ_WORKER_COUNT,
},
};
};
+35 -14
View File
@@ -93,6 +93,14 @@ export const getResponseContact = (
};
};
const mapResponsePrismaToResponse = (
responsePrisma: Prisma.ResponseGetPayload<{ select: typeof responseSelection }>
): TResponse => ({
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});
export const getResponsesByContactId = reactCache(
async (contactId: string, page?: number): Promise<TResponseWithQuotas[]> => {
validateInputs([contactId, ZId], [page, ZOptionalNumber]);
@@ -172,13 +180,7 @@ export const getResponseBySingleUseId = reactCache(
return null;
}
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
return mapResponsePrismaToResponse(responsePrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -204,13 +206,7 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
return null;
}
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
return mapResponsePrismaToResponse(responsePrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -220,6 +216,31 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
}
});
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
select: responseSelection,
});
if (!responsePrisma) {
return null;
}
return mapResponsePrismaToResponse(responsePrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getResponseFilteringValues = reactCache(async (surveyId: string) => {
validateInputs([surveyId, ZId]);
@@ -186,6 +186,8 @@ const baseSurveyProperties = {
autoClose: 10,
delay: 0,
autoComplete: 7,
publishOn: null,
closeOn: null,
redirectUrl: "https://github.com/formbricks/formbricks",
recontactDays: 3,
displayLimit: 3,
@@ -0,0 +1,379 @@
import { prisma } from "@/lib/__mocks__/database";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ValidationError } from "@formbricks/types/errors";
import { getActionClasses } from "@/lib/actionClass/service";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import {
createSurveyInput,
mockActionClass,
mockSurveyOutput,
updateSurveyInput,
} from "./__mock__/survey.mock";
import { createSurvey, updateSurveyInternal } from "./service";
const { mockQueueAuditEventWithoutRequest } = vi.hoisted(() => ({
mockQueueAuditEventWithoutRequest: vi.fn(),
}));
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn(),
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventWithoutRequest: mockQueueAuditEventWithoutRequest,
}));
const createSchedulingCandidate = ({
id = updateSurveyInput.id,
closeOn,
publishOn,
status,
workspaceId = updateSurveyInput.workspaceId,
}: {
id?: string;
closeOn: Date | null;
publishOn: Date | null;
status: "draft" | "paused" | "inProgress";
workspaceId?: string;
}) => ({
id,
closeOn,
publishOn,
status,
workspace: {
organizationId: "org123",
},
workspaceId,
});
describe("survey service scheduling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-17T12:30:00.000Z"));
vi.mocked(getActionClasses).mockResolvedValue([mockActionClass] as never);
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({ id: "org123" } as never);
mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
test("manual publish clears publishOn", async () => {
const scheduledPublishSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: scheduledPublishSelection,
status: "draft",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "inProgress",
} as never);
await updateSurveyInternal(
{
...updateSurveyInput,
publishOn: scheduledPublishSelection,
status: "inProgress",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
publishOn: null,
status: "inProgress",
}),
})
);
});
test("manual paused status clears closeOn", async () => {
const scheduledCloseSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: scheduledCloseSelection,
status: "inProgress",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
status: "paused",
} as never);
await updateSurveyInternal(
{
...updateSurveyInput,
closeOn: scheduledCloseSelection,
status: "paused",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
closeOn: null,
status: "paused",
}),
})
);
});
test("scheduling from draft keeps closeOn when publishOn is set", async () => {
const scheduledPublishSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
const scheduledCloseSelection = new Date(Date.UTC(2026, 3, 21, 12, 0, 0));
const normalizedPublishOn = new Date("2026-04-19T22:00:00.000Z");
const normalizedCloseOn = new Date("2026-04-20T22:00:00.000Z");
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
publishOn: null,
status: "draft",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: normalizedCloseOn,
publishOn: normalizedPublishOn,
status: "paused",
} as never);
prisma.survey.findMany.mockResolvedValueOnce([] as never).mockResolvedValueOnce([] as never);
await updateSurveyInternal(
{
...updateSurveyInput,
closeOn: scheduledCloseSelection,
publishOn: scheduledPublishSelection,
status: "paused",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
closeOn: normalizedCloseOn,
publishOn: normalizedPublishOn,
status: "paused",
}),
})
);
});
test("manual completion clears both scheduling dates", async () => {
const scheduledSelection = new Date(Date.UTC(2026, 3, 20, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: scheduledSelection,
publishOn: scheduledSelection,
status: "inProgress",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
publishOn: null,
status: "completed",
} as never);
await updateSurveyInternal(
{
...updateSurveyInput,
closeOn: scheduledSelection,
publishOn: scheduledSelection,
status: "completed",
},
true
);
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
closeOn: null,
publishOn: null,
status: "completed",
}),
})
);
});
test("saving a due publish schedule catches up immediately for paused surveys", async () => {
const dueSelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
const normalizedDuePublishOn = new Date("2026-04-16T22:00:00.000Z");
prisma.survey.findUnique
.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "paused",
} as never)
.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "inProgress",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: normalizedDuePublishOn,
status: "paused",
} as never);
prisma.survey.findMany
.mockResolvedValueOnce([
createSchedulingCandidate({
closeOn: null,
publishOn: normalizedDuePublishOn,
status: "paused",
}),
] as never)
.mockResolvedValueOnce([] as never);
prisma.survey.updateMany.mockResolvedValueOnce({ count: 1 } as never);
const updatedSurvey = await updateSurveyInternal(
{
...updateSurveyInput,
publishOn: dueSelection,
status: "paused",
},
true
);
expect(updatedSurvey.status).toBe("inProgress");
expect(prisma.survey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
publishOn: normalizedDuePublishOn,
}),
})
);
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledTimes(1);
});
test("saving a due publish schedule in draft does not auto-publish", async () => {
const dueSelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
const normalizedDuePublishOn = new Date("2026-04-16T22:00:00.000Z");
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "draft",
} as never);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: normalizedDuePublishOn,
status: "draft",
} as never);
prisma.survey.findMany.mockResolvedValueOnce([] as never).mockResolvedValueOnce([] as never);
const updatedSurvey = await updateSurveyInternal(
{
...updateSurveyInput,
publishOn: dueSelection,
status: "draft",
},
true
);
expect(updatedSurvey.status).toBe("draft");
expect(prisma.survey.updateMany).not.toHaveBeenCalled();
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledTimes(0);
});
test("creating a paused survey with a due publish schedule catches up immediately", async () => {
const dueSelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
const normalizedDuePublishOn = new Date("2026-04-16T22:00:00.000Z");
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: normalizedDuePublishOn,
status: "paused",
type: "link",
} as never);
prisma.survey.findMany
.mockResolvedValueOnce([
createSchedulingCandidate({
closeOn: null,
publishOn: normalizedDuePublishOn,
status: "paused",
}),
] as never)
.mockResolvedValueOnce([] as never);
prisma.survey.updateMany.mockResolvedValueOnce({ count: 1 } as never);
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
publishOn: null,
status: "inProgress",
type: "link",
} as never);
const createdSurvey = await createSurvey(updateSurveyInput.workspaceId, {
...createSurveyInput,
name: "Scheduled survey",
publishOn: dueSelection,
status: "paused",
type: "link",
});
expect(createdSurvey.status).toBe("inProgress");
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
publishOn: normalizedDuePublishOn,
}),
})
);
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledTimes(1);
});
test("same-day publish and close is rejected", async () => {
const sameDaySelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
prisma.survey.findUnique.mockResolvedValueOnce({
...mockSurveyOutput,
closeOn: null,
publishOn: null,
status: "paused",
} as never);
await expect(
updateSurveyInternal(
{
...updateSurveyInput,
closeOn: sameDaySelection,
publishOn: sameDaySelection,
status: "paused",
},
true
)
).rejects.toThrow(ValidationError);
expect(prisma.survey.update).not.toHaveBeenCalled();
expect(mockQueueAuditEventWithoutRequest).not.toHaveBeenCalled();
});
test("creating a survey with the same publish and close date is rejected", async () => {
const sameDaySelection = new Date(Date.UTC(2026, 3, 17, 12, 0, 0));
await expect(
createSurvey(updateSurveyInput.workspaceId, {
...createSurveyInput,
closeOn: sameDaySelection,
name: "Scheduled survey",
publishOn: sameDaySelection,
status: "paused",
type: "link",
})
).rejects.toThrow(ValidationError);
expect(prisma.survey.create).not.toHaveBeenCalled();
expect(mockQueueAuditEventWithoutRequest).not.toHaveBeenCalled();
});
});
+37 -21
View File
@@ -5,7 +5,12 @@ import { testInputValidation } from "vitestSetup";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyCreateInput, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getActionClasses } from "@/lib/actionClass/service";
@@ -14,6 +19,7 @@ import {
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { evaluateLogic } from "@/lib/surveyLogic/utils";
import { handleTriggerUpdates } from "@/modules/survey/lib/trigger-updates";
import {
mockActionClass,
mockId,
@@ -30,12 +36,13 @@ import {
getSurveys,
getSurveysByActionClassId,
getSurveysBySegmentId,
handleTriggerUpdates,
loadNewSegmentInSurvey,
updateSurvey,
updateSurveyInternal,
} from "./service";
const SURVEY_SERVICE_TEST_TIMEOUT_MS = 30_000;
// Mock organization service
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn().mockResolvedValue({
@@ -315,7 +322,13 @@ describe("Tests for updateSurvey", () => {
});
describe("Sad Path", () => {
testInputValidation(updateSurvey, "123#");
test(
"throws a ValidationError if the inputs are invalid",
async () => {
await expect(updateSurvey("123#" as unknown as TSurvey)).rejects.toThrow(ValidationError);
},
SURVEY_SERVICE_TEST_TIMEOUT_MS
);
test("Throws ResourceNotFoundError if the survey does not exist", async () => {
prisma.survey.findUnique.mockRejectedValueOnce(
@@ -346,12 +359,12 @@ describe("Tests for updateSurvey", () => {
describe("Tests for getSurveyCount service", () => {
describe("Happy Path", () => {
test("Counts the total number of surveys for a given environment ID", async () => {
test("Counts the total number of surveys for a given workspace ID", async () => {
const count = await getSurveyCount(mockId);
expect(count).toEqual(1);
});
test("Returns zero count when there are no surveys for a given environment ID", async () => {
test("Returns zero count when there are no surveys for a given workspace ID", async () => {
prisma.survey.count.mockResolvedValue(0);
const count = await getSurveyCount(mockId);
expect(count).toEqual(0);
@@ -631,7 +644,6 @@ describe("Tests for createSurvey", () => {
beforeEach(() => {
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses as TActionClass[]);
// environment model removed - no mock needed
});
describe("Happy Path", () => {
@@ -1007,21 +1019,25 @@ describe("updateSurveyDraftAction", () => {
});
describe("Sad Path", () => {
test("should reject publishing survey with incomplete translations", async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
test(
"should reject publishing survey with incomplete translations",
async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
},
SURVEY_SERVICE_TEST_TIMEOUT_MS
);
});
});
+134 -118
View File
@@ -1,5 +1,5 @@
import "server-only";
import { ActionClass, Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -11,7 +11,12 @@ import {
getOrganizationByWorkspaceId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { handleTriggerUpdates } from "@/modules/survey/lib/trigger-updates";
import {
isSurveySchedulingDue,
normalizeSurveyScheduling,
reconcileDueSurveySchedules,
} from "@/modules/survey/scheduling/lib/survey-scheduling";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -45,6 +50,8 @@ export const selectSurvey = {
delay: true,
displayPercentage: true,
autoComplete: true,
publishOn: true,
closeOn: true,
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true,
@@ -107,74 +114,45 @@ export const selectSurvey = {
slug: true,
} satisfies Prisma.SurveySelect;
const getTriggerIds = (triggers: TSurvey["triggers"]): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
const reconcilePersistedSurveySchedulingIfDue = async ({
logSource,
survey,
workspaceId,
}: {
logSource: "survey-create" | "survey-update";
survey: TSurvey;
workspaceId: string;
}): Promise<TSurvey> => {
const now = new Date();
if (!isSurveySchedulingDue(survey, now)) {
return survey;
}
return triggers.map((trigger) => {
const actionClassId = trigger?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
const reconciliationResult = await reconcileDueSurveySchedules({
logContext: {
source: logSource,
surveyId: survey.id,
workspaceId,
},
now,
surveyId: survey.id,
});
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
};
export const handleTriggerUpdates = (
updatedTriggers: TSurvey["triggers"],
currentTriggers: TSurvey["triggers"],
actionClasses: ActionClass[]
) => {
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
if (!reconciliationResult.surveyUpdated) {
return survey;
}
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggerIds,
},
};
const reconciledSurvey = await prisma.survey.findUnique({
where: { id: survey.id },
select: selectSurvey,
});
if (!reconciledSurvey) {
throw new ResourceNotFoundError("Survey", survey.id);
}
return triggersUpdate;
return transformPrismaSurvey<TSurvey>(reconciledSurvey);
};
export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey | null> => {
@@ -537,12 +515,16 @@ export const updateSurveyInternal = async (
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
const organization = await getOrganizationByWorkspaceId(updatedSurvey.workspaceId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
const normalizedScheduling = normalizeSurveyScheduling({
currentStatus: currentSurvey.status,
closeOn: surveyData.closeOn,
publishOn: surveyData.publishOn,
status: updatedSurvey.status,
});
surveyData.updatedAt = new Date();
surveyData.publishOn = normalizedScheduling.publishOn;
surveyData.closeOn = normalizedScheduling.closeOn;
data = {
...surveyData,
@@ -551,28 +533,17 @@ export const updateSurveyInternal = async (
};
delete data.createdBy;
const prismaSurvey = await prisma.survey.update({
const persistedSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
return await reconcilePersistedSurveySchedulingIfDue({
logSource: "survey-update",
survey: transformPrismaSurvey<TSurvey>(persistedSurvey),
workspaceId: updatedSurvey.workspaceId,
});
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -592,6 +563,63 @@ export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise<TSurvey
return updateSurveyInternal(updatedSurvey, true);
};
const attachSurveyCreatorToCreateData = (
data: Omit<Prisma.SurveyCreateInput, "workspace">,
createdBy?: string | null
): Omit<Prisma.SurveyCreateInput, "workspace"> => {
if (!createdBy) {
return data;
}
return {
...data,
creator: {
connect: {
id: createdBy,
},
},
};
};
const attachSurveyFollowUpsToCreateData = (
data: Omit<Prisma.SurveyCreateInput, "workspace">,
followUps?: TSurveyCreateInput["followUps"]
): Omit<Prisma.SurveyCreateInput, "workspace"> => {
const { followUps: _, ...dataWithoutFollowUps } = data;
if (!followUps?.length) {
return dataWithoutFollowUps;
}
return {
...dataWithoutFollowUps,
followUps: {
create: followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
},
};
};
const validateSurveyCreateDataMedia = (
data: Omit<Prisma.SurveyCreateInput, "workspace">
): Omit<Prisma.SurveyCreateInput, "workspace"> => {
if (data.questions) {
checkForInvalidImagesInQuestions(data.questions);
}
if (data.blocks?.length) {
return {
...data,
blocks: validateMediaAndPrepareBlocks(data.blocks),
};
}
return data;
};
export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreateInput): Promise<TSurvey> => {
const [parsedWorkspaceId, parsedSurveyBody] = validateInputs(
[workspaceId, ZId],
@@ -600,55 +628,37 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
try {
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const actionClasses = await getActionClasses(parsedWorkspaceId);
let data: Omit<Prisma.SurveyCreateInput, "workspace"> = {
const baseData: Omit<Prisma.SurveyCreateInput, "workspace"> = {
...restSurveyBody,
...normalizeSurveyScheduling({
closeOn: normalizedCloseOn,
publishOn: normalizedPublishOn,
status: restSurveyBody.status ?? "draft",
}),
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
? // @ts-expect-error - triggers' createdAt and updatedAt are actually dates
handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
if (createdBy) {
data.creator = {
connect: {
id: createdBy,
},
};
}
const data = validateSurveyCreateDataMedia(
attachSurveyFollowUpsToCreateData(
attachSurveyCreatorToCreateData(baseData, createdBy),
restSurveyBody.followUps
)
);
const organization = await getOrganizationByWorkspaceId(parsedWorkspaceId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
create: restSurveyBody.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
};
} else {
delete data.followUps;
}
if (data.questions) {
checkForInvalidImagesInQuestions(data.questions);
}
// Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -702,11 +712,17 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
}),
};
const reconciledSurvey = await reconcilePersistedSurveySchedulingIfDue({
logSource: "survey-create",
survey: transformedSurvey,
workspaceId: parsedWorkspaceId,
});
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
await subscribeOrganizationMembersToSurveyResponses(reconciledSurvey.id, createdBy, organization.id);
}
return transformedSurvey;
return reconciledSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error creating survey");
+2 -1
View File
@@ -1,5 +1,5 @@
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
@@ -17,6 +17,7 @@ const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"tr-TR": tr,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
+1 -1
View File
@@ -54,7 +54,7 @@ export const findRecallInfoById = (text: string, id: string): string | null => {
return match ? match[0] : null;
};
const getRecallItemLabel = <T extends TSurvey>(
export const getRecallItemLabel = <T extends TSurvey>(
recallItemId: string,
survey: T,
languageCode: string
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,4 +32,23 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false);
});
});
describe("toSafeIdentifier", () => {
test("normalizes free-form labels into safe identifiers", () => {
expect(toSafeIdentifier("Date of Birth")).toBe("date_of_birth");
expect(toSafeIdentifier("Customer-ID")).toBe("customer_id");
expect(toSafeIdentifier(" Preferred Language ")).toBe("preferred_language");
expect(toSafeIdentifier("city__name")).toBe("city_name");
});
test("strips invalid leading characters until first lowercase letter", () => {
expect(toSafeIdentifier("123 Date")).toBe("date");
expect(toSafeIdentifier("__name")).toBe("name");
expect(toSafeIdentifier("99")).toBe("");
});
test("keeps already safe identifiers unchanged", () => {
expect(toSafeIdentifier("country_code")).toBe("country_code");
});
});
});
+38
View File
@@ -12,6 +12,44 @@ export const isSafeIdentifier = (value: string): boolean => {
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a free-form string to a safe identifier candidate.
* The output only contains lowercase letters, numbers, and underscores.
* It also ensures the identifier starts with a lowercase letter by stripping invalid leading chars.
*/
export const toSafeIdentifier = (value: string): string => {
const normalized = value.trim().toLowerCase();
let safeIdentifier = "";
let shouldInsertUnderscore = false;
for (const char of normalized) {
const isLowercaseLetter = char >= "a" && char <= "z";
const isDigit = char >= "0" && char <= "9";
if (isLowercaseLetter || isDigit) {
if (shouldInsertUnderscore && safeIdentifier.length > 0) {
safeIdentifier += "_";
}
safeIdentifier += char;
shouldInsertUnderscore = false;
continue;
}
if (safeIdentifier.length > 0) {
shouldInsertUnderscore = true;
}
}
for (let i = 0; i < safeIdentifier.length; i++) {
const char = safeIdentifier[i];
if (char >= "a" && char <= "z") {
return safeIdentifier.slice(i);
}
}
return "";
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
+222 -134
View File
@@ -125,6 +125,7 @@
"activity": "Aktivität",
"add": "Hinzufügen",
"add_action": "Aktion hinzufügen",
"add_chart": "Diagramm hinzufügen",
"add_charts": "Diagramme hinzufügen",
"add_existing_chart_description": "Suche und wähle Diagramme aus, um sie zu diesem Dashboard hinzuzufügen.",
"add_filter": "Filter hinzufügen",
@@ -159,6 +160,7 @@
"change_workspace": "Workspace wechseln",
"chart": "Diagramm",
"charts": "Diagramme",
"choice_n": "Auswahl {n}",
"choices": "Entscheidungen",
"choose_organization": "Organisation auswählen",
"choose_workspace": "Projekt auswählen",
@@ -171,8 +173,9 @@
"close": "Schließen",
"code": "Code",
"collapse_rows": "Zeilen einklappen",
"column_n": "Spalte {n}",
"completed": "Abgeschlossen",
"configuration": "Konfiguration",
"configuration": "Konfigurieren",
"confirm": "Bestätigen",
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
@@ -230,7 +233,6 @@
"ending_card": "Abschlusskarte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise-Lizenz",
"environment": "Umgebung",
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder du hast nicht die erforderlichen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
@@ -238,10 +240,11 @@
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte versuche es später erneut.",
"error_rate_limit_title": "Rate-Limit überschritten",
"expand_rows": "Zeilen erweitern",
"failed_to_copy_to_clipboard": "Kopieren in die Zwischenablage fehlgeschlagen",
"failed_to_load_organizations": "Laden der Organisationen fehlgeschlagen",
"failed_to_load_workspaces": "Laden der Arbeitsbereiche fehlgeschlagen",
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"failed_to_parse_csv": "CSV-Analyse fehlgeschlagen",
"field_placeholder": "Platzhalter für {field}",
"filter": "Filter",
"finish": "Fertig",
"first_name": "Vorname",
@@ -253,11 +256,13 @@
"generate": "Generieren",
"go_back": "Geh zurück",
"go_to_dashboard": "Zum Dashboard gehen",
"headline": "Überschrift",
"hidden": "Versteckt",
"hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder",
"hide": "Ausblenden",
"hide_column": "Spalte ausblenden",
"html": "HTML",
"id": "ID",
"image": "Bild",
"images": "Bilder",
@@ -306,7 +311,6 @@
"more_options": "Weitere Optionen",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
"my_product": "mein Produkt",
"name": "Name",
"new": "Neu",
@@ -323,10 +327,12 @@
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
"no_surveys_found": "Keine Umfragen gefunden.",
"none_of_the_above": "Nichts davon",
"no_text_found": "Kein Text gefunden",
"none_of_the_above": "Keine der oben genannten Optionen",
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
"not_authorized": "Nicht autorisiert",
"not_connected": "Nicht verbunden",
"not_set": "Nicht festgelegt",
"note": "Hinweis",
"notifications": "Benachrichtigungen",
"number": "Nummer",
@@ -347,7 +353,7 @@
"organization_settings": "Organisationseinstellungen",
"other": "Sonstiges",
"other_filters": "Weitere Filter",
"others": "Andere",
"other_placeholder": "Sonstiger Platzhalter",
"overlay_color": "Overlay-Farbe",
"overview": "Übersicht",
"password": "Passwort",
@@ -365,10 +371,8 @@
"please_upgrade_your_plan": "Bitte upgrade deinen Plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Vorschau",
"preview_survey": "Umfrage-Vorschau",
"privacy": "Datenschutzerklärung",
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"production": "Produktion",
"profile": "Profil",
"profile_id": "Profil-ID",
"progress": "Fortschritt",
@@ -388,18 +392,22 @@
"report_survey": "Umfrage melden",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"resize": "Größe ändern",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
"restart": "Neu starten",
"retry": "Erneut versuchen",
"role": "Rolle",
"row_n": "Zeile {n}",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
"save_as_draft": "Als Entwurf speichern",
"save_changes": "Änderungen speichern",
"save_without_scheduling": "Ohne Planung speichern",
"saving": "Speichert",
"scheduled": "Geplant",
"search": "Suchen",
"search_charts": "Diagramme durchsuchen...",
"security": "Sicherheit",
@@ -426,6 +434,7 @@
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
"something_went_wrong": "Etwas ist schiefgelaufen",
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"soon": "Bald",
"sort_by": "Sortieren nach",
"start_free_trial": "Kostenlose Testversion starten",
"status": "Status",
@@ -433,7 +442,8 @@
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"string": "Text",
"styling": "Styling",
"submit": "Absenden",
"subheader": "Unterüberschrift",
"submit": "Abschicken",
"summary": "Zusammenfassung",
"survey": "Umfrage",
"survey_completed": "Umfrage abgeschlossen.",
@@ -441,6 +451,7 @@
"survey_languages": "Umfragesprachen",
"survey_live": "Umfrage live",
"survey_paused": "Umfrage pausiert.",
"survey_scheduled": "Umfrage geplant.",
"survey_type": "Umfragetyp",
"surveys": "Umfragen",
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
@@ -503,7 +514,6 @@
"workspaces": "Projekte",
"years": "Jahre",
"yes": "Ja",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
"you_have_reached_your_limit_of_workspace_limit": "Du hast dein Limit von {workspaceLimit} Workspaces erreicht.",
@@ -521,11 +531,11 @@
"email_footer_text_2": "Dein Formbricks-Team",
"email_template_text_1": "Diese E-Mail wurde über Formbricks versendet.",
"embed_survey_preview_email_didnt_request": "Nicht angefordert?",
"embed_survey_preview_email_environment_id": "Umgebungs-ID",
"embed_survey_preview_email_fight_spam": "Hilf uns, Spam zu bekämpfen und leite diese E-Mail an hola@formbricks.com weiter",
"embed_survey_preview_email_heading": "E-Mail-Einbettung Vorschau",
"embed_survey_preview_email_subject": "Formbricks E-Mail-Umfrage Vorschau",
"embed_survey_preview_email_text": "So sieht das Code-Snippet eingebettet in einer E-Mail aus:",
"embed_survey_preview_email_workspace_id": "Workspace-ID",
"forgot_password_email_change_password": "Passwort ändern",
"forgot_password_email_did_not_request": "Falls Du das nicht angefordert hast, kannst Du diese E-Mail einfach ignorieren.",
"forgot_password_email_heading": "Passwort ändern",
@@ -628,6 +638,7 @@
"question_preview": "Fragenvorschau",
"response_already_received": "Wir haben bereits eine Antwort für diese E-Mail-Adresse erhalten.",
"response_submitted": "Es existiert bereits eine Antwort, die mit dieser Umfrage und diesem Kontakt verknüpft ist",
"scheduled": "Diese Umfrage ist geplant und wird bald live gehen.",
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
"survey_sent_to": "Umfrage gesendet an {email}",
@@ -762,11 +773,15 @@
"career_development_survey_question_6_choice_1": "Einzelner Mitarbeiter",
"career_development_survey_question_6_choice_2": "Manager",
"career_development_survey_question_6_choice_3": "Senior Manager",
"career_development_survey_question_6_choice_4": "Vice President",
"career_development_survey_question_6_choice_5": "Führungskraft",
"career_development_survey_question_6_choice_6": "Sonstiges",
"career_development_survey_question_6_headline": "Welche der folgenden Optionen beschreibt deine aktuelle Jobstufe am besten?",
"career_development_survey_question_6_subheader": "Bitte wähle eine der folgenden Optionen:",
"career_development_survey_question_6_choice_4": "Vizepräsident",
"career_development_survey_question_6_choice_5": "Geschäftsführung",
"career_development_survey_question_6_choice_6": "Andere",
"career_development_survey_question_6_headline": "Was beschreibt deine aktuelle Position am besten?",
"career_development_survey_question_6_subheader": "Bitte wähle eine der folgenden Optionen",
"ces": "Kundenaufwand (CES)",
"ces_description": "Customer Effort Score messen (1-5 oder 1-7)",
"ces_lower_label": "Sehr schwierig",
"ces_upper_label": "Sehr einfach",
"cess_survey_name": "CES-Umfrage",
"cess_survey_question_1_headline": "$[workspaceName] macht es mir leicht, [ZIEL HINZUFÜGEN]",
"cess_survey_question_1_lower_label": "Stimme überhaupt nicht zu",
@@ -826,11 +841,13 @@
"collect_feedback_question_6_headline": "Wie hast du von uns erfahren?",
"collect_feedback_question_7_headline": "Zum Abschluss würden wir gerne auf dein Feedback antworten. Bitte teile deine E-Mail-Adresse:",
"collect_feedback_question_7_placeholder": "beispiel@email.com",
"consent": "Einwilligung",
"consent_description": "Bitte um Zustimmung zu Bedingungen, Konditionen oder Datennutzung",
"contact_info": "Kontaktdaten",
"contact_info_description": "Frage nach Vorname, Nachname, E-Mail, Telefonnummer und Firma zusammen",
"csat_description": "Miss die Kundenzufriedenheit (CSAT) für dein Produkt oder deine Dienstleistung.",
"consent": "Zustimmung",
"consent_description": "Bitte um Zustimmung zu den Bedingungen, Konditionen oder der Datennutzung",
"contact_info": "Kontaktinfo",
"contact_info_description": "Bitte nach Name, Nachname, E-Mail, Telefonnummer und Firma gemeinsam fragen",
"csat": "Kundenzufriedenheit (CSAT)",
"csat_description": "Customer Satisfaction Score messen (1-5)",
"csat_lower_label": "Sehr unzufrieden",
"csat_name": "Kundenzufriedenheitswert (CSAT)",
"csat_question_10_headline": "Hast du noch weitere Anmerkungen, Fragen oder Anliegen?",
"csat_question_10_placeholder": "Gib hier deine Antwort ein…",
@@ -903,10 +920,11 @@
"csat_survey_question_1_lower_label": "Äußerst unzufrieden",
"csat_survey_question_1_upper_label": "Äußerst zufrieden",
"csat_survey_question_2_headline": "Super! Gibt es etwas, das wir tun können, um deine Erfahrung zu verbessern?",
"csat_survey_question_2_placeholder": "Gib hier deine Antwort ein…",
"csat_survey_question_3_headline": "Oh je, tut uns leid! Gibt es etwas, das wir tun können, um deine Erfahrung zu verbessern?",
"csat_survey_question_3_placeholder": "Gib hier deine Antwort ein…",
"cta_description": "Zeige Informationen an und fordere Nutzer zu einer bestimmten Aktion auf",
"csat_survey_question_2_placeholder": "Tippe deine Antwort hier...",
"csat_survey_question_3_headline": "Ugh, sorry! Können wir irgendwas tun, um deine Erfahrung zu verbessern?",
"csat_survey_question_3_placeholder": "Tippe deine Antwort hier...",
"csat_upper_label": "Sehr zufrieden",
"cta_description": "Information anzeigen und Benutzer auffordern, eine bestimmte Aktion auszuführen",
"custom_survey_description": "Erstelle eine Umfrage ohne Vorlage.",
"custom_survey_name": "Von Grund auf neu beginnen",
"custom_survey_question_1_headline": "Was möchtest du wissen?",
@@ -1071,12 +1089,17 @@
"gauge_feature_satisfaction_question_2_headline": "Was könnten wir besser machen?",
"identify_customer_goals_description": "Verstehe besser, ob deine Botschaft die richtigen Erwartungen an den Wert deines Produkts weckt.",
"identify_customer_goals_name": "Kundenziele identifizieren",
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldehürden zu erhalten.",
"identify_sign_up_barriers_name": "Anmeldehürden identifizieren",
"identify_sign_up_barriers_question_1_button_label": "10 % Rabatt sichern",
"identify_sign_up_barriers_question_1_headline": "Beantworte diese kurze Umfrage und erhalte 10 % Rabatt!",
"identify_sign_up_barriers_question_1_html": "Du überlegst anscheinend, dich anzumelden. Beantworte vier Fragen und erhalte 10 % Rabatt auf jeden Plan.",
"identify_sign_up_barriers_question_2_headline": "Wie wahrscheinlich ist es, dass du dich bei $[workspaceName] anmeldest?",
"identify_customer_goals_question_1_choice_1": "Meine Nutzerbasis tiefgehend verstehen",
"identify_customer_goals_question_1_choice_2": "Upselling-Möglichkeiten erkennen",
"identify_customer_goals_question_1_choice_3": "Das bestmögliche Produkt entwickeln",
"identify_customer_goals_question_1_choice_4": "Die Welt beherrschen, damit alle zum Frühstück Rosenkohl essen",
"identify_customer_goals_question_1_headline": "Was ist dein Hauptziel bei der Nutzung von $[workspaceName]?",
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldebarrieren zu gewinnen.",
"identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren",
"identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt",
"identify_sign_up_barriers_question_1_headline": "Beantworte diese kurze Umfrage, erhalte 10% Rabatt!",
"identify_sign_up_barriers_question_1_html": "Du scheinst darüber nachzudenken, Dich anzumelden. Beantworte vier Fragen und erhalte 10% Rabatt auf jeden Plan.",
"identify_sign_up_barriers_question_2_headline": "Wie wahrscheinlich ist es, dass du dich für $[workspaceName] anmeldest?",
"identify_sign_up_barriers_question_2_lower_label": "Überhaupt nicht wahrscheinlich",
"identify_sign_up_barriers_question_2_upper_label": "Sehr wahrscheinlich",
"identify_sign_up_barriers_question_3_choice_1_label": "Hat vielleicht nicht das, was ich suche",
@@ -1144,14 +1167,16 @@
"improve_trial_conversion_question_1_headline": "Warum hast du deine Testphase beendet?",
"improve_trial_conversion_question_1_subheader": "Hilf uns, dich besser zu verstehen:",
"improve_trial_conversion_question_2_button_label": "Weiter",
"improve_trial_conversion_question_2_headline": "Schade! Was war das größte Problem bei der Nutzung von $[workspaceName]?",
"improve_trial_conversion_question_4_button_label": "20% Rabatt sichern",
"improve_trial_conversion_question_4_headline": "Schade! Hol dir 20% Rabatt auf das erste Jahr.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Wir bieten dir gerne 20% Rabatt auf einen Jahresplan an.</span></p>",
"improve_trial_conversion_question_2_headline": "Schade. Was war das größte Problem bei der Nutzung von $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Weiter",
"improve_trial_conversion_question_3_headline": "Was hast du von $[workspaceName] erwartet?",
"improve_trial_conversion_question_4_button_label": "Erhalte 20% Rabatt",
"improve_trial_conversion_question_4_headline": "Das tut mir leid zu hören! Erhalte 20% Rabatt im ersten Jahr.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Wir freuen uns, dir einen 20% Rabatt auf einen Jahresplan anzubieten.</span></p>",
"improve_trial_conversion_question_5_button_label": "Weiter",
"improve_trial_conversion_question_5_headline": "Was möchtest du erreichen?",
"improve_trial_conversion_question_5_headline": "Was möchtest Du erreichen?",
"improve_trial_conversion_question_5_subheader": "Bitte wähle eine der folgenden Optionen:",
"improve_trial_conversion_question_6_headline": "Wie löst du dein Problem aktuell?",
"improve_trial_conversion_question_6_headline": "Wie löst Du dein Problem heutzutage?",
"improve_trial_conversion_question_6_subheader": "Bitte nenne alternative Lösungen:",
"integration_setup_survey_description": "Bewerte, wie einfach Nutzer Integrationen zu deinem Produkt hinzufügen können. Finde blinde Flecken.",
"integration_setup_survey_name": "Umfrage zur Integration-Nutzung",
@@ -1615,7 +1640,6 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Diese Aktion wird ausgelöst, wenn die Seite geladen wird.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Diese Aktion wird ausgelöst, wenn der Nutzer 50 % der Seite scrollt.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Diese Aktion wird ausgelöst, wenn der Nutzer versucht, die Seite zu verlassen.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Das ist eine Code-Aktion. Bitte nimm die Änderungen in deiner Code-Basis vor.",
"time_in_seconds": "Zeit in Sekunden",
"time_in_seconds_placeholder": "z. B. 10",
"time_in_seconds_with_unit": "{seconds}s",
@@ -1662,7 +1686,7 @@
"chart_type_bar": "Balkendiagramm",
"chart_type_big_number": "Große Zahl",
"chart_type_line": "Liniendiagramm",
"chart_type_not_supported": "Diagrammtyp \"{{chartType}}\" wird noch nicht unterstützt",
"chart_type_not_supported": "Diagrammtyp \"{chartType}\" wird noch nicht unterstützt",
"chart_type_pie": "Kreisdiagramm",
"chart_updated_successfully": "Diagramm erfolgreich aktualisiert!",
"configure_description": "Ändere den Diagrammtyp und andere Einstellungen für diese Visualisierung.",
@@ -1750,7 +1774,7 @@
"no_valid_data_to_display": "Keine gültigen Daten zur Anzeige",
"not_contains": "enthält nicht",
"not_equals": "ist nicht gleich",
"open_chart": "Diagramm {{name}} öffnen",
"open_chart": "Diagramm {name} öffnen",
"open_options": "Diagrammoptionen öffnen",
"or_filter_logic": "ODER",
"original": "Original",
@@ -1761,8 +1785,10 @@
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
"predefined_measures": "Vordefinierte Kennzahlen",
"preset": "Vorlage",
"preview_chart": "Vorschaudiagramm",
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
"save_and_add_to_dashboard": "Speichern und zum Dashboard hinzufügen",
"save_chart": "Diagramm speichern",
"save_chart_dialog_title": "Diagramm speichern",
"select_data_source": "Select a data source",
@@ -1771,11 +1797,12 @@
"select_field": "Feld auswählen",
"select_measures": "Metriken auswählen...",
"select_preset": "Vorlage auswählen",
"showing_first_n_of": "Zeige die ersten {{n}} von {{count}} Zeilen",
"showing_first_n_of": "Zeige die ersten {n} von {count} Zeilen",
"start_date": "Startdatum",
"time_dimension": "Zeitdimension",
"time_dimension_title": "Zeitbasierte Gruppierung hinzufügen",
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf."
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.",
"update_chart": "Diagramm aktualisieren"
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
@@ -1786,6 +1813,7 @@
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
"create_failed": "Dashboard konnte nicht erstellt werden",
"create_new_chart": "Neues Diagramm erstellen",
"create_success": "Dashboard erfolgreich erstellt!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -1800,12 +1828,14 @@
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
"duplicate_success": "Dashboard erfolgreich dupliziert!",
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
"no_charts_available_description": "Es gibt keine Diagramme, die zu diesem Dashboard hinzugefügt werden können. Entweder existieren noch keine Diagramme oder alle vorhandenen Diagramme wurden bereits hinzugefügt. Gehe zur Diagramm-Seite, um neue Diagramme zu erstellen.",
"no_charts_to_add_message": "Keine Diagramme zum Hinzufügen zu diesem Dashboard vorhanden.",
"no_dashboards_found": "Keine Dashboards gefunden.",
"no_data_message": "Keine Daten. Es gibt derzeit keine Informationen zum Anzeigen. Füge Diagramme hinzu, um dein Dashboard zu erstellen.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Sie haben keine Feedback-Datensätze, über die Sie berichten können. Richten Sie Feedbackquellen ein, um Daten in das System einzuspeisen.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Richten Sie Feedbackquellen ein"
},
"api_keys": {
"add_api_key": "API-Key hinzufügen",
@@ -1818,13 +1848,19 @@
"api_key_updated": "API-Schlüssel aktualisiert",
"delete_api_key_confirmation": "Alle Anwendungen, die diesen Schlüssel verwenden, können nicht mehr auf deine Formbricks-Daten zugreifen.",
"duplicate_access": "Doppelter Workspace-Zugriff ist nicht erlaubt",
"duplicate_directory_access": "Doppelter Zugriff auf Feedback-Datensatz-Verzeichnis nicht erlaubt",
"feedback_record_directory_access": "Zugriff auf Feedback-Datensatz-Verzeichnis",
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
"no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden",
"no_directory_permissions_found": "Keine Berechtigungen für Feedback-Datensatz-Verzeichnis gefunden",
"no_workspace_permissions_found": "Keine Workspace-Berechtigungen gefunden",
"organization_access": "Organisations-Zugriff",
"organization_access_description": "Wähle Lese- oder Schreibrechte für organisationsweite Ressourcen aus.",
"permissions": "Berechtigungen",
"secret": "Geheimnis",
"unable_to_copy_api_key": "API-Schlüssel konnte nicht kopiert werden",
"unable_to_delete_api_key": "API-Schlüssel konnte nicht gelöscht werden",
"unknown_directory": "Unbekanntes Verzeichnis",
"unknown_workspace": "Unbekannter Arbeitsbereich",
"workspace_access": "Workspace-Zugriff"
},
"app-connection": {
@@ -1832,8 +1868,6 @@
"app_connection_description": "Verbinde deine App oder Website mit Formbricks.",
"cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 1 Minute dauern, bis diese Änderungen in deiner lokalen App mit dem Formbricks SDK sichtbar werden.",
"cache_update_delay_title": "Änderungen werden nach ~1 Minute durch Caching übernommen",
"environment_id": "Deine Workspace-ID",
"environment_id_description": "Diese ID identifiziert diesen Formbricks-Workspace eindeutig.",
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
"formbricks_sdk_not_connected": "Formbricks SDK ist noch nicht verbunden.",
"formbricks_sdk_not_connected_description": "Füge das Formbricks SDK zu deiner Website oder App hinzu, um es mit Formbricks zu verbinden",
@@ -1845,7 +1879,9 @@
"sdk_connection_details_description": "Deine einzigartige Workspace-ID und SDK-Verbindungs-URL zur Integration von Formbricks in deine Anwendung.",
"setup_alert_description": "Folge dieser Schritt-für-Schritt-Anleitung, um deine App oder Website in unter 5 Minuten zu verbinden.",
"setup_alert_title": "So verbindest du dich",
"webapp_url": "SDK-Verbindungs-URL"
"webapp_url": "SDK-Verbindungs-URL",
"workspace_id": "Deine Workspace-ID",
"workspace_id_description": "Diese ID identifiziert diesen Formbricks-Workspace eindeutig."
},
"connect": {
"congrats": "Glückwunsch!",
@@ -1876,10 +1912,10 @@
"attribute_value_placeholder": "Attributwert",
"attributes_msg_attribute_limit_exceeded": "{count} neue(s) Attribut(e) konnten nicht erstellt werden, da das Maximum von {limit} Attributklassen überschritten würde. Bestehende Attribute wurden erfolgreich aktualisiert.",
"attributes_msg_attribute_type_validation_error": "{error} (Attribut \"{key}\" hat dataType: {dataType})",
"attributes_msg_email_already_exists": "Die E-Mail-Adresse existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"attributes_msg_email_already_exists": "Die E-Mail-Adresse existiert bereits für diesen Workspace und wurde nicht aktualisiert.",
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder Benutzer-ID ist erforderlich. Die bestehenden Werte wurden beibehalten.",
"attributes_msg_new_attribute_created": "Neues Attribut \"{key}\" mit Typ \"{dataType}\" erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diesen Workspace und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
@@ -2140,7 +2176,6 @@
"duplicate_language_or_language_id": "Doppelte Sprache oder Sprach-ID",
"edit_languages": "Sprachen bearbeiten",
"identifier": "Kennung (ISO)",
"incomplete_translations": "Unvollständige Übersetzungen",
"language": "Sprache",
"language_deleted_successfully": "Sprache erfolgreich gelöscht",
"languages_updated_successfully": "Sprachen erfolgreich aktualisiert",
@@ -2150,8 +2185,7 @@
"please_select_a_language": "Bitte wähle eine Sprache aus",
"remove_language": "Sprache entfernen",
"remove_language_from_surveys_to_remove_it_from_workspace": "Bitte entferne die Sprache aus diesen Umfragen, um sie aus dem Workspace zu entfernen.",
"search_items": "Elemente durchsuchen",
"translate": "Übersetzen"
"search_items": "Elemente suchen"
},
"look": {
"add_background_color": "Hintergrundfarbe hinzufügen",
@@ -2365,7 +2399,7 @@
"most_popular": "Am beliebtesten",
"pending_change_removed": "Geplante Planänderung entfernt.",
"pending_plan_badge": "Geplant",
"pending_plan_change_description": "Dein Plan wechselt am {{date}} zu {{plan}}.",
"pending_plan_change_description": "Dein Plan wechselt am {date} zu {plan}.",
"pending_plan_change_title": "Geplante Planänderung",
"pending_plan_cta": "Geplant",
"per_month": "pro Monat",
@@ -2524,21 +2558,22 @@
"nav_label": "Feedback-Verzeichnisse",
"no_access": "Du hast keine Berechtigung, Feedback-Datensatz-Verzeichnisse zu verwalten.",
"no_connectors": "Noch keine Connectoren mit diesem Verzeichnis verknüpft.",
"pause_connectors_confirmation_description": "Wenn du diese Connectoren pausierst, werden keine neuen Datensätze mehr hinzugefügt.",
"pause_connectors_confirmation_title": "Verknüpfte Connectoren pausieren?",
"select_workspaces_placeholder": "Workspaces auswählen...",
"show_archived": "Archivierte anzeigen",
"title": "Feedback-Datensatz-Verzeichnisse",
"unarchive": "Aus Archiv wiederherstellen"
"unarchive": "Aus Archiv wiederherstellen",
"unarchive_workspace_conflict": "Dieses Verzeichnis kann nicht wiederhergestellt werden, weil ein oder mehrere zugewiesene Workspaces archiviert sind.",
"workspace_access": "Workspace-Zugriff"
},
"general": {
"ai_data_analysis_disabled_for_organization": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
"ai_data_analysis_enabled_description": "KI nutzen, um mehr aus deinen Daten herauszuholen richte Dashboards, Diagramme, Berichte und mehr ein. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks KI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_features_not_enabled_for_organization": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte deine:n Administrator:in, AI_PROVIDER, AI_MODEL und die passenden Provider-Zugangsdaten zu setzen, bevor du KI-Funktionen aktivierst.",
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
"ai_smart_tools_disabled_for_organization": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
"ai_smart_tools_enabled": "Smarte Funktionen (KI)",
"ai_smart_tools_enabled_description": "KI, die dir hilft, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
"bulk_invite_warning_description": "Im kostenlosen Tarif erhalten alle Organisationsmitglieder automatisch die Rolle „Inhaber:in“.",
@@ -2596,7 +2631,9 @@
"security_list_tip_link": "Hier anmelden.",
"share_invite_link": "Einladungslink teilen",
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit deine Organisationsmitglieder deiner Organisation beitreten können:",
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet"
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
"unlock_ai_features_description": "KI-gestützte Übersetzungen, smarte Tools und Datenanalyse sind in höheren Plänen verfügbar. Upgrade jetzt und bring deine Umfragen mit KI auf das nächste Level.",
"unlock_ai_features_with_a_higher_plan": "Schalte KI-Funktionen mit einem höheren Plan frei"
},
"notifications": {
"auto_subscribe_to_new_surveys": "Automatisch bei neuen Umfragen anmelden",
@@ -2693,22 +2730,15 @@
},
"surveys": {
"all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen",
"alphabetical": "Alphabetisch",
"copy_survey": "Umfrage kopieren",
"copy_survey_description": "Kopiere diese Umfrage in einen anderen Workspace",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"alphabetical": "alphabetisch",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_no_workspaces": "Es gibt keine anderen Workspaces, in die diese Umfrage kopiert werden kann.",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert",
"delete_survey_and_responses_warning": "Bist du sicher, dass du diese Umfrage und alle zugehörigen Antworten löschen möchtest?",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Aktiviere die Übersetzung für bestimmte Sprachen:",
"add": "Hinzufügen +",
"add_a_delay_or_auto_close_the_survey": "Verzögerung hinzufügen oder Umfrage automatisch schließen",
"add_a_four_digit_pin": "Vierstellige PIN hinzufügen",
"add_a_variable_to_calculate": "Variable zur Berechnung hinzufügen",
"activate_translations": "Übersetzungen aktivieren",
"add": "+ hinzufügen",
"add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.",
"add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu",
"add_a_variable_to_calculate": "Variable hinzufügen",
"add_action_below": "Aktion unten hinzufügen",
"add_block": "Block hinzufügen",
"add_choice_below": "Auswahl unten hinzufügen",
@@ -2740,7 +2770,19 @@
"address_line_2": "Adresszeile 2",
"adjust_survey_closed_message": "„Umfrage geschlossen“-Nachricht anpassen",
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_the_theme_in_the": "Passe das Theme im",
"adjust_the_theme_in_the": "Passe das Thema an in den",
"ai_data_analysis_disabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
"ai_features_not_enabled": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
"ai_translate": "Mit KI übersetzen",
"ai_translating": "Übersetze mit KI... Bitte lasse dieses Fenster geöffnet.",
"ai_translation_all_fields_populated": "Alle Felder sind bereits übersetzt",
"ai_translation_complete": "KI-Übersetzung abgeschlossen",
"ai_translation_failed": "Übersetzung fehlgeschlagen",
"ai_translation_instance_not_configured": "KI ist auf dieser Instanz nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_translation_not_available": "KI-Übersetzung ist in deinem aktuellen Plan nicht verfügbar. Upgraden, um diese Funktion freizuschalten.",
"ai_translation_not_enabled": "KI-Smart-Tools sind für diese Organisation deaktiviert. Aktiviere sie in den Organisationseinstellungen.",
"all_are_true": "alle sind wahr",
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
"allow_multi_select": "Mehrfachauswahl erlauben",
@@ -2754,7 +2796,7 @@
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
"auto_progress_rating_and_nps_description": "Automatisches Fortschreiten bei Ein-Fragen-Blöcken. Pflichtfragen blenden „Weiter“ aus, außer wenn „Sonstiges“ ausgewählt ist.",
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Deine Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht versehentlich aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
@@ -2800,6 +2842,7 @@
"caution_text": "Änderungen führen zu Inkonsistenzen",
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_default": "Standard ändern",
"change_question_type": "Fragetyp ändern",
"change_survey_type": "Wechsel des Umfragetyps wirkt sich auf bestehenden Zugriff aus",
"change_the_background_to_a_color_image_or_animation": "Ändere den Hintergrund in eine Farbe, ein Bild oder eine Animation.",
@@ -2811,7 +2854,11 @@
"choose_the_first_question_on_your_block": "Wähle die erste Frage in Deinem Block",
"choose_where_to_run_the_survey": "Wähle aus, wo die Umfrage ausgeführt werden soll.",
"city": "Stadt",
"close_survey_on_response_limit": "Umfrage bei Erreichen der Antwortgrenze schließen",
"clear_close_on_date": "Pausierungsdatum loeschen",
"clear_publish_on_date": "Veroeffentlichungsdatum loeschen",
"close_survey_on_date": "Pausierungsdatum",
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
"code": "Code",
"color": "Farbe",
"column_used_in_logic_error": "Diese Spalte wird in der Logik von Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"columns": "Spalten",
@@ -2833,9 +2880,10 @@
"css_selector": "CSS-Selektor",
"cta_button_label": "„CTA“-Button-Beschriftung",
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfrage-Logo anpassen",
"darken_or_lighten_background_of_your_choice": "Verdunkle oder erhelle den Hintergrund deiner Wahl.",
"days_before_showing_this_survey_again": "oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dem erneuten Anzeigen dieser Umfrage vergehen müssen.",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"default_language": "Standardsprache",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
"delete_choice": "Auswahl löschen",
@@ -2853,9 +2901,8 @@
"dropdown": "Dropdown",
"duplicate_block": "Block duplizieren",
"duplicate_question": "Frage duplizieren",
"edit_link": "Link bearbeiten",
"edit_recall": "Recall bearbeiten",
"edit_translations": "{lang}-Übersetzungen bearbeiten",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"element_not_found": "Frage nicht gefunden",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Erlaube Teilnehmenden, die Sprache jederzeit zu wechseln. Mindestens 2 aktive Sprachen erforderlich.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Der Spam-Schutz nutzt reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
@@ -2992,18 +3039,20 @@
"long_answer_toggle_description": "Erlaube Befragten, längere, mehrzeilige Antworten zu schreiben.",
"lower_label": "Untere Beschriftung",
"manage_languages": "Sprachen verwalten",
"manage_translations": "Übersetzungen verwalten",
"matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen",
"max_file_size": "Maximale Dateigröße",
"max_file_size_limit_is": "Die maximale Dateigröße beträgt",
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
"missing_first": "Fehlende zuerst",
"move_question_to_block": "Frage in Block verschieben",
"multiply": "Multiplizieren *",
"needed_for_self_hosted_cal_com_instance": "Erforderlich für eine selbst gehostete Cal.com-Instanz",
"next_block": "Nächster Block",
"next_button_label": "Beschriftung für \"Weiter\"-Button",
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder vorhanden. Füge unten das erste hinzu.",
"no_images_found_for": "Keine Bilder gefunden für \"{query}\"",
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
"next_button_label": "Beschriftung der Schaltfläche \"Weiter\"",
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder. Füge das erste unten hinzu.",
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "In diesem Workspace wurden keine Umfragesprachen gefunden. Bitte füge eine hinzu, um zu starten.",
"no_option_found": "Keine Option gefunden",
"no_recall_items_found": "Keine Wiederholungselemente gefunden",
"no_variables_yet_add_first_one_below": "Noch keine Variablen vorhanden. Füge unten die erste hinzu.",
@@ -3030,12 +3079,14 @@
"please_enter_a_valid_url": "Bitte gib eine gültige URL ein (z. B. https://example.com)",
"please_set_a_survey_trigger": "Bitte lege einen Umfrage-Trigger fest",
"please_specify": "Bitte angeben",
"prevent_double_submission": "Doppelte Einreichung verhindern",
"prevent_double_submission_description": "Nur 1 Antwort pro E-Mail-Adresse zulassen",
"present_your_survey_in_multiple_languages": "Präsentiere deine Umfrage in mehreren Sprachen",
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
"progress_saved": "Fortschritt gespeichert",
"protect_survey_with_pin": "Umfrage mit PIN schützen",
"protect_survey_with_pin_description": "Nur Nutzer mit der PIN können auf die Umfrage zugreifen.",
"publish": "Veröffentlichen",
"publish_survey_on_date": "Veroeffentlichungsdatum",
"question": "Frage",
"question_deleted": "Frage gelöscht.",
"question_duplicated": "Frage dupliziert.",
@@ -3106,6 +3157,7 @@
"rows": "Zeilen",
"save_and_close": "Speichern & Schließen",
"scale": "Skala",
"schedule_survey": "Umfrage planen",
"search_for_images": "Nach Bildern suchen",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt",
"seconds_before_showing_the_survey": "Sekunden vor dem Anzeigen der Umfrage.",
@@ -3121,8 +3173,9 @@
"seven_points": "7 Punkte",
"show_block_settings": "Block-Einstellungen anzeigen",
"show_button": "Button anzeigen",
"show_language_switch": "Sprachumschalter anzeigen",
"show_multiple_times": "Eine begrenzte Anzahl von Malen anzeigen",
"show_in_order": "In Reihenfolge anzeigen",
"show_language_switch": "Sprachwechsel anzeigen",
"show_multiple_times": "Begrenzte Anzahl von Malen anzeigen",
"show_only_once": "Nur einmal anzeigen",
"show_question_settings": "Frage-Einstellungen anzeigen",
"show_survey_maximum_of": "Umfrage maximal anzeigen",
@@ -3146,13 +3199,14 @@
"subtract": "Subtrahieren -",
"survey_closed_message_heading_required": "Füge eine Überschrift zur benutzerdefinierten Nachricht für geschlossene Umfragen hinzu.",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose & quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Umfrage-Anzeigeeinstellungen",
"survey_placement": "Umfrage-Platzierung",
"survey_preview": "Umfrage-Vorschau 👀",
"survey_styling": "Umfrage-Styling",
"survey_trigger": "Umfrage-Auslöser",
"switch_multi_language_on_to_get_started": "Schalte Mehrsprachigkeit ein, um loszulegen 👉",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage",
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage",
"survey_will_be_closed_at_midnight_cet": "Die Umfrage wird am ausgewählten Datum um {time} in der Zeitzone {timeZone} geschlossen",
"survey_will_be_published_at_midnight_cet": "Die Umfrage wird am ausgewählten Datum um {time} in der Zeitzone {timeZone} veröffentlicht",
"target_block_not_found": "Zielblock nicht gefunden",
"targeted": "Gezielt",
"ten_points": "10 Punkte",
@@ -3160,11 +3214,13 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Zeige ein einziges Mal, auch wenn sie nicht antworten.",
"then": "Dann",
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
"this_will_remove_the_language_and_all_its_translations": "Dies entfernt diese Sprache und alle zugehörigen Übersetzungen aus dieser Umfrage. Diese Aktion kann nicht rückgängig gemacht werden.",
"three_points": "3 Punkte",
"times": "Mal",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg einheitlich zu halten, kannst du",
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird…",
"try_lollipop_or_mountain": "Probiere „Lutscher“ oder „Berg“…",
"times": "Zeiten",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
"translated": "Übersetzt",
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"type_field_id": "Feld-ID eingeben",
"underline": "Unterstreichen",
"unlock_targeting_description": "Richte dich an bestimmte Nutzergruppen basierend auf Attributen oder Geräteinformationen",
@@ -3236,8 +3292,9 @@
"verify_email_before_submission": "E-Mail vor dem Absenden verifizieren",
"verify_email_before_submission_description": "Lass nur Personen mit einer echten E-Mail-Adresse antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
"visibility_and_recontact_description": "Steuere, wann diese Umfrage erscheinen kann und wie oft sie erneut angezeigt werden kann.",
"wait": "Warten",
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
"visible": "Sichtbar",
"wait": "Warte",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
"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.",
@@ -3438,14 +3495,16 @@
"configure_alerts": "Benachrichtigungen konfigurieren",
"congrats": "Glückwunsch! Deine Umfrage ist live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"current_count": "Aktueller Zählerstand",
"custom_range": "Benutzerdefinierter Zeitraum…",
"delete_all_existing_responses_and_displays": "Alle vorhandenen Antworten und Anzeigen löschen",
"download_qr_code": "QR-Code herunterladen",
"csat_satisfied": "CSAT: {percentage}% Zufrieden",
"csat_satisfied_tooltip": "{percentage}% der Befragten haben eine Bewertung von 4 oder 5 gegeben (CSAT).",
"current_count": "Aktuelle Anzahl",
"custom_range": "Benutzerdefinierter Bereich...",
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
"download_qr_code": "QR Code herunterladen",
"downloading_qr_code": "QR-Code wird heruntergeladen",
"drop_offs": "Abbrüche",
"drop_offs_tooltip": "Anzahl der begonnenen, aber nicht abgeschlossenen Umfragen.",
"failed_to_copy_link": "Link konnte nicht kopiert werden",
"drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
"effort_score": "Aufwandswert",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
@@ -3497,6 +3556,7 @@
"limit": "Limit",
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"nps_promoters_tooltip": "{percentage}% der Befragten haben eine Bewertung von 9 oder 10 gegeben (NPS-Promotoren).",
"other_values_found": "Andere Werte gefunden",
"overall": "Gesamt",
"promoters": "Promotoren",
@@ -3509,7 +3569,6 @@
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
"reset_survey": "Umfrage zurücksetzen",
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
"satisfied": "Zufrieden",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"setup_integrations": "Integrationen einrichten",
@@ -3519,6 +3578,7 @@
"starts_tooltip": "Anzahl der Male, wie oft die Umfrage gestartet wurde.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt. {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
"survey_results": "Ergebnisse von {surveyName}",
"survey_scheduled_successfully": "Umfrage erfolgreich geplant",
"this_month": "Diesen Monat",
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
@@ -3533,7 +3593,6 @@
},
"survey_deleted_successfully": "Umfrage erfolgreich gelöscht",
"survey_duplicated_successfully": "Umfrage erfolgreich dupliziert",
"survey_duplication_error": "Umfrage konnte nicht dupliziert werden.",
"templates": {
"all_channels": "Alle Kanäle",
"all_industries": "Alle Branchen",
@@ -3568,16 +3627,21 @@
"team_settings_description": "Sieh nach, welche Teams auf diesen Workspace zugreifen können."
},
"unify": {
"add_feedback_record": "Feedback-Datensatz hinzufügen",
"add_feedback_record_description": "Erstellen Sie manuell einen Feedback-Datensatz.",
"add_feedback_source": "Feedback-Quelle hinzufügen",
"add_source": "Quelle hinzufügen",
"allowed_values": "Zulässige Werte: {values}",
"api_ingestion": "API-Erfassung",
"api_ingestion_manage_api_keys": "API-Schlüssel verwalten",
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
"auto_generated": "Automatisch generiert",
"change_file": "Datei ändern",
"click_load_sample_csv": "Klick auf 'Beispiel-CSV laden', um Spalten zu sehen",
"click_to_upload": "Zum Hochladen klicken",
"collected_at": "Erfasst am",
"configure_import": "Import konfigurieren",
"configure_mapping": "Mapping konfigurieren",
"connection": "Verbindung",
"connector_created_successfully": "Connector erfolgreich erstellt",
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
"connector_duplicated_successfully": "Connector erfolgreich dupliziert",
@@ -3596,9 +3660,12 @@
"csv_import_duplicate_warning": "Wenn Du die Daten zweimal importierst, entstehen doppelte Einträge.",
"csv_inconsistent_columns": "Zeile {row} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.",
"csv_max_records": "Maximal {max} Einträge erlaubt.",
"custom_source_type": "Benutzerdefinierter Quelltyp",
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
"default_connector_name_csv": "CSV-Import",
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
"deselect_all": "Alle abwählen",
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
"drop_a_field_here": "Ziehe ein Feld hierher",
"drop_field_or": "Feld ablegen oder",
"edit_csv_mapping": "CSV-Zuordnung bearbeiten",
@@ -3608,47 +3675,64 @@
"enum": "Aufzählung",
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
"feedback_date": "Aktuelles Datum",
"feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt",
"feedback_record_details": "Details zum Feedback-Datensatz",
"feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.",
"feedback_record_directory": "Feedback-Datensatz-Verzeichnis",
"feedback_record_fields": "Feedback-Eintragsfelder",
"feedback_record_mcp": "Feedback-Datensatz MCP",
"feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert",
"feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich",
"feedback_records": "Feedback-Einträge",
"feedback_records_refreshed": "Feedback-Einträge aktualisiert",
"feedback_sources": "Feedback-Quellen",
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
"feedback_sources_directory_access_single": "Neue Datensätze aus dieser Quelle werden gespeichert in: {directoryNames}",
"feedback_sources_settings_description": "Verbinde und verwalte alle Feedback-Quellen für diesen Workspace.",
"field_group_id": "Feldgruppen-ID",
"field_group_label": "Feldgruppenbezeichnung",
"field_id": "Feld-ID",
"field_label": "Feldbezeichnung",
"field_type": "Feldtyp",
"formbricks_surveys": "Formbricks-Umfragen",
"frd_cannot_be_changed": "Das Feedback-Verzeichnis kann nach der Erstellung nicht mehr geändert werden.",
"go_to_feedback_record_directories": "Zu den Verzeichnis-Einstellungen",
"historical_import_complete": "Import abgeschlossen: {successes} erfolgreich, {failures} fehlgeschlagen, {skipped} übersprungen (keine Daten)",
"import_csv_data": "Feedback importieren",
"import_feedback": "Feedback importieren",
"import_historical_responses": "Bisherige Antworten importieren",
"import_historical_responses_description": "Importiere jetzt vorhandene Antworten aus dieser Umfrage.",
"import_rows": "{count} Zeilen importieren",
"import_via_source_name": "Import über „{sourceName}“",
"importing_data": "Daten werden importiert...",
"importing_historical_data": "Historische Daten werden importiert...",
"invalid_enum_values": "Ungültige Werte in der Spalte, die {field} zugeordnet ist",
"invalid_values_found": "Gefunden: {values} (Zeilen: {rows}) {extra}",
"load_sample_csv": "Beispiel-CSV laden",
"n_supported_questions": "{count} unterstützte Fragen",
"manage_directories": "Verzeichnisse verwalten",
"manage_feedback_sources": "Feedbackquellen verwalten",
"metadata": "Metadaten",
"metadata_key": "Metadatenschlüssel",
"metadata_read_only_entries": "Schreibgeschützte Metadatenwerte (keine Zeichenfolge)",
"metadata_value": "Metadatenwert",
"missing_feedback_source_title": "Feedback-Quelle fehlt?",
"no_feedback_record_directory_available": "Diesem Workspace ist kein Feedback-Datensatz-Verzeichnis zugewiesen. Erstelle oder weise zuerst eines zu.",
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden",
"optional": "Optional",
"or_drag_and_drop": "oder per Drag & Drop",
"question_selected": "<strong>{count}</strong> Frage ausgewählt. Jede Antwort auf diese Frage wird einen neuen Feedback-Eintrag erstellen.",
"question_type_not_supported": "Dieser Fragetyp wird nicht unterstützt",
"questions_selected": "<strong>{count}</strong> Fragen ausgewählt. Jede Antwort auf diese Fragen wird einen neuen Feedback-Eintrag erstellen.",
"records_will_go_to": "Datensätze gehen an",
"refresh_feedback_records": "Feedback-Einträge aktualisieren",
"refreshing_feedback_records": "Feedback-Einträge werden aktualisiert...",
"request_feedback_source": "Quellen-Integration anfragen",
"required": "Erforderlich",
"save_changes": "Änderungen speichern",
"select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen",
"select_a_value": "Wähle einen Wert aus...",
"select_all": "Alle auswählen",
"select_feedback_record_directory": "Verzeichnis auswählen",
"select_feedback_record_source_type": "Wählen Sie den Quelltyp aus",
"select_questions": "Fragen auswählen",
"select_source_type_description": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest.",
"select_source_type_prompt": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest:",
"select_survey": "Umfrage auswählen",
"select_survey_and_questions": "Umfrage & Fragen auswählen",
"select_survey_questions_description": "Wähle aus, welche Umfragefragen FeedbackRecords erstellen sollen.",
@@ -3658,27 +3742,31 @@
"showing_rows": "3 von {count} Zeilen werden angezeigt",
"source": "Quelle",
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
"source_connect_feedback_record_mcp_description": "Sende Feedback-Datensätze über die MCP-Integration.",
"source_connect_formbricks_description": "Feedback aus Deinen Formbricks-Umfragen verbinden",
"source_fields": "Quellfelder",
"source_id": "Quell-ID",
"source_name": "Quellenname",
"source_type": "Quellentyp",
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
"sources": "Quellen",
"status_active": "In Bearbeitung",
"status_completed": "Abgeschlossen",
"status_draft": "Entwurf",
"status_error": "Fehler",
"status_live_sync": "Live-Synchronisierung",
"status_paused": "Pausiert",
"status_ready": "Bereit",
"submission_id": "Einreichungs-ID",
"survey_has_no_questions": "Diese Umfrage hat keine Fragen",
"survey_import_line": "{surveyName}: {responseCount} Antworten × {questionCount} Fragen = {total} Feedback-Datensätze",
"total_feedback_records": "Gesamt: {checked} von {total} Feedback-Datensätzen ausgewählt über {surveyCount} Umfragen",
"topics_and_subtopics": "Themen & Unterthemen",
"unify_feedback": "Feedback vereinheitlichen",
"update_mapping_description": "Aktualisiere die Zuordnungskonfiguration für diese Quelle.",
"updated_at": "Aktualisiert am",
"upload_csv_data_description": "Lade eine CSV-Datei hoch, um Feedback-Daten zu importieren.",
"upload_csv_file": "CSV-Datei hochladen",
"user_identifier": "Benutzer",
"value": "Wert"
"value": "Wert",
"value_boolean": "Wert (Boolescher Wert)",
"value_date": "Wert (Datum)",
"value_number": "Wert (Anzahl)",
"value_text": "Wert (Text)"
},
"xm-templates": {
"ces": "CES",
+151 -65
View File
@@ -160,6 +160,7 @@
"change_workspace": "Change workspace",
"chart": "Chart",
"charts": "Charts",
"choice_n": "Choice {n}",
"choices": "Choices",
"choose_organization": "Choose organization",
"choose_workspace": "Choose workspace",
@@ -172,6 +173,7 @@
"close": "Close",
"code": "Code",
"collapse_rows": "Collapse rows",
"column_n": "Column {n}",
"completed": "Completed",
"configuration": "Configure",
"confirm": "Confirm",
@@ -231,7 +233,6 @@
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment": "Environment",
"error": "Error",
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
"error_component_title": "Error loading resources",
@@ -243,6 +244,7 @@
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"failed_to_parse_csv": "Failed to parse CSV",
"field_placeholder": "{field} Placeholder",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
@@ -254,11 +256,13 @@
"generate": "Generate",
"go_back": "Go Back",
"go_to_dashboard": "Go to Dashboard",
"headline": "Headline",
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide": "Hide",
"hide_column": "Hide column",
"html": "HTML",
"id": "ID",
"image": "Image",
"images": "Images",
@@ -307,7 +311,6 @@
"more_options": "More options",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
"my_product": "my Product",
"name": "Name",
"new": "New",
@@ -324,10 +327,12 @@
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"no_text_found": "No text found",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
"not_connected": "Not Connected",
"not_set": "Not set",
"note": "Note",
"notifications": "Notifications",
"number": "Number",
@@ -348,7 +353,7 @@
"organization_settings": "Organization settings",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"other_placeholder": "Other Placeholder",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
@@ -366,10 +371,8 @@
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
"profile": "Profile",
"profile_id": "Profile ID",
"progress": "Progress",
@@ -389,18 +392,22 @@
"report_survey": "Report Survey",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"resize": "Resize",
"response": "Response",
"response_id": "Response ID",
"responses": "Responses",
"restart": "Restart",
"retry": "Retry",
"role": "Role",
"row_n": "Row {n}",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
"save_as_draft": "Save as draft",
"save_changes": "Save changes",
"save_without_scheduling": "Save without scheduling",
"saving": "Saving",
"scheduled": "Scheduled",
"search": "Search",
"search_charts": "Search charts...",
"security": "Security",
@@ -427,6 +434,7 @@
"some_files_failed_to_upload": "Some files failed to upload",
"something_went_wrong": "Something went wrong",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"soon": "Soon",
"sort_by": "Sort by",
"start_free_trial": "Start free trial",
"status": "Status",
@@ -434,6 +442,7 @@
"storage_not_configured": "File storage not set up, uploads will likely fail",
"string": "Text",
"styling": "Styling",
"subheader": "Subheader",
"submit": "Submit",
"summary": "Summary",
"survey": "Survey",
@@ -442,6 +451,7 @@
"survey_languages": "Survey Languages",
"survey_live": "Survey live",
"survey_paused": "Survey paused.",
"survey_scheduled": "Survey scheduled.",
"survey_type": "Survey Type",
"surveys": "Surveys",
"table_items_deleted_successfully": "{type}s deleted successfully",
@@ -504,7 +514,6 @@
"workspaces": "Workspaces",
"years": "years",
"yes": "Yes",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {workspaceLimit} workspaces.",
@@ -522,11 +531,11 @@
"email_footer_text_2": "The Formbricks Team",
"email_template_text_1": "This email was sent via Formbricks.",
"embed_survey_preview_email_didnt_request": "Did not request this?",
"embed_survey_preview_email_environment_id": "Environment ID",
"embed_survey_preview_email_fight_spam": "Help us fight spam and forward this mail to hola@formbricks.com",
"embed_survey_preview_email_heading": "Preview Email Embed",
"embed_survey_preview_email_subject": "Formbricks Email Survey Preview",
"embed_survey_preview_email_text": "This is how the code snippet looks embedded into an email:",
"embed_survey_preview_email_workspace_id": "Workspace ID",
"forgot_password_email_change_password": "Change password",
"forgot_password_email_did_not_request": "If you did not request this, please ignore this email.",
"forgot_password_email_heading": "Change password",
@@ -629,6 +638,7 @@
"question_preview": "Question Preview",
"response_already_received": "We already received a response for this email address.",
"response_submitted": "A response linked to this survey and contact already exists",
"scheduled": "This survey is scheduled to go live soon.",
"survey_already_answered_heading": "The survey has already been answered.",
"survey_already_answered_subheading": "You can only use this link once.",
"survey_sent_to": "Survey sent to {email}",
@@ -768,6 +778,10 @@
"career_development_survey_question_6_choice_6": "Other",
"career_development_survey_question_6_headline": "Which of the following best describes your current job level?",
"career_development_survey_question_6_subheader": "Please select one of the following options:",
"ces": "Customer Effort (CES)",
"ces_description": "Measure Customer Effort Score (1-5 or 1-7)",
"ces_lower_label": "Very difficult",
"ces_upper_label": "Very easy",
"cess_survey_name": "CES Survey",
"cess_survey_question_1_headline": "$[workspaceName] makes it easy for me to [ADD GOAL]",
"cess_survey_question_1_lower_label": "Strongly disagree",
@@ -831,7 +845,9 @@
"consent_description": "Ask to agree to terms, conditions, or data usage",
"contact_info": "Contact Info",
"contact_info_description": "Ask for name, surname, email, phone number and company jointly",
"csat_description": "Measure the Customer Satisfaction Score of your product or service.",
"csat": "Customer Satisfaction (CSAT)",
"csat_description": "Measure Customer Satisfaction Score (1-5)",
"csat_lower_label": "Very unsatisfied",
"csat_name": "Customer Satisfaction Score (CSAT)",
"csat_question_10_headline": "Do you have any other comments, questions or concerns?",
"csat_question_10_placeholder": "Type your answer here…",
@@ -907,6 +923,7 @@
"csat_survey_question_2_placeholder": "Type your answer here…",
"csat_survey_question_3_headline": "Ugh, sorry! Is there anything we can do to improve your experience?",
"csat_survey_question_3_placeholder": "Type your answer here…",
"csat_upper_label": "Very satisfied",
"cta_description": "Display information and prompt users to take a specific action",
"custom_survey_description": "Create a survey without template.",
"custom_survey_name": "Start from scratch",
@@ -1072,6 +1089,11 @@
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?",
"identify_customer_goals_description": "Better understand if your messaging creates the right expectations of the value your product provides.",
"identify_customer_goals_name": "Identify Customer Goals",
"identify_customer_goals_question_1_choice_1": "Understand my user base deeply",
"identify_customer_goals_question_1_choice_2": "Identify upselling opportunities",
"identify_customer_goals_question_1_choice_3": "Build the best possible product",
"identify_customer_goals_question_1_choice_4": "Rule the world to make everyone breakfast brussels sprouts",
"identify_customer_goals_question_1_headline": "What is your primary goal for using $[workspaceName]?",
"identify_sign_up_barriers_description": "Offer a discount to gather insights about sign up barriers.",
"identify_sign_up_barriers_name": "Identify Sign Up Barriers",
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
@@ -1146,6 +1168,8 @@
"improve_trial_conversion_question_1_subheader": "Help us understand you better:",
"improve_trial_conversion_question_2_button_label": "Next",
"improve_trial_conversion_question_2_headline": "Sorry to hear. What was the biggest problem using $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Next",
"improve_trial_conversion_question_3_headline": "What did you expect $[workspaceName] to do?",
"improve_trial_conversion_question_4_button_label": "Get 20% off",
"improve_trial_conversion_question_4_headline": "Sorry to hear! Get 20% off the first year.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We are happy to offer you a 20% discount on a yearly plan.</span></p>",
@@ -1616,7 +1640,6 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "This action will be triggered when the page is loaded.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "This action will be triggered when the user scrolls 50% of the page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "This action will be triggered when the user tries to leave the page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "This is a code action. Please make changes in your code base.",
"time_in_seconds": "Time in seconds",
"time_in_seconds_placeholder": "e.g. 10",
"time_in_seconds_with_unit": "{seconds}s",
@@ -1663,7 +1686,7 @@
"chart_type_bar": "Bar Chart",
"chart_type_big_number": "Big Number",
"chart_type_line": "Line Chart",
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
"chart_type_not_supported": "Chart type \"{chartType}\" not yet supported",
"chart_type_pie": "Pie Chart",
"chart_updated_successfully": "Chart updated successfully!",
"configure_description": "Modify the chart type and other settings for this visualization.",
@@ -1751,7 +1774,7 @@
"no_valid_data_to_display": "No valid data to display",
"not_contains": "not contains",
"not_equals": "not equals",
"open_chart": "Open chart {{name}}",
"open_chart": "Open chart {name}",
"open_options": "Open chart options",
"or_filter_logic": "OR",
"original": "Original",
@@ -1762,8 +1785,10 @@
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"preview_chart": "Preview chart",
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_and_add_to_dashboard": "Save & add to dashboard",
"save_chart": "Save Chart",
"save_chart_dialog_title": "Save Chart",
"select_data_source": "Select a data source",
@@ -1772,11 +1797,12 @@
"select_field": "Select field",
"select_measures": "Select measures...",
"select_preset": "Select preset",
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
"showing_first_n_of": "Showing first {n} of {count} rows",
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_title": "Add time-based grouping",
"time_dimension_toggle_description": "Monitor trends over time."
"time_dimension_toggle_description": "Monitor trends over time.",
"update_chart": "Update chart"
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
@@ -1802,12 +1828,14 @@
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"failed_to_load_chart_data": "Failed to load chart data",
"no_charts_available_description": "No more charts available to add. Create a new one.",
"no_charts_to_add_message": "No charts to add to this dashboard.",
"no_dashboards_found": "No dashboards found.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
"please_enter_name": "Please enter a dashboard name"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Setup feedback sources"
},
"api_keys": {
"add_api_key": "Add API Key",
@@ -1820,13 +1848,19 @@
"api_key_updated": "API Key updated",
"delete_api_key_confirmation": "Any applications using this key will no longer be able to access your Formbricks data.",
"duplicate_access": "Duplicate workspace access not allowed",
"duplicate_directory_access": "Duplicate feedback record directory access not allowed",
"feedback_record_directory_access": "Feedback Record Directory Access",
"no_api_keys_yet": "You do not have any API keys yet",
"no_env_permissions_found": "No environment permissions found",
"no_directory_permissions_found": "No feedback record directory permissions found",
"no_workspace_permissions_found": "No Workspace permissions found",
"organization_access": "Organization Access",
"organization_access_description": "Select read or write privileges for organization-wide resources.",
"permissions": "Permissions",
"secret": "Secret",
"unable_to_copy_api_key": "Unable to copy API key",
"unable_to_delete_api_key": "Unable to delete API Key",
"unknown_directory": "Unknown directory",
"unknown_workspace": "Unknown workspace",
"workspace_access": "Workspace Access"
},
"app-connection": {
@@ -1834,8 +1868,6 @@
"app_connection_description": "Connect your app or website to Formbricks.",
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 1 minute for those changes to appear in your local app running the Formbricks SDK.",
"cache_update_delay_title": "Changes will be reflected after ~1 minute due to caching",
"environment_id": "Your Workspace ID",
"environment_id_description": "This id uniquely identifies this Formbricks workspace.",
"formbricks_sdk_connected": "Formbricks SDK is connected",
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
@@ -1844,10 +1876,12 @@
"receiving_data": "Receiving data 💃🕺",
"recheck": "Re-check",
"sdk_connection_details": "SDK Connection Details",
"sdk_connection_details_description": "Your unique workspace ID and SDK connection URL for integrating Formbricks with your application.",
"sdk_connection_details_description": "Your unique Workspace ID and SDK connection URL for integrating Formbricks with your application.",
"setup_alert_description": "Follow this step-by-step tutorial to connect your app or website in under 5 minutes.",
"setup_alert_title": "How to connect",
"webapp_url": "SDK Connection URL"
"webapp_url": "SDK Connection URL",
"workspace_id": "Your Workspace ID",
"workspace_id_description": "This id uniquely identifies this Formbricks Workspace."
},
"connect": {
"congrats": "Congrats!",
@@ -1878,10 +1912,10 @@
"attribute_value_placeholder": "Attribute Value",
"attributes_msg_attribute_limit_exceeded": "Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
"attributes_msg_attribute_type_validation_error": "{error} (attribute “{key}” has dataType: {dataType})",
"attributes_msg_email_already_exists": "The email already exists for this environment and was not updated.",
"attributes_msg_email_already_exists": "The email already exists for this Workspace and was not updated.",
"attributes_msg_email_or_userid_required": "Either email or user ID is required. The existing values were preserved.",
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"attributes_msg_userid_already_exists": "The user ID already exists for this Workspace and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
@@ -2142,7 +2176,6 @@
"duplicate_language_or_language_id": "Duplicate language or language ID",
"edit_languages": "Edit languages",
"identifier": "Identifier (ISO)",
"incomplete_translations": "Incomplete translations",
"language": "Language",
"language_deleted_successfully": "Language deleted successfully",
"languages_updated_successfully": "Languages updated successfully",
@@ -2152,8 +2185,7 @@
"please_select_a_language": "Please select a language",
"remove_language": "Remove Language",
"remove_language_from_surveys_to_remove_it_from_workspace": "Please remove the language from these surveys in order to remove it from the workspace.",
"search_items": "Search items",
"translate": "Translate"
"search_items": "Search items"
},
"look": {
"add_background_color": "Add background color",
@@ -2367,7 +2399,7 @@
"most_popular": "Most popular",
"pending_change_removed": "Scheduled plan change removed.",
"pending_plan_badge": "Scheduled",
"pending_plan_change_description": "Your plan will switch to {{plan}} on {{date}}.",
"pending_plan_change_description": "Your plan will switch to {plan} on {date}.",
"pending_plan_change_title": "Scheduled plan change",
"pending_plan_cta": "Scheduled",
"per_month": "per month",
@@ -2526,21 +2558,22 @@
"nav_label": "Feedback Directories",
"no_access": "You do not have permission to manage feedback record directories.",
"no_connectors": "No connectors linked to this directory yet.",
"pause_connectors_confirmation_description": "Pausing these connectors will stop new records from being added.",
"pause_connectors_confirmation_title": "Pause linked connectors?",
"select_workspaces_placeholder": "Select workspaces...",
"show_archived": "Show archived",
"title": "Feedback Record Directories",
"unarchive": "Unarchive"
"unarchive": "Unarchive",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
"workspace_access": "Workspace access"
},
"general": {
"ai_data_analysis_disabled_for_organization": "AI data analysis is disabled for this organization.",
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Manage AI-powered features for this organization.",
"ai_features_not_enabled_for_organization": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
"ai_settings_updated_successfully": "AI settings updated successfully",
"ai_smart_tools_disabled_for_organization": "AI smart tools are disabled for this organization.",
"ai_smart_tools_enabled": "Smart functionality (AI)",
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
@@ -2598,7 +2631,9 @@
"security_list_tip_link": "Sign up here.",
"share_invite_link": "Share Invite Link",
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
"test_email_sent_successfully": "Test email sent successfully"
"test_email_sent_successfully": "Test email sent successfully",
"unlock_ai_features_description": "AI-powered translations, smart tools, and data analysis are available on higher plans. Upgrade to supercharge your surveys with AI.",
"unlock_ai_features_with_a_higher_plan": "Unlock AI features with a higher plan"
},
"notifications": {
"auto_subscribe_to_new_surveys": "Auto-subscribe to new surveys",
@@ -2696,17 +2731,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "You are all set! Time to create your first survey",
"alphabetical": "Alphabetical",
"copy_survey": "Copy survey",
"copy_survey_description": "Copy this survey to another workspace",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_no_workspaces": "There are no other workspaces to copy this survey to.",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
"activate_translations": "Activate translations",
"add": "Add +",
"add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey",
"add_a_four_digit_pin": "Add a four digit PIN",
@@ -2743,6 +2771,18 @@
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_the_theme_in_the": "Adjust the theme in the",
"ai_data_analysis_disabled": "AI data analysis is disabled for this organization.",
"ai_features_not_enabled": "AI features are not enabled for this organization.",
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
"ai_translate": "Translate with AI",
"ai_translating": "Translating with AI... Please keep this modal open.",
"ai_translation_all_fields_populated": "All fields are already translated",
"ai_translation_complete": "AI translation complete",
"ai_translation_failed": "Translation failed",
"ai_translation_instance_not_configured": "AI is not configured on this instance. Contact your administrator.",
"ai_translation_not_available": "AI translation is not available on your current plan. Upgrade to unlock this feature.",
"ai_translation_not_enabled": "AI smart tools are disabled for this organization. Enable them in organization settings.",
"all_are_true": "all are true",
"all_other_answers_will_continue_to": "All other answers will continue to",
"allow_multi_select": "Allow multi-select",
@@ -2756,7 +2796,7 @@
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
"auto_progress_rating_and_nps_description": "Auto-advance in single-question blocks. Required questions hide Next, except when \"Other\" is selected.",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
@@ -2802,6 +2842,7 @@
"caution_text": "Changes will lead to inconsistencies",
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_default": "Change default",
"change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access",
"change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
@@ -2813,7 +2854,11 @@
"choose_the_first_question_on_your_block": "Choose the first question on your Block",
"choose_where_to_run_the_survey": "Choose where to run the survey.",
"city": "City",
"clear_close_on_date": "Clear close on date",
"clear_publish_on_date": "Clear publish on date",
"close_survey_on_date": "Close survey on date",
"close_survey_on_response_limit": "Close survey on response limit",
"code": "Code",
"color": "Color",
"column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.",
"columns": "Columns",
@@ -2838,6 +2883,7 @@
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"default_language": "Default language",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
@@ -2857,7 +2903,6 @@
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"element_not_found": "Question not found",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
@@ -2993,11 +3038,13 @@
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
"manage_languages": "Manage Languages",
"manage_languages": "Manage languages",
"manage_translations": "Manage translations",
"matrix_all_fields": "All fields",
"matrix_rows": "Rows",
"max_file_size": "Max file size",
"max_file_size_limit_is": "Max file size limit is",
"missing_first": "Missing first",
"move_question_to_block": "Move question to block",
"multiply": "Multiply *",
"needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance",
@@ -3005,7 +3052,7 @@
"next_button_label": "“Next” button label",
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.",
"no_images_found_for": "No images found for “{query}”",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
"no_languages_found_add_first_one_to_get_started": "No survey languages found in this workspace. Please add one to get started.",
"no_option_found": "No option found",
"no_recall_items_found": "No recall items found",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
@@ -3032,12 +3079,14 @@
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"present_your_survey_in_multiple_languages": "Present your survey in multiple languages",
"prevent_double_submission": "Prevent double submission",
"prevent_double_submission_description": "Only allow 1 response per email address",
"progress_saved": "Progress saved",
"protect_survey_with_pin": "Protect survey with a PIN",
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
"publish": "Publish",
"publish_survey_on_date": "Publish survey on date",
"question": "Question",
"question_deleted": "Question deleted.",
"question_duplicated": "Question duplicated.",
@@ -3108,6 +3157,7 @@
"rows": "Rows",
"save_and_close": "Save & Close",
"scale": "Scale",
"schedule_survey": "Schedule survey",
"search_for_images": "Search for images",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconds after trigger the survey will be closed if no response",
"seconds_before_showing_the_survey": "seconds before showing the survey.",
@@ -3123,6 +3173,7 @@
"seven_points": "7 points",
"show_block_settings": "Show Block settings",
"show_button": "Show Button",
"show_in_order": "Show in order",
"show_language_switch": "Show language switch",
"show_multiple_times": "Show a limited number of times",
"show_only_once": "Show only once",
@@ -3154,7 +3205,8 @@
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
"survey_will_be_closed_at_midnight_cet": "Survey will be closed at {time} in the {timeZone} timezone on the selected date",
"survey_will_be_published_at_midnight_cet": "Survey will be published at {time} in the {timeZone} timezone on the selected date",
"target_block_not_found": "Target block not found",
"targeted": "Targeted",
"ten_points": "10 points",
@@ -3162,9 +3214,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they do not respond.",
"then": "Then",
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
"this_will_remove_the_language_and_all_its_translations": "This will remove this language and all its translations from this survey. This action cannot be undone.",
"three_points": "3 points",
"times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
"translated": "Translated",
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired…",
"try_lollipop_or_mountain": "Try “lollipop” or “mountain”…",
"type_field_id": "Type field id",
@@ -3239,6 +3293,7 @@
"verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact",
"visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.",
"visible": "Visible",
"wait": "Wait",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey",
"waiting_time_across_surveys": "Cooldown Period (across surveys)",
@@ -3440,6 +3495,8 @@
"configure_alerts": "Configure alerts",
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"csat_satisfied": "CSAT: {percentage}% Satisfied",
"csat_satisfied_tooltip": "{percentage}% of respondents gave a rating of 4 or 5 (CSAT).",
"current_count": "Current count",
"custom_range": "Custom range…",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
@@ -3447,7 +3504,7 @@
"downloading_qr_code": "Downloading QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
"failed_to_copy_link": "Failed to copy link",
"effort_score": "Effort Score",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
@@ -3499,6 +3556,7 @@
"limit": "Limit",
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
@@ -3511,7 +3569,6 @@
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
"reset_survey": "Reset survey",
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
"satisfied": "Satisfied",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"setup_integrations": "Setup integrations",
@@ -3521,6 +3578,7 @@
"starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully. {responseCount} responses and {displayCount} displays were deleted.",
"survey_results": "{surveyName} Results",
"survey_scheduled_successfully": "Survey scheduled successfully",
"this_month": "This month",
"this_quarter": "This quarter",
"this_year": "This year",
@@ -3535,7 +3593,6 @@
},
"survey_deleted_successfully": "Survey deleted successfully",
"survey_duplicated_successfully": "Survey duplicated successfully",
"survey_duplication_error": "Failed to duplicate the survey.",
"templates": {
"all_channels": "All channels",
"all_industries": "All industries",
@@ -3570,16 +3627,21 @@
"team_settings_description": "See which teams can access this workspace."
},
"unify": {
"add_feedback_record": "Add feedback record",
"add_feedback_record_description": "Create a feedback record manually.",
"add_feedback_source": "Add Feedback Source",
"add_source": "Add source",
"allowed_values": "Allowed values: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_manage_api_keys": "Manage API keys",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"auto_generated": "Auto-generated",
"change_file": "Change file",
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
"click_to_upload": "Click to upload",
"collected_at": "Collected At",
"configure_import": "Configure import",
"configure_mapping": "Configure Mapping",
"connection": "Connection",
"connector_created_successfully": "Connector created successfully",
"connector_deleted_successfully": "Connector deleted successfully",
"connector_duplicated_successfully": "Connector duplicated successfully",
@@ -3598,9 +3660,12 @@
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
"csv_max_records": "Maximum {max} records allowed.",
"custom_source_type": "Custom source type",
"custom_source_type_placeholder": "Enter custom source type",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"deselect_all": "Deselect all",
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
"discard_feedback_record_changes_title": "Discard unsaved changes?",
"drop_a_field_here": "Drop a field here",
"drop_field_or": "Drop field or",
"edit_csv_mapping": "Edit CSV mapping",
@@ -3610,47 +3675,64 @@
"enum": "enum",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_record_created_successfully": "Feedback record created successfully",
"feedback_record_details": "Feedback record details",
"feedback_record_details_description": "Review and update feedback record fields.",
"feedback_record_directory": "Feedback Record Directory",
"feedback_record_fields": "Feedback Record Fields",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "Feedback record updated successfully",
"feedback_record_value_required": "A value is required for the selected field type",
"feedback_records": "Feedback Records",
"feedback_records_refreshed": "Feedback records refreshed",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
"field_group_id": "Field Group ID",
"field_group_label": "Field Group Label",
"field_id": "Field ID",
"field_label": "Field Label",
"field_type": "Field Type",
"formbricks_surveys": "Formbricks Surveys",
"frd_cannot_be_changed": "Feedback directory cannot be changed after creation.",
"go_to_feedback_record_directories": "Go to directories settings",
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
"import_csv_data": "Import feedback",
"import_feedback": "Import feedback",
"import_historical_responses": "Import historical responses",
"import_historical_responses_description": "Import existing responses from this survey now.",
"import_rows": "Import {count} rows",
"import_via_source_name": "Import via \"{sourceName}\"",
"importing_data": "Importing data...",
"importing_historical_data": "Importing historical data...",
"invalid_enum_values": "Invalid values in column mapped to {field}",
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
"load_sample_csv": "Load sample CSV",
"n_supported_questions": "{count} supported questions",
"manage_directories": "Manage directories",
"manage_feedback_sources": "Manage feedback sources",
"metadata": "Metadata",
"metadata_key": "Metadata key",
"metadata_read_only_entries": "Read-only metadata values (non-string)",
"metadata_value": "Metadata value",
"missing_feedback_source_title": "Missing feedback source?",
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"no_surveys_found": "No surveys found in this environment",
"optional": "Optional",
"or_drag_and_drop": "or drag and drop",
"question_selected": "<strong>{count}</strong> question selected. Each response to these questions will create a new Feedback Record.",
"question_type_not_supported": "This question type is not supported",
"questions_selected": "<strong>{count}</strong> questions selected. Each response to these questions will create a new Feedback Record.",
"records_will_go_to": "Records will go to",
"refresh_feedback_records": "Refresh feedback records",
"refreshing_feedback_records": "Refreshing feedback records...",
"request_feedback_source": "Request source integration",
"required": "Required",
"save_changes": "Save changes",
"select_a_survey_to_see_questions": "Select a survey to see its questions",
"select_a_value": "Select a value...",
"select_all": "Select all",
"select_feedback_record_directory": "Select a directory",
"select_feedback_record_source_type": "Select source type",
"select_questions": "Select questions",
"select_source_type_description": "Select the type of feedback source you want to connect.",
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
"select_survey": "Select Survey",
"select_survey_and_questions": "Select Survey & Questions",
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
@@ -3660,27 +3742,31 @@
"showing_rows": "Showing 3 of {count} rows",
"source": "source",
"source_connect_csv_description": "Import feedback from CSV files",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
"source_fields": "Source Fields",
"source_id": "Source ID",
"source_name": "Source Name",
"source_type": "Source Type",
"source_type_cannot_be_changed": "Source type cannot be changed",
"sources": "Sources",
"status_active": "In Progress",
"status_completed": "Completed",
"status_draft": "Draft",
"status_error": "Error",
"status_live_sync": "Live sync",
"status_paused": "Paused",
"status_ready": "Ready",
"submission_id": "Submission ID",
"survey_has_no_questions": "This survey has no questions",
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
"topics_and_subtopics": "Topics & Subtopics",
"unify_feedback": "Unify Feedback",
"update_mapping_description": "Update the mapping configuration for this source.",
"updated_at": "Updated at",
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
"upload_csv_file": "Upload CSV File",
"user_identifier": "User",
"value": "Value"
"value": "Value",
"value_boolean": "Value (Boolean)",
"value_date": "Value (Date)",
"value_number": "Value (Number)",
"value_text": "Value (Text)"
},
"xm-templates": {
"ces": "CES",
+153 -65
View File
@@ -125,6 +125,7 @@
"activity": "Actividad",
"add": "Añadir",
"add_action": "Añadir acción",
"add_chart": "Agregar gráfico",
"add_charts": "Añadir gráficos",
"add_existing_chart_description": "Busca y selecciona gráficos para añadir a este panel.",
"add_filter": "Añadir filtro",
@@ -159,6 +160,7 @@
"change_workspace": "Cambiar espacio de trabajo",
"chart": "Gráfico",
"charts": "Gráficos",
"choice_n": "Opción {n}",
"choices": "Opciones",
"choose_organization": "Elegir organización",
"choose_workspace": "Elegir proyecto",
@@ -171,8 +173,9 @@
"close": "Cerrar",
"code": "Código",
"collapse_rows": "Contraer filas",
"column_n": "Columna {n}",
"completed": "Completado",
"configuration": "Configuración",
"configuration": "Configurar",
"confirm": "Confirmar",
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
@@ -230,7 +233,6 @@
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial",
"environment": "Entorno",
"error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
"error_component_title": "Error al cargar recursos",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"failed_to_parse_csv": "Error al analizar el CSV",
"field_placeholder": "Marcador de posición de {field}",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
@@ -253,11 +256,13 @@
"generate": "Generar",
"go_back": "Volver",
"go_to_dashboard": "Ir al panel de control",
"headline": "Titular",
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar columna",
"html": "HTML",
"id": "ID",
"image": "Imagen",
"images": "Imágenes",
@@ -306,7 +311,6 @@
"more_options": "Más opciones",
"move_down": "Mover hacia abajo",
"move_up": "Mover hacia arriba",
"multiple_languages": "Múltiples idiomas",
"my_product": "mi producto",
"name": "Nombre",
"new": "Nuevo",
@@ -323,10 +327,12 @@
"no_result_found": "No se encontró resultado",
"no_results": "Sin resultados",
"no_surveys_found": "No se encontraron encuestas.",
"no_text_found": "No se encontró texto",
"none_of_the_above": "Ninguna de las anteriores",
"not_authenticated": "No estás autenticado para realizar esta acción.",
"not_authorized": "No autorizado",
"not_connected": "No conectado",
"not_set": "No establecido",
"note": "Nota",
"notifications": "Notificaciones",
"number": "Número",
@@ -347,7 +353,7 @@
"organization_settings": "Ajustes de la organización",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"other_placeholder": "Otro marcador de posición",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
@@ -365,10 +371,8 @@
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
"product_manager": "Gestor de producto",
"production": "Producción",
"profile": "Perfil",
"profile_id": "ID de perfil",
"progress": "Progreso",
@@ -388,18 +392,22 @@
"report_survey": "Reportar encuesta",
"request_trial_license": "Solicitar licencia de prueba",
"reset_to_default": "Restablecer a valores predeterminados",
"resize": "Cambiar tamaño",
"response": "Respuesta",
"response_id": "ID de respuesta",
"responses": "Respuestas",
"restart": "Reiniciar",
"retry": "Reintentar",
"role": "Rol",
"row_n": "Fila {n}",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
"save_as_draft": "Guardar como borrador",
"save_changes": "Guardar cambios",
"save_without_scheduling": "Guardar sin programar",
"saving": "Guardando",
"scheduled": "Programada",
"search": "Buscar",
"search_charts": "Buscar gráficos...",
"security": "Seguridad",
@@ -426,6 +434,7 @@
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
"something_went_wrong": "Algo ha salido mal",
"something_went_wrong_please_try_again": "Algo ha salido mal. Por favor, inténtalo de nuevo.",
"soon": "Próximamente",
"sort_by": "Ordenar por",
"start_free_trial": "Iniciar prueba gratuita",
"status": "Estado",
@@ -433,6 +442,7 @@
"storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas",
"string": "Texto",
"styling": "Estilo",
"subheader": "Subtítulo",
"submit": "Enviar",
"summary": "Resumen",
"survey": "Encuesta",
@@ -441,6 +451,7 @@
"survey_languages": "Idiomas de la encuesta",
"survey_live": "Encuesta activa",
"survey_paused": "Encuesta pausada.",
"survey_scheduled": "Encuesta programada.",
"survey_type": "Tipo de encuesta",
"surveys": "Encuestas",
"table_items_deleted_successfully": "{type}s eliminados correctamente",
@@ -503,7 +514,6 @@
"workspaces": "Proyectos",
"years": "años",
"yes": "Sí",
"you": "Tú",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {workspaceLimit} espacios de trabajo.",
@@ -521,11 +531,11 @@
"email_footer_text_2": "El equipo de Formbricks",
"email_template_text_1": "Este correo electrónico fue enviado a través de Formbricks.",
"embed_survey_preview_email_didnt_request": "¿No lo has solicitado?",
"embed_survey_preview_email_environment_id": "ID de entorno",
"embed_survey_preview_email_fight_spam": "Ayúdanos a combatir el spam y reenvía este correo a hola@formbricks.com",
"embed_survey_preview_email_heading": "Vista previa del correo electrónico incrustado",
"embed_survey_preview_email_subject": "Vista previa de la encuesta por correo electrónico de Formbricks",
"embed_survey_preview_email_text": "Así es como se ve el fragmento de código incrustado en un correo electrónico:",
"embed_survey_preview_email_workspace_id": "ID del espacio de trabajo",
"forgot_password_email_change_password": "Cambiar contraseña",
"forgot_password_email_did_not_request": "Si no has solicitado esto, por favor ignora este correo electrónico.",
"forgot_password_email_heading": "Cambiar contraseña",
@@ -628,6 +638,7 @@
"question_preview": "Vista previa de la pregunta",
"response_already_received": "Ya hemos recibido una respuesta para esta dirección de correo electrónico.",
"response_submitted": "Ya existe una respuesta vinculada a esta encuesta y contacto",
"scheduled": "Esta encuesta está programada para publicarse pronto.",
"survey_already_answered_heading": "La encuesta ya ha sido respondida.",
"survey_already_answered_subheading": "Solo puedes usar este enlace una vez.",
"survey_sent_to": "Encuesta enviada a {email}",
@@ -767,6 +778,10 @@
"career_development_survey_question_6_choice_6": "Otro",
"career_development_survey_question_6_headline": "¿Cuál de las siguientes opciones describe mejor tu nivel de trabajo actual?",
"career_development_survey_question_6_subheader": "Por favor, selecciona una de las siguientes opciones",
"ces": "Esfuerzo del Cliente (CES)",
"ces_description": "Mide la Puntuación de Esfuerzo del Cliente (1-5 o 1-7)",
"ces_lower_label": "Muy difícil",
"ces_upper_label": "Muy fácil",
"cess_survey_name": "Encuesta CES",
"cess_survey_question_1_headline": "$[workspaceName] me facilita [AÑADIR OBJETIVO]",
"cess_survey_question_1_lower_label": "Totalmente en desacuerdo",
@@ -830,7 +845,9 @@
"consent_description": "Solicitar aceptación de términos, condiciones o uso de datos",
"contact_info": "Información de contacto",
"contact_info_description": "Solicitar nombre, apellidos, correo electrónico, número de teléfono y empresa conjuntamente",
"csat_description": "Mide el índice de satisfacción del cliente de tu producto o servicio.",
"csat": "Satisfacción del Cliente (CSAT)",
"csat_description": "Mide la Puntuación de Satisfacción del Cliente (1-5)",
"csat_lower_label": "Muy insatisfecho",
"csat_name": "Índice de satisfacción del cliente (CSAT)",
"csat_question_10_headline": "¿Tienes algún otro comentario, pregunta o inquietud?",
"csat_question_10_placeholder": "Escribe tu respuesta aquí...",
@@ -906,6 +923,7 @@
"csat_survey_question_2_placeholder": "Escribe tu respuesta aquí...",
"csat_survey_question_3_headline": "Vaya, ¡lo sentimos! ¿Hay algo que podamos hacer para mejorar tu experiencia?",
"csat_survey_question_3_placeholder": "Escribe tu respuesta aquí...",
"csat_upper_label": "Muy satisfecho",
"cta_description": "Muestra información y anima a los usuarios a realizar una acción específica",
"custom_survey_description": "Crea una encuesta sin plantilla.",
"custom_survey_name": "Empezar desde cero",
@@ -1071,6 +1089,11 @@
"gauge_feature_satisfaction_question_2_headline": "¿Qué es una cosa que podríamos mejorar?",
"identify_customer_goals_description": "Comprende mejor si tus mensajes crean las expectativas correctas sobre el valor que proporciona tu producto.",
"identify_customer_goals_name": "Identificar objetivos del cliente",
"identify_customer_goals_question_1_choice_1": "Conocer a fondo mi base de usuarios",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de venta adicional",
"identify_customer_goals_question_1_choice_3": "Construir el mejor producto posible",
"identify_customer_goals_question_1_choice_4": "Dominar el mundo para hacer que todos desayunen coles de Bruselas",
"identify_customer_goals_question_1_headline": "¿Cuál es tu objetivo principal al usar $[workspaceName]?",
"identify_sign_up_barriers_description": "Ofrece un descuento para obtener información sobre las barreras de registro.",
"identify_sign_up_barriers_name": "Identificar barreras de registro",
"identify_sign_up_barriers_question_1_button_label": "Obtener 10 % de descuento",
@@ -1145,6 +1168,8 @@
"improve_trial_conversion_question_1_subheader": "Ayúdanos a entenderte mejor:",
"improve_trial_conversion_question_2_button_label": "Siguiente",
"improve_trial_conversion_question_2_headline": "Lamentamos oírlo. ¿Cuál fue el mayor problema al usar $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Siguiente",
"improve_trial_conversion_question_3_headline": "¿Qué esperabas que hiciera $[workspaceName]?",
"improve_trial_conversion_question_4_button_label": "Obtener 20 % de descuento",
"improve_trial_conversion_question_4_headline": "¡Sentimos oírlo! Obtén un 20 % de descuento en el primer año.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nos complace ofrecerte un 20 % de descuento en un plan anual.</span></p>",
@@ -1615,7 +1640,6 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Esta acción se activará cuando se cargue la página.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Esta acción se activará cuando el usuario desplace el 50 % de la página.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Esta acción se activará cuando el usuario intente abandonar la página.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Esta es una acción de código. Por favor, realiza cambios en tu base de código.",
"time_in_seconds": "Tiempo en segundos",
"time_in_seconds_placeholder": "p. ej. 10",
"time_in_seconds_with_unit": "{seconds}s",
@@ -1662,7 +1686,7 @@
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de líneas",
"chart_type_not_supported": "El tipo de gráfico \"{{chartType}}\" aún no está soportado",
"chart_type_not_supported": "El tipo de gráfico \"{chartType}\" aún no está soportado",
"chart_type_pie": "Gráfico circular",
"chart_updated_successfully": "¡Gráfico actualizado correctamente!",
"configure_description": "Modifica el tipo de gráfico y otros ajustes para esta visualización.",
@@ -1750,7 +1774,7 @@
"no_valid_data_to_display": "No hay datos válidos para mostrar",
"not_contains": "no contiene",
"not_equals": "no es igual a",
"open_chart": "Abrir gráfico {{name}}",
"open_chart": "Abrir gráfico {name}",
"open_options": "Abrir opciones del gráfico",
"or_filter_logic": "O",
"original": "Original",
@@ -1761,8 +1785,10 @@
"please_select_dashboard": "Selecciona un panel de control",
"predefined_measures": "Medidas predefinidas",
"preset": "Preajuste",
"preview_chart": "Vista previa del gráfico",
"query_executed_successfully": "Consulta ejecutada correctamente",
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
"save_and_add_to_dashboard": "Guardar y agregar al panel",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_data_source": "Select a data source",
@@ -1771,11 +1797,12 @@
"select_field": "Seleccionar campo",
"select_measures": "Seleccionar medidas...",
"select_preset": "Seleccionar preajuste",
"showing_first_n_of": "Mostrando las primeras {{n}} de {{count}} filas",
"showing_first_n_of": "Mostrando las primeras {n} de {count} filas",
"start_date": "Fecha de inicio",
"time_dimension": "Dimensión temporal",
"time_dimension_title": "Añadir agrupación temporal",
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo."
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.",
"update_chart": "Cuadro de actualización"
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
@@ -1786,6 +1813,7 @@
"create_dashboard": "Crear panel",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
"create_failed": "Error al crear el panel de control",
"create_new_chart": "Crear nuevo gráfico",
"create_success": "Panel de control creado correctamente",
"dashboard": "Panel",
"dashboard_delete_confirmation": "¿Estás seguro de que quieres eliminar este panel? Esta acción no se puede deshacer.",
@@ -1800,12 +1828,14 @@
"duplicate_failed": "Error al duplicar el panel de control",
"duplicate_success": "Panel de control duplicado correctamente",
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
"no_charts_available_description": "No hay gráficos que se puedan añadir a este panel. O bien no existen gráficos todavía, o todos los gráficos existentes ya se han añadido. Ve a la página de Gráficos para crear nuevos gráficos.",
"no_charts_to_add_message": "No hay gráficos para añadir a este panel.",
"no_dashboards_found": "No se han encontrado paneles de control.",
"no_data_message": "Sin datos. Actualmente no hay información que mostrar. Añade gráficos para crear tu panel.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "No tienes registros de comentarios sobre los que informar. Configure fuentes de comentarios para introducir datos en el sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fuentes de comentarios"
},
"api_keys": {
"add_api_key": "Añadir clave API",
@@ -1818,13 +1848,19 @@
"api_key_updated": "Clave API actualizada",
"delete_api_key_confirmation": "Cualquier aplicación que use esta clave ya no podrá acceder a tus datos de Formbricks.",
"duplicate_access": "No se permite el acceso duplicado al espacio de trabajo",
"duplicate_directory_access": "No se permite el acceso duplicado al directorio de registros de feedback",
"feedback_record_directory_access": "Acceso al Directorio de Registros de Feedback",
"no_api_keys_yet": "Aún no tienes ninguna clave API",
"no_env_permissions_found": "No se encontraron permisos de entorno",
"no_directory_permissions_found": "No se encontraron permisos de directorio de registros de feedback",
"no_workspace_permissions_found": "No se encontraron permisos del espacio de trabajo",
"organization_access": "Acceso a la organización",
"organization_access_description": "Selecciona privilegios de lectura o escritura para recursos de toda la organización.",
"permissions": "Permisos",
"secret": "Secreto",
"unable_to_copy_api_key": "No se puede copiar la clave de API",
"unable_to_delete_api_key": "No se puede eliminar la clave API",
"unknown_directory": "Directorio desconocido",
"unknown_workspace": "Espacio de trabajo desconocido",
"workspace_access": "Acceso al espacio de trabajo"
},
"app-connection": {
@@ -1832,8 +1868,6 @@
"app_connection_description": "Conecta tu aplicación o sitio web a Formbricks.",
"cache_update_delay_description": "Cuando realizas actualizaciones en encuestas, contactos, acciones u otros datos, puede tardar hasta 1 minuto en que esos cambios aparezcan en tu aplicación local que ejecuta el SDK de Formbricks.",
"cache_update_delay_title": "Los cambios se reflejarán después de ~1 minuto debido al almacenamiento en caché",
"environment_id": "ID de tu espacio de trabajo",
"environment_id_description": "Este ID identifica de forma única este espacio de trabajo de Formbricks.",
"formbricks_sdk_connected": "El SDK de Formbricks está conectado",
"formbricks_sdk_not_connected": "El SDK de Formbricks aún no está conectado.",
"formbricks_sdk_not_connected_description": "Añade el SDK de Formbricks a tu sitio web o aplicación para conectarlo con Formbricks",
@@ -1842,10 +1876,12 @@
"receiving_data": "Recibiendo datos 💃🕺",
"recheck": "Volver a comprobar",
"sdk_connection_details": "Detalles de conexión del SDK",
"sdk_connection_details_description": "Tu ID de espacio de trabajo único y la URL de conexión del SDK para integrar Formbricks con tu aplicación.",
"sdk_connection_details_description": "Tu ID único de espacio de trabajo y URL de conexión del SDK para integrar Formbricks con tu aplicación.",
"setup_alert_description": "Sigue este tutorial paso a paso para conectar tu aplicación o sitio web en menos de 5 minutos.",
"setup_alert_title": "Cómo conectar",
"webapp_url": "URL de conexión del SDK"
"webapp_url": "URL de conexión del SDK",
"workspace_id": "ID de tu espacio de trabajo",
"workspace_id_description": "Este ID identifica de forma única este espacio de trabajo de Formbricks."
},
"connect": {
"congrats": "¡Enhorabuena!",
@@ -1876,10 +1912,10 @@
"attribute_value_placeholder": "Valor del atributo",
"attributes_msg_attribute_limit_exceeded": "No se pudieron crear {count} atributo(s) nuevo(s) ya que se excedería el límite máximo de {limit} clases de atributos. Los atributos existentes se actualizaron correctamente.",
"attributes_msg_attribute_type_validation_error": "{error} (el atributo “{key}” tiene dataType: {dataType})",
"attributes_msg_email_already_exists": "El email ya existe para este entorno y no se actualizó.",
"attributes_msg_email_already_exists": "El correo electrónico ya existe para este espacio de trabajo y no se actualizó.",
"attributes_msg_email_or_userid_required": "Se requiere el correo electrónico o el ID de usuario. Se conservaron los valores existentes.",
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este espacio de trabajo y no se actualizó.",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
@@ -2140,7 +2176,6 @@
"duplicate_language_or_language_id": "Idioma o ID de idioma duplicado",
"edit_languages": "Editar idiomas",
"identifier": "Identificador (ISO)",
"incomplete_translations": "Traducciones incompletas",
"language": "Idioma",
"language_deleted_successfully": "Idioma eliminado correctamente",
"languages_updated_successfully": "Idiomas actualizados correctamente",
@@ -2150,8 +2185,7 @@
"please_select_a_language": "Por favor, selecciona un idioma",
"remove_language": "Eliminar idioma",
"remove_language_from_surveys_to_remove_it_from_workspace": "Por favor, elimina el idioma de estas encuestas para poder eliminarlo del espacio de trabajo.",
"search_items": "Buscar elementos",
"translate": "Traducir"
"search_items": "Buscar elementos"
},
"look": {
"add_background_color": "Añadir color de fondo",
@@ -2365,7 +2399,7 @@
"most_popular": "Más popular",
"pending_change_removed": "Cambio de plan programado eliminado.",
"pending_plan_badge": "Programado",
"pending_plan_change_description": "Tu plan cambiará a {{plan}} el {{date}}.",
"pending_plan_change_description": "Tu plan cambiará a {plan} el {date}.",
"pending_plan_change_title": "Cambio de plan programado",
"pending_plan_cta": "Programado",
"per_month": "por mes",
@@ -2524,21 +2558,22 @@
"nav_label": "Directorios de Feedback",
"no_access": "No tienes permiso para gestionar los directorios de registros de feedback.",
"no_connectors": "Aún no hay conectores vinculados a este directorio.",
"pause_connectors_confirmation_description": "Si pausas estos conectores, no se añadirán nuevos registros.",
"pause_connectors_confirmation_title": "¿Pausar conectores vinculados?",
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
"show_archived": "Mostrar archivados",
"title": "Directorios de Registros de Feedback",
"unarchive": "Desarchivar"
"unarchive": "Desarchivar",
"unarchive_workspace_conflict": "No se puede desarchivar este directorio porque uno o más espacios de trabajo asignados están archivados.",
"workspace_access": "Acceso al espacio de trabajo"
},
"general": {
"ai_data_analysis_disabled_for_organization": "El análisis y enriquecimiento de datos con IA está deshabilitado para esta organización.",
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_features_not_enabled_for_organization": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
"ai_smart_tools_disabled_for_organization": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
@@ -2596,7 +2631,9 @@
"security_list_tip_link": "Regístrate aquí.",
"share_invite_link": "Compartir enlace de invitación",
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente"
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
"unlock_ai_features_description": "Las traducciones impulsadas por IA, herramientas inteligentes y análisis de datos están disponibles en planes superiores. Mejora tu plan para potenciar tus encuestas con IA.",
"unlock_ai_features_with_a_higher_plan": "Desbloquea funciones de IA con un plan superior"
},
"notifications": {
"auto_subscribe_to_new_surveys": "Suscripción automática a nuevas encuestas",
@@ -2694,17 +2731,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "¡Todo listo! Es hora de crear tu primera encuesta",
"alphabetical": "Alfabético",
"copy_survey": "Copiar encuesta",
"copy_survey_description": "Copia esta encuesta a otro espacio de trabajo",
"copy_survey_error": "Error al copiar la encuesta",
"copy_survey_link_to_clipboard": "Copiar enlace de la encuesta al portapapeles",
"copy_survey_no_workspaces": "No hay otros espacios de trabajo a los que copiar esta encuesta.",
"copy_survey_partially_success": "{success} encuestas copiadas correctamente, {error} fallidas.",
"copy_survey_success": "¡Encuesta copiada correctamente!",
"delete_survey_and_responses_warning": "¿Estás seguro de que quieres eliminar esta encuesta y todas sus respuestas?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Elige el idioma predeterminado para esta encuesta:",
"2_activate_translation_for_specific_languages": "2. Activa la traducción para idiomas específicos:",
"activate_translations": "Activar traducciones",
"add": "Añadir +",
"add_a_delay_or_auto_close_the_survey": "Añadir un retraso o cerrar automáticamente la encuesta",
"add_a_four_digit_pin": "Añadir un PIN de cuatro dígitos",
@@ -2741,6 +2771,18 @@
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_the_theme_in_the": "Ajustar el tema en el",
"ai_data_analysis_disabled": "El análisis de datos con IA está deshabilitado para esta organización.",
"ai_features_not_enabled": "Las funciones de IA no están habilitadas para esta organización.",
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
"ai_translate": "Traducir con IA",
"ai_translating": "Traduciendo con IA... Por favor, mantén este modal abierto.",
"ai_translation_all_fields_populated": "Todos los campos ya están traducidos",
"ai_translation_complete": "Traducción con IA completada",
"ai_translation_failed": "La traducción ha fallado",
"ai_translation_instance_not_configured": "La IA no está configurada en esta instancia. Contacta con tu administrador.",
"ai_translation_not_available": "La traducción por IA no está disponible en tu plan actual. Mejora tu plan para desbloquear esta función.",
"ai_translation_not_enabled": "Las herramientas inteligentes de IA están desactivadas para esta organización. Actívalas en la configuración de la organización.",
"all_are_true": "todas son verdaderas",
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
"allow_multi_select": "Permitir selección múltiple",
@@ -2754,7 +2796,7 @@
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
"auto_progress_rating_and_nps_description": "Avance automático en bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente, excepto cuando se selecciona \"Otro\".",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
@@ -2800,6 +2842,7 @@
"caution_text": "Los cambios provocarán inconsistencias",
"change_anyway": "Cambiar de todos modos",
"change_background": "Cambiar fondo",
"change_default": "Cambiar predeterminado",
"change_question_type": "Cambiar tipo de pregunta",
"change_survey_type": "Cambiar el tipo de encuesta afecta al acceso existente",
"change_the_background_to_a_color_image_or_animation": "Cambiar el fondo a un color, imagen o animación.",
@@ -2811,7 +2854,11 @@
"choose_the_first_question_on_your_block": "Elige la primera pregunta en tu bloque",
"choose_where_to_run_the_survey": "Elige dónde ejecutar la encuesta.",
"city": "Ciudad",
"clear_close_on_date": "Borrar fecha de pausa",
"clear_publish_on_date": "Borrar fecha de publicación",
"close_survey_on_date": "Fecha de pausa",
"close_survey_on_response_limit": "Cerrar encuesta al alcanzar el límite de respuestas",
"code": "Código",
"color": "Color",
"column_used_in_logic_error": "Esta columna se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"columns": "Columnas",
@@ -2836,6 +2883,7 @@
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"default_language": "Idioma predeterminado",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
"delete_choice": "Eliminar opción",
@@ -2855,7 +2903,6 @@
"duplicate_question": "Duplicar pregunta",
"edit_link": "Editar enlace",
"edit_recall": "Editar recuperación",
"edit_translations": "Editar traducciones de {lang}",
"element_not_found": "Pregunta no encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir a los participantes cambiar el idioma de la encuesta en cualquier momento durante la encuesta.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protección contra spam utiliza reCAPTCHA v3 para filtrar las respuestas spam.",
@@ -2992,10 +3039,12 @@
"long_answer_toggle_description": "Permitir a los encuestados escribir respuestas más largas y de varias líneas.",
"lower_label": "Etiqueta inferior",
"manage_languages": "Gestionar idiomas",
"manage_translations": "Gestionar traducciones",
"matrix_all_fields": "Todos los campos",
"matrix_rows": "Filas",
"max_file_size": "Tamaño máximo de archivo",
"max_file_size_limit_is": "El límite de tamaño máximo de archivo es",
"missing_first": "Faltantes primero",
"move_question_to_block": "Mover pregunta al bloque",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necesario para una instancia Cal.com autohospedada",
@@ -3003,7 +3052,7 @@
"next_button_label": "Etiqueta del botón \"Siguiente\"",
"no_hidden_fields_yet_add_first_one_below": "Aún no hay campos ocultos. Añade el primero a continuación.",
"no_images_found_for": "No se encontraron imágenes para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas. Añade el primero para comenzar.",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas de encuesta en este espacio de trabajo. Por favor, añade uno para comenzar.",
"no_option_found": "No se encontró ninguna opción",
"no_recall_items_found": "No se encontraron elementos de recuperación",
"no_variables_yet_add_first_one_below": "No hay variables todavía. Añade la primera a continuación.",
@@ -3030,12 +3079,14 @@
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
"please_specify": "Por favor, especifica",
"present_your_survey_in_multiple_languages": "Presenta tu encuesta en varios idiomas",
"prevent_double_submission": "Evitar envío duplicado",
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
"progress_saved": "Progreso guardado",
"protect_survey_with_pin": "Proteger encuesta con un PIN",
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
"publish": "Publicar",
"publish_survey_on_date": "Fecha de publicación",
"question": "Pregunta",
"question_deleted": "Pregunta eliminada.",
"question_duplicated": "Pregunta duplicada.",
@@ -3106,6 +3157,7 @@
"rows": "Filas",
"save_and_close": "Guardar y cerrar",
"scale": "Escala",
"schedule_survey": "Programar encuesta",
"search_for_images": "Buscar imágenes",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos después de activarse, la encuesta se cerrará si no hay respuesta",
"seconds_before_showing_the_survey": "segundos antes de mostrar la encuesta.",
@@ -3121,6 +3173,7 @@
"seven_points": "7 puntos",
"show_block_settings": "Mostrar ajustes del bloque",
"show_button": "Mostrar botón",
"show_in_order": "Mostrar en orden",
"show_language_switch": "Mostrar cambio de idioma",
"show_multiple_times": "Mostrar un número limitado de veces",
"show_only_once": "Mostrar solo una vez",
@@ -3152,7 +3205,8 @@
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
"survey_will_be_closed_at_midnight_cet": "La encuesta se cerrará a las {time} en la zona horaria {timeZone} en la fecha seleccionada",
"survey_will_be_published_at_midnight_cet": "La encuesta se publicará a las {time} en la zona horaria {timeZone} en la fecha seleccionada",
"target_block_not_found": "Bloque objetivo no encontrado",
"targeted": "Dirigido",
"ten_points": "10 puntos",
@@ -3160,9 +3214,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
"then": "Entonces",
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
"this_will_remove_the_language_and_all_its_translations": "Esto eliminará este idioma y todas sus traducciones de esta encuesta. Esta acción no se puede deshacer.",
"three_points": "3 puntos",
"times": "veces",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
"translated": "Traducido",
"trigger_survey_when_one_of_the_actions_is_fired": "Activar encuesta cuando se dispare una de las acciones...",
"try_lollipop_or_mountain": "Prueba 'piruleta' o 'montaña'...",
"type_field_id": "Escribe el id del campo",
@@ -3237,6 +3293,7 @@
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
"visibility_and_recontact": "Visibilidad y recontacto",
"visibility_and_recontact_description": "Controla cuándo puede aparecer esta encuesta y con qué frecuencia puede volver a aparecer.",
"visible": "Visible",
"wait": "Esperar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta",
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
@@ -3438,6 +3495,8 @@
"configure_alerts": "Configurar alertas",
"congrats": "¡Enhorabuena! Tu encuesta está activa.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecta tu sitio web o aplicación con Formbricks para comenzar.",
"csat_satisfied": "CSAT: {percentage}% Satisfechos",
"csat_satisfied_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 4 o 5 (CSAT).",
"current_count": "Recuento actual",
"custom_range": "Rango personalizado...",
"delete_all_existing_responses_and_displays": "Eliminar todas las respuestas y visualizaciones existentes",
@@ -3445,7 +3504,7 @@
"downloading_qr_code": "Descargando código QR",
"drop_offs": "Abandonos",
"drop_offs_tooltip": "Número de veces que se ha iniciado la encuesta pero no se ha completado.",
"failed_to_copy_link": "Error al copiar el enlace",
"effort_score": "Puntuación de Esfuerzo",
"filter_added_successfully": "Filtro añadido correctamente",
"filter_updated_successfully": "Filtro actualizado correctamente",
"filtered_responses_csv": "Respuestas filtradas (CSV)",
@@ -3497,6 +3556,7 @@
"limit": "Límite",
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
@@ -3509,7 +3569,6 @@
"quotas_completed_tooltip": "El número de cuotas completadas por los encuestados.",
"reset_survey": "Reiniciar encuesta",
"reset_survey_warning": "Reiniciar una encuesta elimina todas las respuestas y visualizaciones asociadas a esta encuesta. Esto no se puede deshacer.",
"satisfied": "Satisfecho",
"selected_responses_csv": "Respuestas seleccionadas (CSV)",
"selected_responses_excel": "Respuestas seleccionadas (Excel)",
"setup_integrations": "Configurar integraciones",
@@ -3519,6 +3578,7 @@
"starts_tooltip": "Número de veces que se ha iniciado la encuesta.",
"survey_reset_successfully": "¡Encuesta restablecida correctamente! Se eliminaron {responseCount} respuestas y {displayCount} visualizaciones.",
"survey_results": "Resultados de {surveyName}",
"survey_scheduled_successfully": "Encuesta programada correctamente",
"this_month": "Este mes",
"this_quarter": "Este trimestre",
"this_year": "Este año",
@@ -3533,7 +3593,6 @@
},
"survey_deleted_successfully": "¡Encuesta eliminada correctamente!",
"survey_duplicated_successfully": "Encuesta duplicada correctamente.",
"survey_duplication_error": "Error al duplicar la encuesta.",
"templates": {
"all_channels": "Todos los canales",
"all_industries": "Todas las industrias",
@@ -3568,16 +3627,21 @@
"team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo."
},
"unify": {
"add_feedback_record": "Agregar registro de comentarios",
"add_feedback_record_description": "Cree un registro de comentarios manualmente.",
"add_feedback_source": "Añadir fuente de feedback",
"add_source": "Añadir fuente",
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingesta de API",
"api_ingestion_manage_api_keys": "Gestionar claves de API",
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
"auto_generated": "Generado automáticamente",
"change_file": "Cambiar archivo",
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
"click_to_upload": "Haz clic para subir",
"collected_at": "Recopilado el",
"configure_import": "Configurar importación",
"configure_mapping": "Configurar asignación",
"connection": "Conexión",
"connector_created_successfully": "Conector creado correctamente",
"connector_deleted_successfully": "Conector eliminado correctamente",
"connector_duplicated_successfully": "Conector duplicado correctamente",
@@ -3596,9 +3660,12 @@
"csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.",
"csv_inconsistent_columns": "La fila {row} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.",
"csv_max_records": "Máximo de {max} registros permitidos.",
"custom_source_type": "Tipo de fuente personalizado",
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
"default_connector_name_csv": "Importación CSV",
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
"deselect_all": "Deseleccionar todo",
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
"drop_a_field_here": "Suelta un campo aquí",
"drop_field_or": "Suelta el campo o",
"edit_csv_mapping": "Editar mapeo de CSV",
@@ -3608,47 +3675,64 @@
"enum": "enum",
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
"feedback_date": "Fecha actual",
"feedback_record_created_successfully": "Registro de comentarios creado correctamente",
"feedback_record_details": "Detalles del registro de comentarios",
"feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.",
"feedback_record_directory": "Directorio de Registros de Comentarios",
"feedback_record_fields": "Campos de registro de comentarios",
"feedback_record_mcp": "MCP de registros de feedback",
"feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente",
"feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado",
"feedback_records": "Registros de comentarios",
"feedback_records_refreshed": "Registros de comentarios actualizados",
"feedback_sources": "Fuentes de feedback",
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
"feedback_sources_directory_access_single": "Los nuevos registros de esta fuente se almacenarán en: {directoryNames}",
"feedback_sources_settings_description": "Conecta y gestiona todas las fuentes de feedback para este espacio de trabajo.",
"field_group_id": "ID de grupo de campos",
"field_group_label": "Etiqueta de grupo de campos",
"field_id": "ID de campo",
"field_label": "Etiqueta de campo",
"field_type": "Tipo de campo",
"formbricks_surveys": "Formbricks Surveys",
"frd_cannot_be_changed": "El directorio de comentarios no se puede cambiar después de su creación.",
"go_to_feedback_record_directories": "Ir a la configuración de directorios",
"historical_import_complete": "Importación completada: {successes} correctas, {failures} fallidas, {skipped} omitidas (sin datos)",
"import_csv_data": "Importar comentarios",
"import_feedback": "Importar comentarios",
"import_historical_responses": "Importar respuestas históricas",
"import_historical_responses_description": "Importa las respuestas existentes de esta encuesta ahora.",
"import_rows": "Importar {count} filas",
"import_via_source_name": "Importar mediante \"{sourceName}\"",
"importing_data": "Importando datos...",
"importing_historical_data": "Importando datos históricos...",
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
"invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}",
"load_sample_csv": "Cargar CSV de muestra",
"n_supported_questions": "{count} preguntas compatibles",
"manage_directories": "Gestionar directorios",
"manage_feedback_sources": "Administrar fuentes de comentarios",
"metadata": "Metadatos",
"metadata_key": "Clave de metadatos",
"metadata_read_only_entries": "Valores de metadatos de solo lectura (no cadenas)",
"metadata_value": "Valor de metadatos",
"missing_feedback_source_title": "¿Falta alguna fuente de feedback?",
"no_feedback_record_directory_available": "No hay ningún directorio de registros de comentarios asignado a este espacio de trabajo. Crea o asigna uno primero.",
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"no_surveys_found": "No se encontraron encuestas en este entorno",
"optional": "Opcional",
"or_drag_and_drop": "o arrastra y suelta",
"question_selected": "<strong>{count}</strong> pregunta seleccionada. Cada respuesta a esta pregunta creará un registro de feedback nuevo.",
"question_type_not_supported": "Este tipo de pregunta no es compatible",
"questions_selected": "<strong>{count}</strong> preguntas seleccionadas. Cada respuesta a estas preguntas creará un registro de feedback nuevo.",
"records_will_go_to": "Los registros se enviarán a",
"refresh_feedback_records": "Actualizar los registros de comentarios",
"refreshing_feedback_records": "Actualizando registros de comentarios...",
"request_feedback_source": "Solicitar integración de fuente",
"required": "Obligatorio",
"save_changes": "Guardar cambios",
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
"select_a_value": "Selecciona un valor...",
"select_all": "Seleccionar todo",
"select_feedback_record_directory": "Selecciona un directorio",
"select_feedback_record_source_type": "Seleccionar tipo de fuente",
"select_questions": "Seleccionar preguntas",
"select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.",
"select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:",
"select_survey": "Seleccionar encuesta",
"select_survey_and_questions": "Seleccionar encuesta y preguntas",
"select_survey_questions_description": "Elige qué preguntas de la encuesta deben crear FeedbackRecords.",
@@ -3658,27 +3742,31 @@
"showing_rows": "Mostrando 3 de {count} filas",
"source": "origen",
"source_connect_csv_description": "Importar feedback desde archivos CSV",
"source_connect_feedback_record_mcp_description": "Envía registros de feedback a través de la integración MCP.",
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
"source_fields": "Campos de origen",
"source_id": "ID de fuente",
"source_name": "Nombre de origen",
"source_type": "Tipo de fuente",
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
"sources": "Orígenes",
"status_active": "En progreso",
"status_completed": "Completado",
"status_draft": "Borrador",
"status_error": "Error",
"status_live_sync": "Sincronización en vivo",
"status_paused": "Pausado",
"status_ready": "Listo",
"submission_id": "ID de envío",
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
"survey_import_line": "{surveyName}: {responseCount} respuestas × {questionCount} preguntas = {total} registros de feedback",
"total_feedback_records": "Total: {checked} de {total} registros de feedback seleccionados en {surveyCount} encuestas",
"topics_and_subtopics": "Temas y subtemas",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
"updated_at": "Actualizado el",
"upload_csv_data_description": "Sube un archivo CSV para importar datos de comentarios.",
"upload_csv_file": "Subir archivo CSV",
"user_identifier": "Usuario",
"value": "Valor"
"value": "Valor",
"value_boolean": "Valor (booleano)",
"value_date": "Valor (Fecha)",
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
"xm-templates": {
"ces": "CES",
+153 -65
View File
@@ -125,6 +125,7 @@
"activity": "Activité",
"add": "Ajouter",
"add_action": "Ajouter une action",
"add_chart": "Ajouter un graphique",
"add_charts": "Ajouter des graphiques",
"add_existing_chart_description": "Recherchez et sélectionnez des graphiques à ajouter à ce tableau de bord.",
"add_filter": "Ajouter un filtre",
@@ -159,6 +160,7 @@
"change_workspace": "Changer d'espace de travail",
"chart": "Graphique",
"charts": "Graphiques",
"choice_n": "Choix {n}",
"choices": "Choix",
"choose_organization": "Choisir l'organisation",
"choose_workspace": "Choisir un projet",
@@ -171,8 +173,9 @@
"close": "Fermer",
"code": "Code",
"collapse_rows": "Réduire les lignes",
"column_n": "Colonne {n}",
"completed": "Terminé",
"configuration": "Configuration",
"configuration": "Configurer",
"confirm": "Confirmer",
"connect": "Connecter",
"connect_formbricks": "Connecter Formbricks",
@@ -230,7 +233,6 @@
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
"environment": "Environnement",
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
"error_component_title": "Erreur de chargement des ressources",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des projets",
"failed_to_parse_csv": "Échec de l'analyse du CSV",
"field_placeholder": "Espace réservé pour {field}",
"filter": "Filtre",
"finish": "Terminer",
"first_name": "Prénom",
@@ -253,11 +256,13 @@
"generate": "Générer",
"go_back": "Retourner",
"go_to_dashboard": "Aller au tableau de bord",
"headline": "Titre principal",
"hidden": "Caché",
"hidden_field": "Champ caché",
"hidden_fields": "Champs cachés",
"hide": "Masquer",
"hide_column": "Cacher la colonne",
"html": "HTML",
"id": "ID",
"image": "Image",
"images": "Images",
@@ -306,7 +311,6 @@
"more_options": "Plus d'options",
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
"my_product": "mon produit",
"name": "Nom",
"new": "Nouveau",
@@ -323,10 +327,12 @@
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
"no_text_found": "Aucun texte trouvé",
"none_of_the_above": "Aucun des éléments ci-dessus",
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
"not_authorized": "Non autorisé",
"not_connected": "Non connecté",
"not_set": "Non défini",
"note": "Remarque",
"notifications": "Notifications",
"number": "Numéro",
@@ -347,7 +353,7 @@
"organization_settings": "Paramètres de l'organisation",
"other": "Autre",
"other_filters": "Autres filtres",
"others": "Autres",
"other_placeholder": "Autre espace réservé",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
@@ -365,10 +371,8 @@
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"production": "Production",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"progress": "Progression",
@@ -388,18 +392,22 @@
"report_survey": "Rapport d'enquête",
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"resize": "Redimensionner",
"response": "Réponse",
"response_id": "ID de réponse",
"responses": "Réponses",
"restart": "Recommencer",
"retry": "Réessayer",
"role": "Rôle",
"row_n": "Ligne {n}",
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
"save_as_draft": "Enregistrer comme brouillon",
"save_changes": "Enregistrer les modifications",
"save_without_scheduling": "Enregistrer sans planification",
"saving": "Sauvegarder",
"scheduled": "Planifié",
"search": "Recherche",
"search_charts": "Rechercher des graphiques...",
"security": "Sécurité",
@@ -426,6 +434,7 @@
"some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés",
"something_went_wrong": "Quelque chose s'est mal passé.",
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
"soon": "Bientôt",
"sort_by": "Trier par",
"start_free_trial": "Commencer l'essai gratuit",
"status": "Statut",
@@ -433,6 +442,7 @@
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
"string": "Texte",
"styling": "Style",
"subheader": "Sous-titre",
"submit": "Soumettre",
"summary": "Résumé",
"survey": "Enquête",
@@ -441,6 +451,7 @@
"survey_languages": "Langues de l'enquête",
"survey_live": "Sondage en direct",
"survey_paused": "Sondage en pause.",
"survey_scheduled": "Enquête planifiée.",
"survey_type": "Type de sondage",
"surveys": "Enquêtes",
"table_items_deleted_successfully": "{type}s supprimés avec succès",
@@ -503,7 +514,6 @@
"workspaces": "Projets",
"years": "années",
"yes": "Oui",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {workspaceLimit} espaces de travail.",
@@ -521,11 +531,11 @@
"email_footer_text_2": "L'équipe Formbricks",
"email_template_text_1": "Cet e-mail a été envoyé via Formbricks.",
"embed_survey_preview_email_didnt_request": "Vous n'avez pas demandé cela ?",
"embed_survey_preview_email_environment_id": "ID d'environnement",
"embed_survey_preview_email_fight_spam": "Aidez-nous à lutter contre le spam et transférez ce mail à hola@formbricks.com.",
"embed_survey_preview_email_heading": "Aperçu de l'email intégré",
"embed_survey_preview_email_subject": "Aperçu du sondage par e-mail Formbricks",
"embed_survey_preview_email_text": "C'est ainsi que le code s'affiche intégré dans un e-mail :",
"embed_survey_preview_email_workspace_id": "ID de l'espace de travail",
"forgot_password_email_change_password": "Changer le mot de passe",
"forgot_password_email_did_not_request": "Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
"forgot_password_email_heading": "Changer le mot de passe",
@@ -628,6 +638,7 @@
"question_preview": "Aperçu de la question",
"response_already_received": "Nous avons déjà reçu une réponse pour cette adresse e-mail.",
"response_submitted": "Une réponse liée à cette enquête et à ce contact existe déjà",
"scheduled": "Cette enquête est planifiée pour être mise en ligne prochainement.",
"survey_already_answered_heading": "L'enquête a déjà été répondue.",
"survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.",
"survey_sent_to": "Enquête envoyée à {email}",
@@ -767,6 +778,10 @@
"career_development_survey_question_6_choice_6": "Autre",
"career_development_survey_question_6_headline": "Lequel des éléments suivants décrit le mieux votre niveau de poste actuel ?",
"career_development_survey_question_6_subheader": "Veuillez sélectionner l'une des options suivantes.",
"ces": "Effort Client (CES)",
"ces_description": "Mesurer le score d'effort client (1-5 ou 1-7)",
"ces_lower_label": "Très difficile",
"ces_upper_label": "Très facile",
"cess_survey_name": "Sondage CES",
"cess_survey_question_1_headline": "$[workspaceName] me facilite la tâche pour [AJOUTER L'OBJECTIF]",
"cess_survey_question_1_lower_label": "Pas du tout d'accord",
@@ -830,7 +845,9 @@
"consent_description": "Demander d'accepter les termes, conditions ou l'utilisation des données",
"contact_info": "Informations de contact",
"contact_info_description": "Demandez le nom, le prénom, l'email, le numéro de téléphone et l'entreprise ensemble.",
"csat_description": "Mesurez le score de satisfaction client de votre produit ou service.",
"csat": "Satisfaction Client (CSAT)",
"csat_description": "Mesurer le score de satisfaction client (1-5)",
"csat_lower_label": "Très insatisfait",
"csat_name": "Score de Satisfaction Client (CSAT)",
"csat_question_10_headline": "Avez-vous d'autres commentaires, questions ou préoccupations ?",
"csat_question_10_placeholder": "Entrez votre réponse ici...",
@@ -906,6 +923,7 @@
"csat_survey_question_2_placeholder": "Entrez votre réponse ici...",
"csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?",
"csat_survey_question_3_placeholder": "Entrez votre réponse ici...",
"csat_upper_label": "Très satisfait",
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
"custom_survey_description": "Créez une enquête sans utiliser de modèle.",
"custom_survey_name": "Tout créer moi-même",
@@ -1071,6 +1089,11 @@
"gauge_feature_satisfaction_question_2_headline": "Quelle est une chose que nous pourrions améliorer ?",
"identify_customer_goals_description": "Mieux comprendre si votre message crée les bonnes attentes quant à la valeur que votre produit apporte.",
"identify_customer_goals_name": "Identifier les objectifs des clients",
"identify_customer_goals_question_1_choice_1": "Comprendre ma base d'utilisateurs en profondeur",
"identify_customer_goals_question_1_choice_2": "Identifier les opportunités de vente incitative",
"identify_customer_goals_question_1_choice_3": "Créer le meilleur produit possible",
"identify_customer_goals_question_1_choice_4": "Conquérir le monde pour imposer les choux de Bruxelles au petit-déjeuner",
"identify_customer_goals_question_1_headline": "Quel est ton objectif principal en utilisant $[workspaceName] ?",
"identify_sign_up_barriers_description": "Offrir une remise pour recueillir des informations sur les obstacles à l'inscription.",
"identify_sign_up_barriers_name": "Identifier les obstacles à l'inscription",
"identify_sign_up_barriers_question_1_button_label": "Obtenez 10 % de réduction",
@@ -1145,6 +1168,8 @@
"improve_trial_conversion_question_1_subheader": "Aidez-nous à mieux vous comprendre :",
"improve_trial_conversion_question_2_button_label": "Suivant",
"improve_trial_conversion_question_2_headline": "Désolé d'apprendre ça. Quel a été le plus gros problème lors de l'utilisation de $[workspaceName] ?",
"improve_trial_conversion_question_3_button_label": "Suivant",
"improve_trial_conversion_question_3_headline": "Qu'est-ce que tu t'attendais à ce que $[workspaceName] fasse ?",
"improve_trial_conversion_question_4_button_label": "Obtenez 20 % de réduction",
"improve_trial_conversion_question_4_headline": "Désolé d'apprendre cela ! Bénéficiez de 20 % de réduction sur la première année.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nous sommes heureux de vous offrir une remise de 20 % sur un plan annuel.</span></p>",
@@ -1615,7 +1640,6 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Cette action se déclenche quand une page est chargée.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Cette action se déclenche quand un utilisateur consulte 50 % d'une page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Cette action se déclenche quand un utilisateur tente de quitter une page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ceci est une action de code. Veuillez apporter des modifications à votre base de code.",
"time_in_seconds": "Temps en secondes",
"time_in_seconds_placeholder": "par ex. 10",
"time_in_seconds_with_unit": "{seconds} s",
@@ -1662,7 +1686,7 @@
"chart_type_bar": "Graphique à barres",
"chart_type_big_number": "Grand nombre",
"chart_type_line": "Graphique linéaire",
"chart_type_not_supported": "Le type de graphique « {{chartType}} » n'est pas encore pris en charge",
"chart_type_not_supported": "Le type de graphique \"{chartType}\" n'est pas encore pris en charge",
"chart_type_pie": "Graphique circulaire",
"chart_updated_successfully": "Graphique mis à jour avec succès!",
"configure_description": "Modifiez le type de graphique et d'autres paramètres pour cette visualisation.",
@@ -1750,7 +1774,7 @@
"no_valid_data_to_display": "Aucune donnée valide à afficher",
"not_contains": "ne contient pas",
"not_equals": "n'est pas égal à",
"open_chart": "Ouvrir le graphique {{name}}",
"open_chart": "Ouvrir le graphique {name}",
"open_options": "Ouvrir les options du graphique",
"or_filter_logic": "OU",
"original": "Original",
@@ -1761,8 +1785,10 @@
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
"predefined_measures": "Mesures prédéfinies",
"preset": "Préréglage",
"preview_chart": "Aperçu du graphique",
"query_executed_successfully": "Requête exécutée avec succès",
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
"save_and_add_to_dashboard": "Enregistrer et ajouter au tableau de bord",
"save_chart": "Enregistrer le graphique",
"save_chart_dialog_title": "Enregistrer le graphique",
"select_data_source": "Select a data source",
@@ -1771,11 +1797,12 @@
"select_field": "Sélectionner un champ",
"select_measures": "Sélectionner les mesures...",
"select_preset": "Sélectionner un préréglage",
"showing_first_n_of": "Affichage des {{n}} premières lignes sur {{count}}",
"showing_first_n_of": "Affichage des {n} premières lignes sur {count}",
"start_date": "Date de début",
"time_dimension": "Dimension temporelle",
"time_dimension_title": "Ajouter un groupement temporel",
"time_dimension_toggle_description": "Surveille les tendances dans le temps."
"time_dimension_toggle_description": "Surveille les tendances dans le temps.",
"update_chart": "Mettre à jour le graphique"
},
"dashboards": {
"add_count_charts": "Ajouter {count} graphique(s)",
@@ -1786,6 +1813,7 @@
"create_dashboard": "Créer un tableau de bord",
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
"create_failed": "Échec de la création du tableau de bord",
"create_new_chart": "Créer un nouveau graphique",
"create_success": "Tableau de bord créé avec succès!",
"dashboard": "Tableau de bord",
"dashboard_delete_confirmation": "Es-tu sûr(e) de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
@@ -1800,12 +1828,14 @@
"duplicate_failed": "Échec de la duplication du tableau de bord",
"duplicate_success": "Tableau de bord dupliqué avec succès!",
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
"no_charts_available_description": "Il n'y a aucun graphique pouvant être ajouté à ce tableau de bord. Soit aucun graphique n'existe encore, soit tous les graphiques existants ont déjà été ajoutés. Rendez-vous sur la page Graphiques pour créer de nouveaux graphiques.",
"no_charts_to_add_message": "Aucun graphique à ajouter à ce tableau de bord.",
"no_dashboards_found": "Aucun tableau de bord trouvé.",
"no_data_message": "Aucune donnée. Il n'y a actuellement aucune information à afficher. Ajoute des graphiques pour construire ton tableau de bord.",
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Vous n'avez pas d'enregistrements de commentaires sur lesquels créer des rapports. Configurez des sources de commentaires pour introduire des données dans le système.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurer les sources de commentaires"
},
"api_keys": {
"add_api_key": "Ajouter une clé API",
@@ -1818,13 +1848,19 @@
"api_key_updated": "Clé API mise à jour",
"delete_api_key_confirmation": "Toute application utilisant cette clé ne pourra plus accéder à vos données Formbricks.",
"duplicate_access": "Accès en double à l'espace de travail non autorisé",
"duplicate_directory_access": "L'accès en double au répertoire d'enregistrement de commentaires n'est pas autorisé",
"feedback_record_directory_access": "Accès au répertoire d'enregistrement de commentaires",
"no_api_keys_yet": "Vous n'avez pas encore de clés API",
"no_env_permissions_found": "Aucune permission d'environnement trouvée",
"no_directory_permissions_found": "Aucune autorisation de répertoire d'enregistrement de commentaires trouvée",
"no_workspace_permissions_found": "Aucune autorisation d'espace de travail trouvée",
"organization_access": "Accès à l'organisation",
"organization_access_description": "Sélectionnez les privilèges de lecture ou d'écriture pour les ressources à l'échelle de l'organisation.",
"permissions": "Permissions",
"secret": "Secret",
"unable_to_copy_api_key": "Impossible de copier la clé API",
"unable_to_delete_api_key": "Impossible de supprimer la clé API",
"unknown_directory": "Répertoire inconnu",
"unknown_workspace": "Espace de travail inconnu",
"workspace_access": "Accès à l'espace de travail"
},
"app-connection": {
@@ -1832,8 +1868,6 @@
"app_connection_description": "Connectez votre application ou votre site web à Formbricks.",
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour de sondages, de contacts, d'actions ou d'autres données, il peut falloir jusqu'à 1 minute pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks.",
"cache_update_delay_title": "Les modifications seront reflétées après environ 1 minute en raison de la mise en cache",
"environment_id": "ID de votre espace de travail",
"environment_id_description": "Cet identifiant identifie de manière unique cet espace de travail Formbricks.",
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
"formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.",
"formbricks_sdk_not_connected_description": "Ajoutez le SDK Formbricks à votre site web ou à votre application pour le connecter à Formbricks",
@@ -1842,10 +1876,12 @@
"receiving_data": "Réception des données 💃🕺",
"recheck": "Revérifier",
"sdk_connection_details": "Détails de connexion du SDK",
"sdk_connection_details_description": "Votre ID d'espace de travail unique et l'URL de connexion SDK pour intégrer Formbricks à votre application.",
"sdk_connection_details_description": "Ton ID d'espace de travail unique et l'URL de connexion SDK pour intégrer Formbricks à ton application.",
"setup_alert_description": "Suivez ce tutoriel étape par étape pour connecter votre application ou votre site web en moins de 5 minutes.",
"setup_alert_title": "Comment se connecter",
"webapp_url": "URL de connexion SDK"
"webapp_url": "URL de connexion SDK",
"workspace_id": "ID de votre espace de travail",
"workspace_id_description": "Cet identifiant identifie de manière unique cet espace de travail Formbricks."
},
"connect": {
"congrats": "Félicitations !",
@@ -1876,10 +1912,10 @@
"attribute_value_placeholder": "Valeur d'attribut",
"attributes_msg_attribute_limit_exceeded": "Impossible de créer {count, plural, one {# nouvel attribut} other {# nouveaux attributs}} car cela dépasserait la limite maximale de {limit} classes d'attributs. Les attributs existants ont été mis à jour avec succès.",
"attributes_msg_attribute_type_validation_error": "{error} (l'attribut “{key}” a le type de données: {dataType})",
"attributes_msg_email_already_exists": "L'adresse e-mail existe déjà pour cet environnement et n'a pas été mise à jour.",
"attributes_msg_email_already_exists": "L'adresse e-mail existe déjà pour cet espace de travail et n'a pas été mise à jour.",
"attributes_msg_email_or_userid_required": "L'e-mail ou l'identifiant utilisateur est requis. Les valeurs existantes ont été conservées.",
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
"attributes_msg_userid_already_exists": "L'ID utilisateur existe déjà pour cet espace de travail et n'a pas été mis à jour.",
"contact_deleted_successfully": "Contact supprimé avec succès",
"contacts_table_refresh": "Actualiser les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
@@ -2140,7 +2176,6 @@
"duplicate_language_or_language_id": "Langue ou identifiant de langue en double",
"edit_languages": "Modifier les langues",
"identifier": "Identifiant (ISO)",
"incomplete_translations": "Traductions incomplètes",
"language": "Langue",
"language_deleted_successfully": "Langue supprimée avec succès",
"languages_updated_successfully": "Langues mises à jour avec succès",
@@ -2150,8 +2185,7 @@
"please_select_a_language": "Veuillez sélectionner une langue",
"remove_language": "Supprimer la langue",
"remove_language_from_surveys_to_remove_it_from_workspace": "Veuillez supprimer la langue de ces sondages afin de la retirer de l'espace de travail.",
"search_items": "Rechercher des éléments",
"translate": "Traduire"
"search_items": "Rechercher des éléments"
},
"look": {
"add_background_color": "Ajouter une couleur d'arrière-plan",
@@ -2365,7 +2399,7 @@
"most_popular": "Le plus populaire",
"pending_change_removed": "Changement de formule programmé supprimé.",
"pending_plan_badge": "Programmé",
"pending_plan_change_description": "Ta formule passera à {{plan}} le {{date}}.",
"pending_plan_change_description": "Votre forfait passera à {plan} le {date}.",
"pending_plan_change_title": "Changement de formule programmé",
"pending_plan_cta": "Programmé",
"per_month": "par mois",
@@ -2524,21 +2558,22 @@
"nav_label": "Répertoires de feedback",
"no_access": "Tu n'as pas la permission de gérer les répertoires de feedback.",
"no_connectors": "Aucun connecteur lié à ce répertoire pour le moment.",
"pause_connectors_confirmation_description": "Si vous mettez ces connecteurs en pause, aucun nouvel enregistrement ne sera ajouté.",
"pause_connectors_confirmation_title": "Mettre en pause les connecteurs liés ?",
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
"show_archived": "Afficher les éléments archivés",
"title": "Répertoires d'enregistrement des retours",
"unarchive": "Désarchiver"
"unarchive": "Désarchiver",
"unarchive_workspace_conflict": "Impossible de désarchiver ce répertoire, car un ou plusieurs espaces de travail attribués sont archivés.",
"workspace_access": "Accès à lespace de travail"
},
"general": {
"ai_data_analysis_disabled_for_organization": "L'analyse et l'enrichissement des données par IA sont désactivés pour cette organisation.",
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
"ai_enabled": "IA Formbricks",
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
"ai_features_not_enabled_for_organization": "Les fonctionnalités d'IA ne sont pas activées pour cette organisation.",
"ai_instance_not_configured": "L'IA est configurée au niveau de l'instance via des variables d'environnement. Demandez à votre administrateur de définir AI_PROVIDER, les identifiants du fournisseur et la liste de modèles correspondante avant d'activer les fonctionnalités d'IA.",
"ai_settings_updated_successfully": "Paramètres IA mis à jour avec succès",
"ai_smart_tools_disabled_for_organization": "Les outils intelligents d'IA sont désactivés pour cette organisation.",
"ai_smart_tools_enabled": "Fonctionnalités intelligentes (IA)",
"ai_smart_tools_enabled_description": "L'IA pour vous aider à accomplir plus en moins de temps. N'accède jamais aux données collectées avec Formbricks. Utilisée uniquement pour, par exemple, traduire les sondages dans d'autres langues.",
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
@@ -2596,7 +2631,9 @@
"security_list_tip_link": "Inscrivez-vous ici.",
"share_invite_link": "Partager le lien d'invitation",
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
"test_email_sent_successfully": "E-mail de test envoyé avec succès"
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
"unlock_ai_features_description": "Les traductions automatiques, les outils intelligents et l'analyse de données sont disponibles avec les forfaits supérieurs. Passez à un forfait supérieur pour booster vos enquêtes avec l'IA.",
"unlock_ai_features_with_a_higher_plan": "Débloquez les fonctionnalités IA avec un forfait supérieur"
},
"notifications": {
"auto_subscribe_to_new_surveys": "S'abonner automatiquement aux nouvelles enquêtes",
@@ -2694,17 +2731,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Vous êtes prêt ! Il est temps de créer votre première enquête.",
"alphabetical": "Alphabétique",
"copy_survey": "Copier l'enquête",
"copy_survey_description": "Copier ce sondage vers un autre espace de travail",
"copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_no_workspaces": "Il n'y a aucun autre espace de travail vers lequel copier ce sondage.",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
"activate_translations": "Activer les traductions",
"add": "Ajouter +",
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
@@ -2741,6 +2771,18 @@
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
"adjust_the_theme_in_the": "Ajustez le thème dans le",
"ai_data_analysis_disabled": "L'analyse de données par IA est désactivée pour cette organisation.",
"ai_features_not_enabled": "Les fonctionnalités IA ne sont pas activées pour cette organisation.",
"ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.",
"ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.",
"ai_translate": "Traduire avec l'IA",
"ai_translating": "Traduction en cours avec l'IA... Garde cette fenêtre ouverte.",
"ai_translation_all_fields_populated": "Tous les champs sont déjà traduits",
"ai_translation_complete": "Traduction IA terminée",
"ai_translation_failed": "La traduction a échoué",
"ai_translation_instance_not_configured": "L'IA n'est pas configurée sur cette instance. Contacte ton administrateur.",
"ai_translation_not_available": "La traduction IA n'est pas disponible avec votre forfait actuel. Passez à un forfait supérieur pour débloquer cette fonctionnalité.",
"ai_translation_not_enabled": "Les outils intelligents IA sont désactivés pour cette organisation. Active-les dans les paramètres de l'organisation.",
"all_are_true": "toutes sont vraies",
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
"allow_multi_select": "Autoriser la sélection multiple",
@@ -2754,7 +2796,7 @@
"audience": "Public",
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
"auto_progress_rating_and_nps_description": "Passage automatique pour les blocs à question unique. Les questions obligatoires masquent le bouton Suivant, sauf lorsque « Autre » est sélection.",
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
@@ -2800,6 +2842,7 @@
"caution_text": "Les changements entraîneront des incohérences.",
"change_anyway": "Changer de toute façon",
"change_background": "Changer l'arrière-plan",
"change_default": "Modifier la langue par défaut",
"change_question_type": "Changer le type de question",
"change_survey_type": "Le changement de type de sondage affecte l'accès existant",
"change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.",
@@ -2811,7 +2854,11 @@
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
"choose_where_to_run_the_survey": "Choisissez où réaliser l'enquête.",
"city": "Ville",
"clear_close_on_date": "Effacer la date de mise en pause",
"clear_publish_on_date": "Effacer la date de publication",
"close_survey_on_date": "Date de mise en pause",
"close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse",
"code": "Code",
"color": "Couleur",
"column_used_in_logic_error": "Cette colonne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"columns": "Colonnes",
@@ -2836,6 +2883,7 @@
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
"default_language": "Langue par défaut",
"delete_anyways": "Supprimer quand même",
"delete_block": "Supprimer le bloc",
"delete_choice": "Supprimer l'option",
@@ -2855,7 +2903,6 @@
"duplicate_question": "Dupliquer la question",
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"element_not_found": "Question non trouvée",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux répondants de changer de langue à tout moment. Nécessite au moins 2 langues actives.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
@@ -2992,10 +3039,12 @@
"long_answer_toggle_description": "Permettre aux répondants d'écrire des réponses plus longues et sur plusieurs lignes.",
"lower_label": "Étiquette inférieure",
"manage_languages": "Gérer les langues",
"manage_translations": "Gérer les traductions",
"matrix_all_fields": "Tous les champs",
"matrix_rows": "Lignes",
"max_file_size": "Taille maximale du fichier",
"max_file_size_limit_is": "La limite de taille maximale du fichier est",
"missing_first": "Manquantes en premier",
"move_question_to_block": "Déplacer la question vers le bloc",
"multiply": "Multiplier *",
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
@@ -3003,7 +3052,7 @@
"next_button_label": "Libellé du bouton « Suivant »",
"no_hidden_fields_yet_add_first_one_below": "Aucun champ caché pour le moment. Ajoutez le premier ci-dessous.",
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
"no_languages_found_add_first_one_to_get_started": "Aucune langue d'enquête trouvée dans cet espace de travail. Veuillez en ajouter une pour commencer.",
"no_option_found": "Aucune option trouvée",
"no_recall_items_found": "Aucun élément de rappel trouvé",
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
@@ -3030,12 +3079,14 @@
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
"please_specify": "Veuillez préciser",
"present_your_survey_in_multiple_languages": "Présente ton questionnaire dans plusieurs langues",
"prevent_double_submission": "Empêcher la double soumission",
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
"progress_saved": "Progression enregistrée",
"protect_survey_with_pin": "Protéger l'enquête par un code PIN",
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
"publish": "Publier",
"publish_survey_on_date": "Date de publication",
"question": "Question",
"question_deleted": "Question supprimée.",
"question_duplicated": "Question dupliquée.",
@@ -3106,6 +3157,7 @@
"rows": "Lignes",
"save_and_close": "Enregistrer et fermer",
"scale": "Échelle",
"schedule_survey": "Planifier l'enquête",
"search_for_images": "Rechercher des images",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Les secondes après le déclenchement, l'enquête sera fermée si aucune réponse n'est donnée.",
"seconds_before_showing_the_survey": "secondes avant de montrer l'enquête.",
@@ -3121,6 +3173,7 @@
"seven_points": "7 points",
"show_block_settings": "Afficher les paramètres du bloc",
"show_button": "Afficher le bouton",
"show_in_order": "Afficher dans l'ordre",
"show_language_switch": "Afficher le changement de langue",
"show_multiple_times": "Afficher un nombre limité de fois",
"show_only_once": "Afficher une seule fois",
@@ -3152,7 +3205,8 @@
"survey_preview": "Aperçu du sondage 👀",
"survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
"survey_will_be_closed_at_midnight_cet": "L'enquête sera fermée à {time} dans le fuseau horaire {timeZone} à la date sélectionnée",
"survey_will_be_published_at_midnight_cet": "L'enquête sera publiée à {time} dans le fuseau horaire {timeZone} à la date sélectionnée",
"target_block_not_found": "Bloc cible non trouvé",
"targeted": "Ciblé",
"ten_points": "10 points",
@@ -3160,9 +3214,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
"then": "Alors",
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
"this_will_remove_the_language_and_all_its_translations": "Cela supprimera cette langue et toutes ses traductions de ce questionnaire. Cette action est irréversible.",
"three_points": "3 points",
"times": "fois",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
"translated": "Traduit",
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
"type_field_id": "Identifiant de champ de type",
@@ -3237,6 +3293,7 @@
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"visibility_and_recontact": "Visibilité et recontact",
"visibility_and_recontact_description": "Contrôlez quand cette enquête peut apparaître et à quelle fréquence elle peut réapparaître.",
"visible": "Visible",
"wait": "Attendre",
"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.",
"waiting_time_across_surveys": "Période de refroidissement (entre les sondages)",
@@ -3438,6 +3495,8 @@
"configure_alerts": "Configurer les alertes",
"congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"csat_satisfied": "CSAT : {percentage} % Satisfaits",
"csat_satisfied_tooltip": "{percentage} % des répondants ont donné une note de 4 ou 5 (CSAT).",
"current_count": "Nombre actuel",
"custom_range": "Plage personnalisée...",
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
@@ -3445,7 +3504,7 @@
"downloading_qr_code": "Téléchargement du code QR",
"drop_offs": "Dépôts",
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
"failed_to_copy_link": "Échec de la copie du lien",
"effort_score": "Score d'effort",
"filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)",
@@ -3497,6 +3556,7 @@
"limit": "Limite",
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"nps_promoters_tooltip": "{percentage} % des répondants ont donné une note de 9 ou 10 (promoteurs NPS).",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
@@ -3509,7 +3569,6 @@
"quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.",
"reset_survey": "Réinitialiser l'enquête",
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
"satisfied": "Satisfait",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
"setup_integrations": "Configurer les intégrations",
@@ -3519,6 +3578,7 @@
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
"survey_results": "Résultats de {surveyName}",
"survey_scheduled_successfully": "Enquête planifiée avec succès",
"this_month": "Ce mois-ci",
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
@@ -3533,7 +3593,6 @@
},
"survey_deleted_successfully": "Enquête supprimée avec succès !",
"survey_duplicated_successfully": "Enquête dupliquée avec succès.",
"survey_duplication_error": "Échec de la duplication de l'enquête.",
"templates": {
"all_channels": "Tous les canaux",
"all_industries": "Tous les secteurs",
@@ -3568,16 +3627,21 @@
"team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail."
},
"unify": {
"add_feedback_record": "Ajouter un enregistrement de commentaires",
"add_feedback_record_description": "Créez manuellement un enregistrement de commentaires.",
"add_feedback_source": "Ajouter une source de feedback",
"add_source": "Ajouter une source",
"allowed_values": "Valeurs autorisées: {values}",
"api_ingestion": "Ingestion par API",
"api_ingestion_manage_api_keys": "Gérer les clés API",
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
"auto_generated": "Généré automatiquement",
"change_file": "Changer de fichier",
"click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes",
"click_to_upload": "Clique pour charger",
"collected_at": "Collecté le",
"configure_import": "Configurer l'importation",
"configure_mapping": "Configurer le mappage",
"connection": "Connexion",
"connector_created_successfully": "Connecteur créé avec succès",
"connector_deleted_successfully": "Connecteur supprimé avec succès",
"connector_duplicated_successfully": "Connecteur dupliqué avec succès",
@@ -3596,9 +3660,12 @@
"csv_import_duplicate_warning": "Importer les données deux fois créera des enregistrements en double.",
"csv_inconsistent_columns": "La ligne {row} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.",
"csv_max_records": "Maximum {max} enregistrements autorisés.",
"custom_source_type": "Type de source personnalisé",
"custom_source_type_placeholder": "Entrez le type de source personnalisé",
"default_connector_name_csv": "Importation CSV",
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
"deselect_all": "Tout désélectionner",
"discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.",
"discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?",
"drop_a_field_here": "Déposez un champ ici",
"drop_field_or": "Déposez un champ ou",
"edit_csv_mapping": "Modifier le mappage CSV",
@@ -3608,47 +3675,64 @@
"enum": "enum",
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
"feedback_date": "Date actuelle",
"feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès",
"feedback_record_details": "Détails de l'enregistrement des commentaires",
"feedback_record_details_description": "Examiner et mettre à jour les champs denregistrement des commentaires.",
"feedback_record_directory": "Répertoire d'enregistrements de retour d'expérience",
"feedback_record_fields": "Champs d'enregistrement de feedback",
"feedback_record_mcp": "MCP d'enregistrement de feedback",
"feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès",
"feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné",
"feedback_records": "Enregistrements de feedback",
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
"feedback_sources": "Sources de feedback",
"feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}",
"feedback_sources_directory_access_single": "Les nouveaux enregistrements de cette source seront stockés dans : {directoryNames}",
"feedback_sources_settings_description": "Connecte et gère toutes les sources de feedback pour cet espace de travail.",
"field_group_id": "ID de groupe de champs",
"field_group_label": "Libellé du groupe de champs",
"field_id": "Identifiant du champ",
"field_label": "Libellé du champ",
"field_type": "Type de champ",
"formbricks_surveys": "Sondages Formbricks",
"frd_cannot_be_changed": "Le répertoire de retours d'expérience ne peut pas être modifié après sa création.",
"go_to_feedback_record_directories": "Accéder aux paramètres des répertoires",
"historical_import_complete": "Importation terminée: {successes} réussies, {failures} échouées, {skipped} ignorées (aucune donnée)",
"import_csv_data": "Importer les retours",
"import_feedback": "Importer les retours",
"import_historical_responses": "Importer les réponses historiques",
"import_historical_responses_description": "Importe les réponses existantes de cette enquête maintenant.",
"import_rows": "Importer {count} lignes",
"import_via_source_name": "Importer via \"{sourceName}\"",
"importing_data": "Importation des données...",
"importing_historical_data": "Importation des données historiques...",
"invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}",
"invalid_values_found": "Trouvées: {values} (lignes: {rows}) {extra}",
"load_sample_csv": "Charger un exemple de CSV",
"n_supported_questions": "{count} questions prises en charge",
"manage_directories": "Gérer les répertoires",
"manage_feedback_sources": "Gérer les sources de commentaires",
"metadata": "Métadonnées",
"metadata_key": "Clé de métadonnées",
"metadata_read_only_entries": "Valeurs de métadonnées en lecture seule (non-chaîne)",
"metadata_value": "Valeur des métadonnées",
"missing_feedback_source_title": "Il manque une source de feedback ?",
"no_feedback_record_directory_available": "Aucun répertoire d'enregistrements de retour d'expérience n'est assigné à cet espace de travail. Créez-en un ou assignez-en un d'abord.",
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
"no_surveys_found": "Aucune enquête trouvée dans cet environnement",
"optional": "Facultatif",
"or_drag_and_drop": "ou glisser-déposer",
"question_selected": "<strong>{count}</strong> question sélectionnée. Chaque réponse à cette question créera un nouvel enregistrement de feedback.",
"question_type_not_supported": "Ce type de question n'est pas pris en charge",
"questions_selected": "<strong>{count}</strong> questions sélectionnées. Chaque réponse à ces questions créera un nouvel enregistrement de feedback.",
"records_will_go_to": "Les enregistrements seront envoyés vers",
"refresh_feedback_records": "Actualiser les enregistrements de retours",
"refreshing_feedback_records": "Actualisation des enregistrements de feedback...",
"request_feedback_source": "Demander une intégration de source",
"required": "Requis",
"save_changes": "Enregistrer les modifications",
"select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions",
"select_a_value": "Sélectionnez une valeur...",
"select_all": "Sélectionner tout",
"select_feedback_record_directory": "Sélectionner un répertoire",
"select_feedback_record_source_type": "Sélectionnez le type de source",
"select_questions": "Sélectionner les questions",
"select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.",
"select_source_type_prompt": "Sélectionnez le type de source de feedback que vous souhaitez connecter:",
"select_survey": "Sélectionner l'enquête",
"select_survey_and_questions": "Sélectionner l'enquête et les questions",
"select_survey_questions_description": "Choisissez quelles questions d'enquête doivent créer des FeedbackRecords.",
@@ -3658,27 +3742,31 @@
"showing_rows": "Affichage de 3 sur {count} lignes",
"source": "source",
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
"source_connect_feedback_record_mcp_description": "Envoyer des enregistrements de feedback via l'intégration MCP.",
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
"source_fields": "Champs source",
"source_id": "Identifiant de la source",
"source_name": "Nom de la source",
"source_type": "Type de source",
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
"sources": "Sources",
"status_active": "En cours",
"status_completed": "Terminé",
"status_draft": "Brouillon",
"status_error": "Erreur",
"status_live_sync": "Synchronisation en direct",
"status_paused": "En pause",
"status_ready": "Prêt",
"submission_id": "ID de soumission",
"survey_has_no_questions": "Ce sondage n'a pas de questions",
"survey_import_line": "{surveyName}: {responseCount} réponses × {questionCount} questions = {total} enregistrements de feedback",
"total_feedback_records": "Total: {checked} sur {total} enregistrements de feedback sélectionnés parmi {surveyCount} sondages",
"topics_and_subtopics": "Thèmes et sous-thèmes",
"unify_feedback": "Unifier les retours",
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
"updated_at": "Mis à jour à",
"upload_csv_data_description": "Téléchargez un fichier CSV pour importer des données de feedback.",
"upload_csv_file": "Télécharger un fichier CSV",
"user_identifier": "Utilisateur",
"value": "Valeur"
"value": "Valeur",
"value_boolean": "Valeur (booléenne)",
"value_date": "Valeur (Date)",
"value_number": "Valeur (Nombre)",
"value_text": "Valeur (texte)"
},
"xm-templates": {
"ces": "CES",
+154 -66
View File
@@ -125,6 +125,7 @@
"activity": "Tevékenység",
"add": "Hozzáadás",
"add_action": "Művelet hozzáadása",
"add_chart": "Diagram hozzáadása",
"add_charts": "Diagramok hozzáadása",
"add_existing_chart_description": "Keressen és válasszon diagramokat a műszerfalhoz való hozzáadáshoz.",
"add_filter": "Szűrő hozzáadása",
@@ -159,6 +160,7 @@
"change_workspace": "Munkaterület módosítása",
"chart": "Diagram",
"charts": "Diagramok",
"choice_n": "{n}. választás",
"choices": "Választási lehetőségek",
"choose_organization": "Szervezet kiválasztása",
"choose_workspace": "Munkaterület kiválasztása",
@@ -171,8 +173,9 @@
"close": "Bezárás",
"code": "Kód",
"collapse_rows": "Sorok összecsukása",
"column_n": "{n}. oszlop",
"completed": "Befejezve",
"configuration": "Konfiguráció",
"configuration": "Konfigurálás",
"confirm": "Megerősítés",
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
@@ -230,7 +233,6 @@
"ending_card": "Befejező kártya",
"enter_url": "URL megadása",
"enterprise_license": "Vállalati licenc",
"environment": "Környezet",
"error": "Hiba",
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
"error_component_title": "Hiba az erőforrások betöltésekor",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"failed_to_parse_csv": "A CSV elemzése sikertelen",
"field_placeholder": "{field} helykitöltő",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
@@ -253,11 +256,13 @@
"generate": "Előállítás",
"go_back": "Vissza",
"go_to_dashboard": "Ugrás a vezérlőpultra",
"headline": "Címsor",
"hidden": "Rejtett",
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
"hide": "Elrejtés",
"hide_column": "Oszlop elrejtése",
"html": "HTML",
"id": "Azonosító",
"image": "Kép",
"images": "Képek",
@@ -306,7 +311,6 @@
"more_options": "További lehetőségek",
"move_down": "Mozgatás le",
"move_up": "Mozgatás fel",
"multiple_languages": "Több nyelv",
"my_product": "saját termék",
"name": "Név",
"new": "Új",
@@ -323,10 +327,12 @@
"no_result_found": "Nem található eredmény",
"no_results": "Nincs találat",
"no_surveys_found": "Nem találhatók kérdőívek.",
"no_text_found": "Nem található szöveg",
"none_of_the_above": "A fentiek közül egyik sem",
"not_authenticated": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.",
"not_authorized": "Nincs felhatalmazva",
"not_connected": "Nincs kapcsolódva",
"not_set": "Nincs beállítva",
"note": "Jegyzet",
"notifications": "Értesítések",
"number": "Szám",
@@ -347,7 +353,7 @@
"organization_settings": "Szervezet beállításai",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"others": "Mások",
"other_placeholder": "Egyéb helyőrző",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
@@ -365,10 +371,8 @@
"please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet",
"preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek",
"product_manager": "Termékmenedzser",
"production": "Produktív",
"profile": "Profil",
"profile_id": "Profilazonosító",
"progress": "Folyamat",
@@ -388,18 +392,22 @@
"report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbaidőszaki licenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"resize": "Átméretezés",
"response": "Válasz",
"response_id": "Válaszazonosító",
"responses": "Válaszok",
"restart": "Újraindítás",
"retry": "Újra",
"role": "Szerep",
"row_n": "{n}. sor",
"saas": "SaaS",
"sales": "Értékesítés",
"save": "Mentés",
"save_as_draft": "Mentés piszkozatként",
"save_changes": "Változtatások mentése",
"save_without_scheduling": "Mentés ütemezés nélkül",
"saving": "Mentés",
"scheduled": "Ütemezve",
"search": "Keresés",
"search_charts": "Diagramok keresése...",
"security": "Biztonság",
@@ -426,6 +434,7 @@
"some_files_failed_to_upload": "Néhány fájlt nem sikerült feltölteni",
"something_went_wrong": "Valami probléma történt",
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
"soon": "Hamarosan",
"sort_by": "Rendezési sorrend",
"start_free_trial": "Ingyenes próbaidőszak indítása",
"status": "Állapot",
@@ -433,6 +442,7 @@
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
"string": "Szöveg",
"styling": "Stíluskészítés",
"subheader": "Alcím",
"submit": "Elküldés",
"summary": "Összegzés",
"survey": "Kérdőív",
@@ -441,6 +451,7 @@
"survey_languages": "Kérdőív nyelvei",
"survey_live": "A kérdőív élő",
"survey_paused": "A kérdőív szüneteltetve.",
"survey_scheduled": "A felmérés ütemezve lett.",
"survey_type": "Kérdőív típusa",
"surveys": "Kérdőívek",
"table_items_deleted_successfully": "{type}s sikeresen törölve",
@@ -503,7 +514,6 @@
"workspaces": "Munkaterületek",
"years": "év",
"yes": "Igen",
"you": "Ön",
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
"you_have_reached_your_limit_of_workspace_limit": "Elérte a munkaterületek {workspaceLimit} darabos korlátját.",
@@ -521,11 +531,11 @@
"email_footer_text_2": "A Formbricks csapata",
"email_template_text_1": "Ez az e-mail a Formbricks által lett elküldve.",
"embed_survey_preview_email_didnt_request": "Nem kérte ezt?",
"embed_survey_preview_email_environment_id": "Környezeti azonosító",
"embed_survey_preview_email_fight_spam": "Segítsen nekünk a levélszemét elleni küzdelemben, és továbbítsa ezt a levelet a hola@formbricks.com címre.",
"embed_survey_preview_email_heading": "Beágyazott e-mail előnézete",
"embed_survey_preview_email_subject": "Formbricks e-mail-kérdőív előnézet",
"embed_survey_preview_email_text": "Így néz ki a kódrészlet egy e-mailbe ágyazva:",
"embed_survey_preview_email_workspace_id": "Munkaterület azonosító",
"forgot_password_email_change_password": "Jelszó megváltoztatása",
"forgot_password_email_did_not_request": "Ha Ön nem kérte ezt, akkor hagyja figyelmen kívül ezt a levelet.",
"forgot_password_email_heading": "Jelszó megváltoztatása",
@@ -628,6 +638,7 @@
"question_preview": "Kérdés előnézete",
"response_already_received": "Már kaptunk választ erről az e-mail-címről.",
"response_submitted": "Ehhez a kérdőívhez és partnerhez kapcsolódó válasz már létezik",
"scheduled": "Ez a felmérés hamarosan ütemezetten elindul.",
"survey_already_answered_heading": "A kérdőív már meg lett válaszolva.",
"survey_already_answered_subheading": "Ezt a hivatkozást csak egyszer használhatja.",
"survey_sent_to": "A kérdőív elküldve a(z) {email} e-mail-címre",
@@ -767,6 +778,10 @@
"career_development_survey_question_6_choice_6": "Egyéb",
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
"ces": "Ügyfél Erőfeszítés (CES)",
"ces_description": "Ügyfél Erőfeszítési Pontszám mérése (1-5 vagy 1-7)",
"ces_lower_label": "Rendkívül nehéz",
"ces_upper_label": "Rendkívül könnyű",
"cess_survey_name": "Ügyfél-erőfeszítési pontszám kérdőív",
"cess_survey_question_1_headline": "A(z) $[workspaceName] megkönnyíti számomra a következő cél elérését: [CÉL HOZZÁADÁSA]",
"cess_survey_question_1_lower_label": "Egyáltalán nem értek egyet",
@@ -830,7 +845,9 @@
"consent_description": "Felhasználási feltételek vagy adatfelhasználás elfogadásának kérése",
"contact_info": "Kapcsolatfelvételi információk",
"contact_info_description": "Név, vezetéknév, e-mail-cím, telefonszám és vállalat együttes megadásának kérése",
"csat_description": "A termék vagy szolgáltatás ügyfél-elégedettségi pontszámának mérése.",
"csat": gyfél Elégedettség (CSAT)",
"csat_description": "Ügyfél Elégedettségi Pontszám mérése (1-5)",
"csat_lower_label": "Rendkívül elégedetlen",
"csat_name": "Ügyfél-elégedettségi pontszám (CSAT)",
"csat_question_10_headline": "Van még egyéb megjegyzése, kérdése vagy aggálya?",
"csat_question_10_placeholder": "Írja be ide a válaszát…",
@@ -906,6 +923,7 @@
"csat_survey_question_2_placeholder": "Írja be ide a válaszát…",
"csat_survey_question_3_headline": "Jaj, bocsánat! Tehetünk valamit, amivel javíthatnánk az élményén?",
"csat_survey_question_3_placeholder": "Írja be ide a válaszát…",
"csat_upper_label": "Rendkívül elégedett",
"cta_description": "Információk megjelenítése és a felhasználók felkérése egy bizonyos művelet elvégzésére",
"custom_survey_description": "Kérdőív létrehozása sablon nélkül.",
"custom_survey_name": "Kezdés a semmiből",
@@ -1071,6 +1089,11 @@
"gauge_feature_satisfaction_question_2_headline": "Mi az egyetlen dolog, amelyet jobban csinálhatnánk?",
"identify_customer_goals_description": "Jobban megérteni, hogy az üzenetei a termék által nyújtott érték megfelelő elvárásait keltik-e.",
"identify_customer_goals_name": "Ügyfélcélok azonosítása",
"identify_customer_goals_question_1_choice_1": "Felhasználói bázis mélyreható megértése",
"identify_customer_goals_question_1_choice_2": "Felárképzési lehetőségek azonosítása",
"identify_customer_goals_question_1_choice_3": "A lehető legjobb termék kifejlesztése",
"identify_customer_goals_question_1_choice_4": "A világ meghódítása, hogy mindenki reggeli kelbimbót egyék",
"identify_customer_goals_question_1_headline": "Mi az Ön elsődleges célja a(z) $[workspaceName] használatával?",
"identify_sign_up_barriers_description": "Kedvezmény felajánlása a regisztrációs akadályokkal kapcsolatos tapasztalatok gyűjtéséhez.",
"identify_sign_up_barriers_name": "Regisztrációs akadályok azonosítása",
"identify_sign_up_barriers_question_1_button_label": "10% kedvezmény",
@@ -1145,12 +1168,14 @@
"improve_trial_conversion_question_1_subheader": "Segítsen nekünk jobban megérteni Önt:",
"improve_trial_conversion_question_2_button_label": "Következő",
"improve_trial_conversion_question_2_headline": "Sajnáljuk. Mi volt a legnagyobb probléma a $[workspaceName] használata során?",
"improve_trial_conversion_question_3_button_label": "Tovább",
"improve_trial_conversion_question_3_headline": "Mire számított a(z) $[workspaceName] kapcsán?",
"improve_trial_conversion_question_4_button_label": "20% kedvezmény",
"improve_trial_conversion_question_4_headline": "Sajnálattal halljuk! 20% kedvezményt kap az első évre.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Boldogan felajánlunk 20% kedvezményt az éves csomagra.</span></p>",
"improve_trial_conversion_question_5_button_label": "Következő",
"improve_trial_conversion_question_5_headline": "Mit szeretne elérni?",
"improve_trial_conversion_question_5_subheader": "Válassza ki a következő lehetőségek egyikét:",
"improve_trial_conversion_question_5_subheader": "Kérem, válasszon egyet a következő lehetőségek közül:",
"improve_trial_conversion_question_6_headline": "Hogyan oldja meg a problémáját most?",
"improve_trial_conversion_question_6_subheader": "Nevezzen meg alternatív megoldásokat:",
"integration_setup_survey_description": "Annak kiértékelése, hogy a felhasználók mennyire könnyen tudnak integrációkat hozzáadni a termékéhez. A vakfoltok megtalálása.",
@@ -1615,7 +1640,6 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
"time_in_seconds": "Idő másodpercben",
"time_in_seconds_placeholder": "például 10",
"time_in_seconds_with_unit": "{seconds} mp",
@@ -1662,7 +1686,7 @@
"chart_type_bar": "Oszlopdiagram",
"chart_type_big_number": "Nagy szám",
"chart_type_line": "Vonaldiagram",
"chart_type_not_supported": "A(z) \"{{chartType}}\" diagramtípus még nem támogatott",
"chart_type_not_supported": "A(z) \"{chartType}\" diagramtípus még nem támogatott",
"chart_type_pie": "Kördiagram",
"chart_updated_successfully": "A diagram sikeresen frissítve!",
"configure_description": "Módosítsd a diagram típusát és egyéb beállításait ehhez a vizualizációhoz.",
@@ -1750,7 +1774,7 @@
"no_valid_data_to_display": "Nincsenek megjeleníthető érvényes adatok",
"not_contains": "nem tartalmazza",
"not_equals": "nem egyenlő",
"open_chart": "{{name}} diagram megnyitása",
"open_chart": "{name} diagram megnyitása",
"open_options": "Diagram beállításainak megnyitása",
"or_filter_logic": "VAGY",
"original": "Eredeti",
@@ -1761,8 +1785,10 @@
"please_select_dashboard": "Kérjük, válassz egy vezérlőpultot",
"predefined_measures": "Előre definiált mérőszámok",
"preset": "Előbeállítás",
"preview_chart": "Előnézet diagram",
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
"save_and_add_to_dashboard": "Mentés és hozzáadása az irányítópulthoz",
"save_chart": "Diagram mentése",
"save_chart_dialog_title": "Diagram mentése",
"select_data_source": "Select a data source",
@@ -1771,11 +1797,12 @@
"select_field": "Mező kiválasztása",
"select_measures": "Mérőszámok kiválasztása...",
"select_preset": "Előbeállítás kiválasztása",
"showing_first_n_of": "Az első {{n}} sor megjelenítése {{count}} sorból",
"showing_first_n_of": "Az első {n} sor megjelenítése a(z) {count} sorból",
"start_date": "Kezdési dátum",
"time_dimension": "Időbeli dimenzió",
"time_dimension_title": "Időalapú csoportosítás hozzáadása",
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében."
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében.",
"update_chart": "Frissítse a diagramot"
},
"dashboards": {
"add_count_charts": "{count} diagram hozzáadása",
@@ -1786,6 +1813,7 @@
"create_dashboard": "Műszerfal létrehozása",
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
"create_failed": "A vezérlőpult létrehozása sikertelen",
"create_new_chart": "Új diagram létrehozása",
"create_success": "A vezérlőpult sikeresen létrehozva!",
"dashboard": "Műszerfal",
"dashboard_delete_confirmation": "Biztos benne, hogy törölni kívánja ezt a műszerfalat? Ez a művelet nem vonható vissza.",
@@ -1800,12 +1828,14 @@
"duplicate_failed": "A vezérlőpult másolása sikertelen",
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
"no_charts_available_description": "Nincsenek diagramok, amelyek hozzáadhatók ehhez az irányítópulthoz. Vagy még nem léteznek diagramok, vagy az összes meglévő diagram már hozzá lett adva. Látogassa meg a Diagramok oldalt új diagramok létrehozásához.",
"no_charts_to_add_message": "Nincsenek hozzáadható diagramok ehhez az irányítópulthoz.",
"no_dashboards_found": "Nem található vezérlőpult.",
"no_data_message": "Nincsenek adatok. Jelenleg nincsenek megjeleníthető információk. Adjon hozzá diagramokat az irányítópult felépítéséhez.",
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Nincsenek visszajelzési rekordjai, amelyekről jelentést tehetne. Állítsa be a visszacsatolási forrásokat, hogy adatokat tápláljon be a rendszerbe.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Visszajelzési források beállítása"
},
"api_keys": {
"add_api_key": "API-kulcs hozzáadása",
@@ -1818,13 +1848,19 @@
"api_key_updated": "API-kulcs frissítve",
"delete_api_key_confirmation": "Az ezt a kulcsot használó bármely alkalmazás többé nem fog tudni hozzáférni a Formbricks adataihoz.",
"duplicate_access": "A kettőzött munkaterület-hozzáférés nem engedélyezett",
"duplicate_directory_access": "A duplikált visszajelzési rekord könyvtár hozzáférése nem engedélyezett",
"feedback_record_directory_access": "Visszajelzési Rekord Könyvtár Hozzáférés",
"no_api_keys_yet": "Még nincs semmilyen API-kulcsa",
"no_env_permissions_found": "Nem találhatók környezetjogosultságok",
"no_directory_permissions_found": "Nem találhatók visszajelzési rekord könyvtár jogosultságok",
"no_workspace_permissions_found": "Nem találhatók munkaterület-jogosultságok",
"organization_access": "Szervezeti hozzáférés",
"organization_access_description": "Olvasási vagy írási jogosultságok kiválasztása a teljes szervezetre vonatkozó erőforrásokhoz.",
"permissions": "Jogosultságok",
"secret": "Titok",
"unable_to_copy_api_key": "Az API kulcs másolása nem lehetséges",
"unable_to_delete_api_key": "Nem lehet törölni az API-kulcsot",
"unknown_directory": "Ismeretlen könyvtár",
"unknown_workspace": "Ismeretlen munkaterület",
"workspace_access": "Munkaterület-hozzáférés"
},
"app-connection": {
@@ -1832,8 +1868,6 @@
"app_connection_description": "Alkalmazás vagy webhely csatlakoztatása a Formbrickshez.",
"cache_update_delay_description": "Ha frissítéseket hajt végre a kérdőíveken, partnereken, műveleteken vagy egyéb adatokon, akkor akár 1 percet is igénybe vehet, mire azok a változtatások megjelennek a Formbricks SDK-t futtató helyi alkalmazásban.",
"cache_update_delay_title": "A változtatások körülbelül 1 perc múlva jelennek meg a gyorsítótárazás miatt",
"environment_id": "Az Ön munkaterület-azonosítója",
"environment_id_description": "Ez az azonosító egyedileg azonosítja ezt a Formbricks munkaterületet.",
"formbricks_sdk_connected": "A Formbricks SDK csatlakoztatva van",
"formbricks_sdk_not_connected": "A Formbricks SDK még nincs csatlakoztatva.",
"formbricks_sdk_not_connected_description": "Adja hozzá a Formbricks SDK-t a webhelyéhez vagy az alkalmazásához, hogy összekapcsolja azt a Formbricks platformmal",
@@ -1842,10 +1876,12 @@
"receiving_data": "Adatok fogadása 💃🕺",
"recheck": "Újraellenőrzés",
"sdk_connection_details": "SDK-kapcsolat részletei",
"sdk_connection_details_description": "Az Ön egyedi munkaterület-azonosítója és SDK kapcsolati URL-címe a Formbricks alkalmazásával való integrációhoz.",
"sdk_connection_details_description": "Az Ön egyedi munkaterület-azonosítója és SDK kapcsolati URL-je a Formbricks alkalmazásával való integrációhoz.",
"setup_alert_description": "Kövesse ezt a léptékenkénti oktatóanyagot, hogy 5 perc alatt csatlakoztassa az alkalmazását vagy a webhelyét.",
"setup_alert_title": "Hogyan kell kapcsolódni",
"webapp_url": "SDK-kapcsolati URL"
"webapp_url": "SDK-kapcsolati URL",
"workspace_id": "Az Ön munkaterület-azonosítója",
"workspace_id_description": "Ez az azonosító egyedileg azonosítja ezt a Formbricks munkaterületet."
},
"connect": {
"congrats": "Gratulálunk!",
@@ -1876,10 +1912,10 @@
"attribute_value_placeholder": "Attribútum értéke",
"attributes_msg_attribute_limit_exceeded": "Nem sikerült létrehozni {count} új attribútumot, mivel túllépte volna a(z) {limit} attribútumosztályból álló legnagyobb korlátot. A meglévő attribútumok sikeresen frissítve lettek.",
"attributes_msg_attribute_type_validation_error": "{error} (a(z) “{key}” attribútum a következő adattípussal rendelkezik: {dataType})",
"attributes_msg_email_already_exists": "Az e-mail-cím már létezik ennél a környezetnél, és nem lett frissítve.",
"attributes_msg_email_already_exists": "Az e-mail cím már létezik ehhez a munkaterülethez, és nem lett frissítve.",
"attributes_msg_email_or_userid_required": "Vagy e-mail-cím, vagy felhasználó-azonosító szükséges. A meglévő értékek megmaradtak.",
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
"attributes_msg_userid_already_exists": "A felhasználói azonosító már létezik ehhez a munkaterülethez, és nem lett frissítve.",
"contact_deleted_successfully": "A partner sikeresen törölve",
"contacts_table_refresh": "Partnerek frissítése",
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
@@ -2140,7 +2176,6 @@
"duplicate_language_or_language_id": "Kettőzött nyelv vagy nyelvazonosító",
"edit_languages": "Nyelvek szerkesztése",
"identifier": "Azonosító (ISO)",
"incomplete_translations": "Befejezetlen fordítások",
"language": "Nyelv",
"language_deleted_successfully": "A nyelv sikeresen törölve",
"languages_updated_successfully": "A nyelvek sikeresen frissítve",
@@ -2150,8 +2185,7 @@
"please_select_a_language": "Válasszon egy nyelvet",
"remove_language": "Nyelv eltávolítása",
"remove_language_from_surveys_to_remove_it_from_workspace": "Távolítsa el a nyelvet ezekből a kérdőívekből, hogy eltávolítsa azt a munkaterületről.",
"search_items": "Elemek keresése",
"translate": "Fordítás"
"search_items": "Elemek keresése"
},
"look": {
"add_background_color": "Háttérszín hozzáadása",
@@ -2365,7 +2399,7 @@
"most_popular": "Legnépszerűbb",
"pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
"pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {{plan}} csomagra fog váltani ekkor: {{date}}.",
"pending_plan_change_description": "Az Ön csomagja {plan} csomagra vált {date} dátummal.",
"pending_plan_change_title": "Ütemezett csomagváltoztatás",
"pending_plan_cta": "Ütemezett",
"per_month": "havonta",
@@ -2524,21 +2558,22 @@
"nav_label": "Visszajelzési könyvtárak",
"no_access": "Nem rendelkezik jogosultsággal a visszajelzési nyilvántartási könyvtárak kezeléséhez.",
"no_connectors": "Még nincsenek csatlakozók társítva ehhez a könyvtárhoz.",
"pause_connectors_confirmation_description": "Ha szünetelteti ezeket a csatlakozókat, nem kerülnek be új rekordok.",
"pause_connectors_confirmation_title": "Szünetelteti a kapcsolódó csatlakozókat?",
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
"show_archived": "Archivált elemek megjelenítése",
"title": "Visszajelzési Nyilvántartási Könyvtárak",
"unarchive": "Archiválás visszavonása"
"unarchive": "Archiválás visszavonása",
"unarchive_workspace_conflict": "A könyvtár nem állítható vissza, mert egy vagy több hozzárendelt munkaterület archiválva van.",
"workspace_access": "Munkaterület-hozzáférés"
},
"general": {
"ai_data_analysis_disabled_for_organization": "Az MI-alapú adatelemzés és adatgazdagítás ki van kapcsolva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_features_not_enabled_for_organization": "Az MI-funkciók nincsenek engedélyezve ennél a szervezetnél.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens funkciói ki vannak kapcsolva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
@@ -2596,7 +2631,9 @@
"security_list_tip_link": "Regisztráljon itt.",
"share_invite_link": "Meghívási hivatkozás megosztása",
"share_this_link_to_let_your_organization_member_join_your_organization": "Ossza meg ezt a hivatkozást, hogy szervezetének tagja csatlakozhasson a szervezetéhez:",
"test_email_sent_successfully": "A teszt e-mail sikeresen elküldve"
"test_email_sent_successfully": "A teszt e-mail sikeresen elküldve",
"unlock_ai_features_description": "A mesterséges intelligencia által támogatott fordítások, intelligens eszközök és adatelemzés a magasabb csomagokban érhetők el. Frissítsen, hogy mesterséges intelligenciával turbózza fel kérdőíveit.",
"unlock_ai_features_with_a_higher_plan": "Oldja fel a mesterséges intelligencia funkcióit egy magasabb csomaggal"
},
"notifications": {
"auto_subscribe_to_new_surveys": "Automatikus feliratkozás az új kérdőívekre",
@@ -2694,17 +2731,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Mindent beállított! Ideje létrehozni az első kérdőívet",
"alphabetical": "Ábécé-sorrend",
"copy_survey": "Kérdőív másolása",
"copy_survey_description": "Másolja át ezt a felmérést egy másik munkaterületre",
"copy_survey_error": "Nem sikerült másolni a kérdőívet",
"copy_survey_link_to_clipboard": "Kérdőív hivatkozásának másolása a vágólapra",
"copy_survey_no_workspaces": "Nincsenek más munkaterületek, amelyekre átmásolhatná ezt a felmérést.",
"copy_survey_partially_success": "{success} kérdőív sikeresen másolva, {error} sikertelen.",
"copy_survey_success": "A kérdőív sikeresen másolva",
"delete_survey_and_responses_warning": "Biztosan törölni szeretné ezt a kérdőívet és az összes válaszát?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Válassza ki a kérdőív alapértelmezett nyelvét:",
"2_activate_translation_for_specific_languages": "2. Aktiválja a fordítást bizonyos nyelvekhez:",
"activate_translations": "Fordítások aktiválása",
"add": "Hozzáadás +",
"add_a_delay_or_auto_close_the_survey": "Késleltetés hozzáadása vagy a kérdőív automatikus lezárása",
"add_a_four_digit_pin": "Négy számjegyű PIN-kód hozzáadása",
@@ -2741,6 +2771,18 @@
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_the_theme_in_the": "A téma beállítása ebben:",
"ai_data_analysis_disabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
"ai_features_not_enabled": "Az AI funkciók nincsenek engedélyezve ezen szervezet számára.",
"ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.",
"ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.",
"ai_translate": "Fordítás mesterséges intelligenciával",
"ai_translating": "AI fordítás folyamatban... Kérjük, tartsa nyitva ezt az ablakot.",
"ai_translation_all_fields_populated": "Minden mező már le van fordítva",
"ai_translation_complete": "A mesterséges intelligencia által végzett fordítás befejeződött",
"ai_translation_failed": "A fordítás sikertelen volt",
"ai_translation_instance_not_configured": "Az AI nincs konfigurálva ezen az instance-on. Vegye fel a kapcsolatot az adminisztrátorral.",
"ai_translation_not_available": "A mesterséges intelligencia által támogatott fordítás nem érhető el az Ön jelenlegi csomagjában. Frissítsen ennek a funkciónak a feloldásához.",
"ai_translation_not_enabled": "Az AI intelligens eszközök le vannak tiltva ennél a szervezetnél. Engedélyezze őket a szervezeti beállításokban.",
"all_are_true": "az összes igaz",
"all_other_answers_will_continue_to": "Az összes többi válasz továbbra is",
"allow_multi_select": "Több választás engedélyezése",
@@ -2754,7 +2796,7 @@
"audience": "Közönség",
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés egyetlen kérdést tartalmazó blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve amikor az \"Egyéb\" opció van kiválasztva.",
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
@@ -2800,6 +2842,7 @@
"caution_text": "A változtatások következetlenségekhez vezetnek",
"change_anyway": "Változtatás mindenképp",
"change_background": "Háttér megváltoztatása",
"change_default": "Alapértelmezett módosítása",
"change_question_type": "Kérdés típusának megváltoztatása",
"change_survey_type": "A kérdőív típusának megváltoztatása befolyásolja a meglévő hozzáférést",
"change_the_background_to_a_color_image_or_animation": "A háttér megváltoztatása színre, képre vagy animációra.",
@@ -2811,7 +2854,11 @@
"choose_the_first_question_on_your_block": "Az első kérdés kiválasztása a blokkban",
"choose_where_to_run_the_survey": "Annak kiválasztása, hogy hol fusson a kérdőív.",
"city": "Város",
"clear_close_on_date": "Szüneteltetési dátum törlése",
"clear_publish_on_date": "Közzétételi dátum törlése",
"close_survey_on_date": "Szüneteltetés dátuma",
"close_survey_on_response_limit": "Kérdőív lezárása a válaszkorlátnál",
"code": "Kód",
"color": "Szín",
"column_used_in_logic_error": "Ez az oszlop használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"columns": "Oszlopok",
@@ -2836,6 +2883,7 @@
"customize_survey_logo": "A kérdőív logójának személyre szabása",
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
"default_language": "Alapértelmezett nyelv",
"delete_anyways": "Törlés mindenképp",
"delete_block": "Blokk törlése",
"delete_choice": "Választási lehetőség törlése",
@@ -2855,7 +2903,6 @@
"duplicate_question": "Kérdés kettőzése",
"edit_link": "Hivatkozás szerkesztése",
"edit_recall": "Visszahívás szerkesztése",
"edit_translations": "{lang} fordítások szerkesztése",
"element_not_found": "A kérdés nem található",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Lehetővé tétel a válaszadóknak, hogy bármikor nyelvet váltsanak. Legalább 2 aktív nyelvet igényel.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A szemét elleni védekezés a reCAPTCHA v3-at használja a kéretlen válaszok kiszűréséhez.",
@@ -2992,10 +3039,12 @@
"long_answer_toggle_description": "Lehetővé tétel a válaszadóknak, hogy hosszabb, többsoros válaszokat írjanak.",
"lower_label": "Alsó címke",
"manage_languages": "Nyelvek kezelése",
"manage_translations": "Fordítások kezelése",
"matrix_all_fields": "Összes mező",
"matrix_rows": "Sorok",
"max_file_size": "Legnagyobb fájlméret",
"max_file_size_limit_is": "A legnagyobb fájlméretkorlát",
"missing_first": "Hiányzók először",
"move_question_to_block": "Kérdés áthelyezése egy blokkba",
"multiply": "Szorzás *",
"needed_for_self_hosted_cal_com_instance": "Saját üzemeltetésű Cal.com-példányhoz szükséges",
@@ -3003,7 +3052,7 @@
"next_button_label": "A „Következő” gomb címkéje",
"no_hidden_fields_yet_add_first_one_below": "Még nincsenek rejtett mezők. Adja hozzá az elsőt lent.",
"no_images_found_for": "Nem találhatók képek a(z) „{query}” lekérdezéshez",
"no_languages_found_add_first_one_to_get_started": "Nem találhatók nyelvek. Adja hozzá az elsőt a kezdéshez.",
"no_languages_found_add_first_one_to_get_started": "Nem található felmérési nyelv ebben a munkaterületen. Kérem, adjon hozzá egyet a kezdéshez.",
"no_option_found": "Nem található lehetőség",
"no_recall_items_found": "Nem találhatók visszahívási elemek",
"no_variables_yet_add_first_one_below": "Még nincsenek változók. Adja hozzá az elsőt lent.",
@@ -3030,12 +3079,14 @@
"please_enter_a_valid_url": "Adjon meg egy érvényes URL-t (például https://example.com)",
"please_set_a_survey_trigger": "Állítson be kérdőív-aktiválót",
"please_specify": "Adja meg",
"present_your_survey_in_multiple_languages": "Mutassa be felmérését több nyelven",
"prevent_double_submission": "Kettős beküldés megakadályozása",
"prevent_double_submission_description": "E-mail-címenként csak 1 válasz engedélyezése",
"progress_saved": "Folyamat elmentve",
"protect_survey_with_pin": "Kérdőív megvédése PIN-kóddal",
"protect_survey_with_pin_description": "Csak a PIN-kóddal rendelkező felhasználók férhetnek hozzá a kérdőívhez.",
"publish": "Közzététel",
"publish_survey_on_date": "Közzététel dátuma",
"question": "Kérdés",
"question_deleted": "Kérdés törölve.",
"question_duplicated": "Kérdés megkettőzve.",
@@ -3106,6 +3157,7 @@
"rows": "Sorok",
"save_and_close": "Mentés és bezárás",
"scale": "Méretezés",
"schedule_survey": "Felmérés ütemezése",
"search_for_images": "Képek keresése",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "másodperccel az aktiváló után a kérdőív le lesz zárva, ha nincs válasz",
"seconds_before_showing_the_survey": "másodperc a kérdőív megjelenítése előtt.",
@@ -3121,6 +3173,7 @@
"seven_points": "7 pont",
"show_block_settings": "Blokkbeállítások megjelenítése",
"show_button": "Gomb megjelenítése",
"show_in_order": "Sorrendben megjelenítés",
"show_language_switch": "Nyelvválasztó megjelenítése",
"show_multiple_times": "Megjelenítés korlátozott számú alkalommal",
"show_only_once": "Megjelenítés csak egyszer",
@@ -3152,7 +3205,8 @@
"survey_preview": "Kérdőív előnézete 👀",
"survey_styling": "Kérdőív stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
"survey_will_be_closed_at_midnight_cet": "A felmérés lezárásra kerül {time} időpontban a {timeZone} időzónában a kiválasztott napon",
"survey_will_be_published_at_midnight_cet": "A felmérés közzétételre kerül {time} időpontban a {timeZone} időzónában a kiválasztott napon",
"target_block_not_found": "A célblokk nem található",
"targeted": "Célzott",
"ten_points": "10 pont",
@@ -3160,9 +3214,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Megjelenítés egyetlen alkalommal, még akkor is, ha nem válaszolnak.",
"then": "Azután",
"this_action_will_remove_all_the_translations_from_this_survey": "Ez a művelet eltávolítja az összes fordítást ebből a kérdőívből.",
"this_will_remove_the_language_and_all_its_translations": "Ez eltávolítja ezt a nyelvet és az összes fordítását ebből a felmérésből. Ez a művelet nem vonható vissza.",
"three_points": "3 pont",
"times": "alkalom",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Ahhoz, hogy következetesen megtartsa az elhelyezést az összes kérdőívnél, az alábbiakat teheti:",
"translated": "Lefordítva",
"trigger_survey_when_one_of_the_actions_is_fired": "A kérdőív aktiválása, ha a műveletek egyikét elindítják…",
"try_lollipop_or_mountain": "A „nyalóka” vagy „hegy” kipróbálása…",
"type_field_id": "Mezőazonosító beírása",
@@ -3237,6 +3293,7 @@
"verify_email_before_submission_description": "Csak valódi e-mail-címmel rendelkező személyek válaszolhassanak.",
"visibility_and_recontact": "Láthatóság és újbóli kapcsolatfelvétel",
"visibility_and_recontact_description": "Annak vezérlése, hogy ez a kérdőív mikor jelenhet meg és milyen gyakran jelenhet meg újra.",
"visible": "Látható",
"wait": "Várakozás",
"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",
"waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)",
@@ -3438,6 +3495,8 @@
"configure_alerts": "Riasztások beállítása",
"congrats": "Gratulálunk! A kérdőíve élő.",
"connect_your_website_or_app_with_formbricks_to_get_started": "A webhelye vagy alkalmazása csatlakoztatása a Formbrickshez a kezdéshez.",
"csat_satisfied": "CSAT: {percentage}% elégedett",
"csat_satisfied_tooltip": "A válaszadók {percentage}%-a 4-es vagy 5-ös értékelést adott (CSAT).",
"current_count": "Jelenlegi darabszám",
"custom_range": "Egyéni tartomány…",
"delete_all_existing_responses_and_displays": "Az összes meglévő válasz és megjelenítés törlése",
@@ -3445,7 +3504,7 @@
"downloading_qr_code": "QR-kód letöltése",
"drop_offs": "Megszakítások",
"drop_offs_tooltip": "A kérdőív elkezdési, de be nem fejezési alkalmainak száma.",
"failed_to_copy_link": "Nem sikerült a hivatkozás másolása",
"effort_score": "Erőfeszítési Pontszám",
"filter_added_successfully": "A szűrő sikeresen hozzáadva",
"filter_updated_successfully": "A szűrő sikeresen frissítve",
"filtered_responses_csv": "Szűrt válaszok (CSV)",
@@ -3497,6 +3556,7 @@
"limit": "Korlát",
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
"no_responses_found": "Nem találhatók válaszok",
"nps_promoters_tooltip": "A válaszadók {percentage}%-a 9-es vagy 10-es értékelést adott (NPS promoters).",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",
"promoters": "Népszerűsítők",
@@ -3509,7 +3569,6 @@
"quotas_completed_tooltip": "A válaszadók által teljesített kvóták száma.",
"reset_survey": "Kérdőív visszaállítása",
"reset_survey_warning": "Egy kérdőív visszaállítása eltávolítja a kérdőívhez hozzárendelt összes választ és megjelenítést. Ezt nem lehet visszavonni.",
"satisfied": "Elégedett",
"selected_responses_csv": "Kijelölt válaszok (CSV)",
"selected_responses_excel": "Kijelölt válaszok (Excel)",
"setup_integrations": "Integrációk beállítása",
@@ -3519,6 +3578,7 @@
"starts_tooltip": "A kérdőív elkezdési alkalmainak száma.",
"survey_reset_successfully": "A kérdőív sikeresen visszaállítva. {responseCount} válasz és {displayCount} megjelenítés lett törölve.",
"survey_results": "{surveyName} eredményei",
"survey_scheduled_successfully": "A felmérés sikeresen ütemezve lett",
"this_month": "Ez a hónap",
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
@@ -3533,7 +3593,6 @@
},
"survey_deleted_successfully": "A kérdőív sikeresen törölve",
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve",
"survey_duplication_error": "Nem sikerült megkettőzni a kérdőívet.",
"templates": {
"all_channels": "Összes csatorna",
"all_industries": "Összes iparág",
@@ -3568,16 +3627,21 @@
"team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez."
},
"unify": {
"add_feedback_record": "Visszajelzés hozzáadása",
"add_feedback_record_description": "Készítsen visszajelzési rekordot manuálisan.",
"add_feedback_source": "Visszajelzési forrás hozzáadása",
"add_source": "Forrás hozzáadása",
"allowed_values": "Engedélyezett értékek: {values}",
"api_ingestion": "API betöltés",
"api_ingestion_manage_api_keys": "API kulcsok kezelése",
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
"auto_generated": "Automatikusan generált",
"change_file": "Fájl módosítása",
"click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez",
"click_to_upload": "Kattintson a feltöltéshez",
"collected_at": "Gyűjtve",
"configure_import": "Importálás konfigurálása",
"configure_mapping": "Leképezés konfigurálása",
"connection": "Kapcsolat",
"connector_created_successfully": "Csatlakozó sikeresen létrehozva",
"connector_deleted_successfully": "Csatlakozó sikeresen törölve",
"connector_duplicated_successfully": "Csatlakozó sikeresen duplikálva",
@@ -3596,9 +3660,12 @@
"csv_import_duplicate_warning": "Az adatok kétszeri importálása duplikált rekordokat hoz létre.",
"csv_inconsistent_columns": "A(z) {row}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.",
"csv_max_records": "Maximum {max} rekord engedélyezett.",
"custom_source_type": "Egyéni forrástípus",
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
"default_connector_name_csv": "CSV importálás",
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
"deselect_all": "Összes kijelölés törlése",
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
"drop_a_field_here": "Húzz ide egy mezőt",
"drop_field_or": "Húzz ide egy mezőt vagy",
"edit_csv_mapping": "CSV leképezés szerkesztése",
@@ -3608,47 +3675,64 @@
"enum": "felsorolás",
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
"feedback_date": "Aktuális dátum",
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
"feedback_record_details": "A visszajelzési rekord részletei",
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
"feedback_record_directory": "Visszajelzési Rekord Könyvtár",
"feedback_record_fields": "Visszajelzési rekord mezők",
"feedback_record_mcp": "Visszajelzési rekord MCP",
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
"feedback_records": "Visszajelzési rekordok",
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
"feedback_sources": "Visszajelzési források",
"feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
"feedback_sources_directory_access_single": "Az ebből a forrásból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
"feedback_sources_settings_description": "Összes visszajelzési forrás csatlakoztatása és kezelése ezen munkaterület számára.",
"field_group_id": "Mezőcsoport azonosítója",
"field_group_label": "Mezőcsoport címke",
"field_id": "Mezőazonosító",
"field_label": "Mező címke",
"field_type": "Mező típus",
"formbricks_surveys": "Formbricks kérdőívek",
"frd_cannot_be_changed": "A visszajelzési könyvtár a létrehozás után nem módosítható.",
"go_to_feedback_record_directories": "Ugrás a könyvtárbeállításokhoz",
"historical_import_complete": "Importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva (nincs adat)",
"import_csv_data": "Visszajelzés importálása",
"import_feedback": "Visszajelzés importálása",
"import_historical_responses": "Korábbi válaszok importálása",
"import_historical_responses_description": "Meglévő válaszok importálása ebből a felmérésből most.",
"import_rows": "{count} sor importálása",
"import_via_source_name": "Importálás a következőn keresztül: \"{sourceName}\"",
"importing_data": "Adatok importálása...",
"importing_historical_data": "Történeti adatok importálása...",
"invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban",
"invalid_values_found": "Talált értékek: {values} (sorok: {rows}) {extra}",
"load_sample_csv": "Minta CSV betöltése",
"n_supported_questions": "{count} támogatott kérdés",
"manage_directories": "Könyvtárak kezelése",
"manage_feedback_sources": "Visszajelzési források kezelése",
"metadata": "Metaadatok",
"metadata_key": "Metaadatkulcs",
"metadata_read_only_entries": "Csak olvasható metaadatértékek (nem karakterlánc)",
"metadata_value": "A metaadat értéke",
"missing_feedback_source_title": "Hiányzik egy visszajelzési forrás?",
"no_feedback_record_directory_available": "Ehhez a munkaterülethez nem tartozik visszajelzési rekord könyvtár. Először hozzon létre vagy rendeljen hozzá egyet.",
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
"no_surveys_found": "Nem találhatók kérdőívek ebben a környezetben",
"optional": "Elhagyható",
"or_drag_and_drop": "vagy húzd ide",
"question_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
"question_type_not_supported": "Ez a kérdéstípus nem támogatott",
"questions_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
"records_will_go_to": "A rekordok ide kerülnek",
"refresh_feedback_records": "Visszajelzési rekordok frissítése",
"refreshing_feedback_records": "Visszajelzési rekordok frissítése...",
"request_feedback_source": "Forrásintegráció kérése",
"required": "Kötelező",
"save_changes": "Változtatások mentése",
"select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez",
"select_a_value": "Válassz egy értéket...",
"select_all": "Összes kiválasztása",
"select_feedback_record_directory": "Válasszon egy könyvtárat",
"select_feedback_record_source_type": "Válassza ki a forrás típusát",
"select_questions": "Kérdések kiválasztása",
"select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.",
"select_source_type_prompt": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát:",
"select_survey": "Kérdőív kiválasztása",
"select_survey_and_questions": "Kérdőív és kérdések kiválasztása",
"select_survey_questions_description": "Válassza ki, mely kérdőívkérdések hozzanak létre visszajelzési rekordokat.",
@@ -3658,27 +3742,31 @@
"showing_rows": "3 megjelenítve {count} sorból",
"source": "forrás",
"source_connect_csv_description": "Visszajelzések importálása CSV fájlokból",
"source_connect_feedback_record_mcp_description": "Visszajelzési rekordok küldése az MCP integráción keresztül.",
"source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
"source_fields": "Forrásmezők",
"source_id": "Forrásazonosító",
"source_name": "Forrásnév",
"source_type": "Forrás típus",
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
"sources": "Források",
"status_active": "Folyamatban",
"status_completed": "Befejezve",
"status_draft": "Piszkozat",
"status_error": "Hiba",
"status_live_sync": "Élő szinkronizálás",
"status_paused": "Szüneteltetve",
"status_ready": "Kész",
"submission_id": "Beküldés azonosítója",
"survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket",
"survey_import_line": "{surveyName}: {responseCount} válasz × {questionCount} kérdés = {total} visszajelzési rekord",
"total_feedback_records": "Összesen: {checked} / {total} visszajelzési rekord kiválasztva {surveyCount} felmérésből",
"topics_and_subtopics": "Témák és altémák",
"unify_feedback": "Visszajelzések egyesítése",
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
"updated_at": "Frissítve",
"upload_csv_data_description": "Tölts fel egy CSV fájlt a visszajelzési adatok importálásához.",
"upload_csv_file": "CSV fájl feltöltése",
"user_identifier": "Felhasználó",
"value": "Érték"
"value": "Érték",
"value_boolean": "Érték (logikai)",
"value_date": "Érték (dátum)",
"value_number": "Érték (szám)",
"value_text": "Érték (szöveg)"
},
"xm-templates": {
"ces": "CES",

Some files were not shown because too many files have changed in this diff Show More