diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 3e41b78f65dc..1597e0be0611 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -100,13 +100,9 @@ jobs:
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash
- # Build the workspace packages the tests import (@formbricks/logger, cache, types, email, …) so
- # vite can resolve their package.json `exports` → dist. Deps only (`^...`), not the Next app.
- - name: Build workspace package dependencies
- if: steps.harness.outputs.present == 'true'
- run: pnpm build --filter=@formbricks/web^...
- shell: bash
-
+ # The .env must exist before the package build: @formbricks/database's prisma.config.ts
+ # resolves DATABASE_URL at config load (dotenv from the repo root), so `prisma generate`
+ # fails without it. Same order as e2e.yml.
- name: Create .env
if: steps.harness.outputs.present == 'true'
run: pnpm dev:setup
@@ -122,6 +118,13 @@ jobs:
sed -i "s|DATABASE_URL=.*|DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks_ba_test?schema=public|" .env
shell: bash
+ # Build the workspace packages the tests import (@formbricks/logger, cache, types, email, …) so
+ # vite can resolve their package.json `exports` → dist. Deps only (`^...`), not the Next app.
+ - name: Build workspace package dependencies
+ if: steps.harness.outputs.present == 'true'
+ run: pnpm build --filter=@formbricks/web^...
+ shell: bash
+
- name: Create the test database
if: steps.harness.outputs.present == 'true'
run: psql "postgresql://postgres:postgres@localhost:5432/postgres" -v ON_ERROR_STOP=1 -c 'CREATE DATABASE formbricks_ba_test;'
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 8fe26f170d36..9f16dde98538 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -59,8 +59,12 @@ RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install --ignore-scripts --frozen-lockfile
-# Build the database package first
-RUN pnpm build --filter=@formbricks/database
+# Build the database package first. `pnpm generate` loads prisma.config.ts, which
+# resolves DATABASE_URL via Prisma's env() helper and throws when it is unset. Mount
+# the secret and run through the secret reader so a build-time fallback is supplied
+# (mirrors the web build below); prisma generate never connects to the database.
+RUN --mount=type=secret,id=database_url \
+ /tmp/read-secrets.sh pnpm build --filter=@formbricks/database
# Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers
@@ -118,8 +122,12 @@ RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
-COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
-RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
+COPY --from=installer /app/packages/database/schema ./packages/database/schema
+RUN chown -R nextjs:nextjs ./packages/database/schema && chmod -R 755 ./packages/database/schema
+
+# The migration runner invokes `prisma migrate deploy --config packages/database/prisma.config.ts`
+COPY --from=installer /app/packages/database/prisma.config.ts ./packages/database/prisma.config.ts
+RUN chown nextjs:nextjs ./packages/database/prisma.config.ts && chmod 644 ./packages/database/prisma.config.ts
COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
index f894cde1d592..4a9c7252928d 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
@@ -14,6 +14,7 @@ import {
RocketIcon,
SettingsIcon,
UserIcon,
+ WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -181,6 +182,20 @@ export const MainNavigation = ({
},
],
},
+ {
+ id: "act",
+ name: t("common.act"),
+ items: [
+ {
+ name: t("common.workflows"),
+ href: `/workspaces/${workspace.id}/workflows`,
+ icon: WorkflowIcon,
+ isActive: pathname?.startsWith(`/workspaces/${workspace.id}/workflows`),
+ isHidden: false,
+ disabled: isMembershipPending || isBilling,
+ },
+ ],
+ },
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/layout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/layout.tsx
new file mode 100644
index 000000000000..20a0bc2be94d
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/layout.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from "react";
+import { getTranslate } from "@/lingodotdev/server";
+import { WorkflowsUpgradePrompt } from "@/modules/ee/workflows/components/workflows-upgrade-prompt";
+import { WorkspaceWorkflowsHeaderCta } from "@/modules/ee/workflows/components/workspace-workflows-header-cta";
+import { WorkspaceWorkflowsSecondaryNavigation } from "@/modules/ee/workflows/components/workspace-workflows-secondary-navigation";
+import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
+import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
+import { PageHeader } from "@/modules/ui/components/page-header";
+import { WorkflowsQueryClientProvider } from "./query-client-provider";
+
+const WorkspaceWorkflowsLayout = async (
+ props: Readonly<{ params: Promise<{ workspaceId: string }>; children: ReactNode }>
+) => {
+ const params = await props.params;
+ const { isReadOnly, isWorkflowsEnabled, organizationId } = await getWorkflowsRouteAuth(params.workspaceId);
+ const t = await getTranslate();
+
+ if (!isWorkflowsEnabled) {
+ // Not entitled: keep the page skeleton (title + tabs, both tabs land on this prompt) but render
+ // the upsell instead of the feature. No CTA and no children — the client pages fetch through
+ // the now-403 workflows API and must not mount.
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ }>
+
+
+ {props.children}
+
+
+ );
+};
+
+export default WorkspaceWorkflowsLayout;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/loading.tsx
new file mode 100644
index 000000000000..7e27b8cb81f0
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/loading.tsx
@@ -0,0 +1 @@
+export { WorkflowsListBodyLoading as default } from "@/modules/ee/workflows/loading";
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/page.tsx
new file mode 100644
index 000000000000..ab907c3849ea
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/page.tsx
@@ -0,0 +1,25 @@
+import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
+import { WorkflowsListPage } from "@/modules/ee/workflows/pages/workflows-list-page";
+
+const WORKFLOWS_PER_PAGE = 12;
+
+const WorkflowsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
+ const params = await props.params;
+ const { isReadOnly, isWorkflowsEnabled } = await getWorkflowsRouteAuth(params.workspaceId);
+
+ // Pages render in parallel with the gating layout; contribute nothing when not entitled so the
+ // client list page (which fetches through the now-403 workflows API) never mounts.
+ if (!isWorkflowsEnabled) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default WorkflowsPage;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/query-client-provider.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/query-client-provider.tsx
new file mode 100644
index 000000000000..76f3bcc8a4a6
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/query-client-provider.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { type ReactNode, useState } from "react";
+
+interface WorkflowsQueryClientProviderProps {
+ children: ReactNode;
+}
+
+export const WorkflowsQueryClientProvider = ({ children }: Readonly) => {
+ const [queryClient] = useState(() => new QueryClient());
+
+ return {children};
+};
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/loading.tsx
new file mode 100644
index 000000000000..5ac400460316
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/loading.tsx
@@ -0,0 +1 @@
+export { WorkspaceWorkflowRunsBodyLoading as default } from "@/modules/ee/workflows/loading";
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/page.tsx
new file mode 100644
index 000000000000..988893053eca
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/(list)/runs/page.tsx
@@ -0,0 +1,8 @@
+import { WorkspaceWorkflowRunsPage } from "@/modules/ee/workflows/pages/workspace-workflow-runs-page";
+
+const WorkflowRunsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
+ const { workspaceId } = await props.params;
+ return ;
+};
+
+export default WorkflowRunsPage;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/layout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/layout.tsx
new file mode 100644
index 000000000000..3756672b6869
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/layout.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from "react";
+import { getTranslate } from "@/lingodotdev/server";
+import { WorkflowEditorProvider } from "@/modules/ee/workflows/components/workflow-editor-provider";
+import { WorkflowHeaderCta } from "@/modules/ee/workflows/components/workflow-header-cta";
+import { WorkflowPageTitle } from "@/modules/ee/workflows/components/workflow-page-title";
+import { WorkflowSecondaryNavigation } from "@/modules/ee/workflows/components/workflow-secondary-navigation";
+import { WorkflowsUpgradePrompt } from "@/modules/ee/workflows/components/workflows-upgrade-prompt";
+import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
+import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
+import { PageHeader } from "@/modules/ui/components/page-header";
+
+const WorkflowDetailLayout = async (
+ props: Readonly<{
+ params: Promise<{ workspaceId: string; workflowId: string }>;
+ children: ReactNode;
+ }>
+) => {
+ const params = await props.params;
+ const { isReadOnly, isWorkflowsEnabled, organizationId } = await getWorkflowsRouteAuth(params.workspaceId);
+
+ if (!isWorkflowsEnabled) {
+ // Not entitled: plain title instead of WorkflowPageTitle/WorkflowHeaderCta/WorkflowEditorProvider —
+ // those fetch the workflow through the now-403 API and must not render broken states. The tabs are
+ // static links; both land on this prompt, matching the list layout.
+ const t = await getTranslate();
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* The editor fills the shell's scroll area instead of scrolling inside it: `h-full` pins this
+ wrapper to that container's height and the flex column hands whatever is left below the
+ header to the canvas + inspector row, which scroll internally. Height is therefore derived,
+ never assumed — the alternative is subtracting a hardcoded guess at the chrome above. */}
+
+ }
+ cta={}>
+
+
+ {props.children}
+
+
+ );
+};
+
+export default WorkflowDetailLayout;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/loading.tsx
new file mode 100644
index 000000000000..e7a087d571ed
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/loading.tsx
@@ -0,0 +1 @@
+export { WorkflowBuilderBodyLoading as default } from "@/modules/ee/workflows/loading";
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/page.tsx
new file mode 100644
index 000000000000..ebe9630c789e
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/page.tsx
@@ -0,0 +1,34 @@
+import { getWorkflowsRouteAuth } from "@/modules/ee/workflows/lib/auth";
+import { getWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/lib/email-authoring-context";
+import { WorkflowBuilderPage } from "@/modules/ee/workflows/pages/workflow-builder-page";
+
+const WorkflowPage = async (
+ props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
+) => {
+ const params = await props.params;
+ const { isReadOnly, isWorkflowsEnabled } = await getWorkflowsRouteAuth(params.workspaceId);
+
+ // Pages render in parallel with the gating layout; skip the server-side context resolution and
+ // contribute nothing when not entitled so the builder never mounts against the now-403 API.
+ if (!isWorkflowsEnabled) {
+ return null;
+ }
+
+ // Resolve the bound survey + team/sender context server-side so the send_email inspector renders
+ // Follow-Ups-parity controls (recall body, recipient options) from fully-formed props.
+ const emailAuthoringContext = await getWorkflowEmailAuthoringContext({
+ workflowId: params.workflowId,
+ workspaceId: params.workspaceId,
+ });
+
+ return (
+
+ );
+};
+
+export default WorkflowPage;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/loading.tsx
new file mode 100644
index 000000000000..64d1d47aa183
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/loading.tsx
@@ -0,0 +1 @@
+export { WorkflowRunsBodyLoading as default } from "@/modules/ee/workflows/loading";
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/page.tsx
new file mode 100644
index 000000000000..c730bd397102
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/[workflowId]/(detail)/runs/page.tsx
@@ -0,0 +1,10 @@
+import { WorkflowRunsPage } from "@/modules/ee/workflows/pages/workflow-runs-page";
+
+const WorkflowRuns = async (
+ props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
+) => {
+ const { workspaceId, workflowId } = await props.params;
+ return ;
+};
+
+export default WorkflowRuns;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/layout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/layout.tsx
new file mode 100644
index 000000000000..f6c8fa4a6213
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/layout.tsx
@@ -0,0 +1,8 @@
+import type { ReactNode } from "react";
+import { WorkflowsQueryClientProvider } from "./query-client-provider";
+
+const WorkflowsLayout = ({ children }: { children: ReactNode }) => {
+ return {children};
+};
+
+export default WorkflowsLayout;
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/workflows/query-client-provider.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/query-client-provider.tsx
new file mode 100644
index 000000000000..bf6f7074c683
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/workflows/query-client-provider.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { type ReactNode, useState } from "react";
+
+export const WorkflowsQueryClientProvider = ({ children }: { children: ReactNode }) => {
+ const [queryClient] = useState(() => new QueryClient());
+
+ return {children};
+};
diff --git a/apps/web/app/.well-known/oauth-protected-resource/route.test.ts b/apps/web/app/.well-known/oauth-protected-resource/route.test.ts
index 20163aaddc8c..a139b2b6aa8e 100644
--- a/apps/web/app/.well-known/oauth-protected-resource/route.test.ts
+++ b/apps/web/app/.well-known/oauth-protected-resource/route.test.ts
@@ -46,6 +46,8 @@ describe("OAuth protected resource metadata", () => {
scopes_supported: [
"surveys:read",
"surveys:write",
+ "workflows:read",
+ "workflows:write",
"feedbackRecords:read",
"feedbackRecords:write",
"offline_access",
diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts
index d0e5c5582c89..52d8cbbee8d5 100644
--- a/apps/web/app/api/mcp/route.test.ts
+++ b/apps/web/app/api/mcp/route.test.ts
@@ -1,6 +1,7 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ApiKeyPermission } from "@formbricks/database/prisma";
+import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit";
import {
createdResponse,
problemBadRequest,
@@ -45,7 +46,14 @@ vi.mock("@formbricks/database", () => ({
vi.mock("@/modules/auth/lib/oauth-urls", () => ({
// Must mirror the real MCP_RESOURCE_SCOPES: the route's minimum-scope gate and its WWW-Authenticate
// challenge are both derived from this list, so a short mock would test a world production doesn't have.
- MCP_RESOURCE_SCOPES: ["surveys:read", "surveys:write", "feedbackRecords:read", "feedbackRecords:write"],
+ MCP_RESOURCE_SCOPES: [
+ "surveys:read",
+ "surveys:write",
+ "workflows:read",
+ "workflows:write",
+ "feedbackRecords:read",
+ "feedbackRecords:write",
+ ],
getAuthIssuerUrl: () => "http://localhost/api/auth",
getMcpOrigin: () => "http://localhost",
getMcpProtectedResourceMetadataUrl: () => "http://localhost/.well-known/oauth-protected-resource/api/mcp",
@@ -163,7 +171,7 @@ describe("POST /api/mcp", () => {
expect(response.status).toBe(401);
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
expect(response.headers.get("WWW-Authenticate")).toBe(
- 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"'
+ 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"'
);
expect(applyIPRateLimit).toHaveBeenCalled();
});
@@ -214,6 +222,19 @@ describe("POST /api/mcp", () => {
"validate_survey",
"patch_survey",
"delete_survey",
+ "list_workflows",
+ "get_workflow",
+ "list_workflow_runs",
+ "get_workflow_run",
+ "test_workflow",
+ "create_workflow",
+ "patch_workflow",
+ "duplicate_workflow",
+ "delete_workflow",
+ "enable_workflow",
+ "disable_workflow",
+ "archive_workflow",
+ "unarchive_workflow",
"list_workspaces",
"list_feedback_datasets",
"list_feedback_records",
@@ -424,7 +445,7 @@ describe("POST /api/mcp", () => {
expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled();
expect(applyIPRateLimit).toHaveBeenCalled();
expect(response.headers.get("WWW-Authenticate")).toBe(
- 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"'
+ 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"'
);
});
@@ -469,6 +490,53 @@ describe("POST /api/mcp", () => {
});
});
+ test("blocks workflow write tools for tokens without workflows:write", async () => {
+ // A write-capable user whose OAuth token was only granted read scopes (surveys:read + workflows:read)
+ // must not be able to reach a workflow mutation — the ENG-1967 token-scope boundary.
+ verifyAccessTokenMock.mockResolvedValueOnce({
+ sub: "user_1",
+ email: "person@example.com",
+ scope: "openid profile email surveys:read workflows:read",
+ exp: Math.floor(Date.now() / 1000) + 900,
+ azp: "client_wf_read_only",
+ });
+
+ const response = await POST(
+ createMcpRequest(
+ {
+ jsonrpc: "2.0",
+ id: 10,
+ method: "tools/call",
+ params: {
+ name: "delete_workflow",
+ arguments: {
+ workflowId: "wf1234567890123456789012ab",
+ },
+ },
+ },
+ {
+ authorization: "Bearer eyJhbGciOiJFZERTQSJ9.wfreadonly.signature",
+ "x-api-key": "",
+ "x-request-id": "req_wf_read_only",
+ }
+ )
+ );
+
+ expect(response.status).toBe(200);
+ const message = await readMcpResponse(response);
+ expect(message.result.isError).toBe(true);
+ expect(message.result.structuredContent.error).toMatchObject({
+ status: 403,
+ code: "forbidden",
+ detail: "OAuth token does not include the required MCP scope",
+ requestId: "req_wf_read_only",
+ });
+ // The scope gate must fire BEFORE any mutation side effect: no audit log is built or queued for a
+ // request that never reaches the workflow handler.
+ expect(buildV3AuditLog).not.toHaveBeenCalled();
+ expect(queueV3AuditLog).not.toHaveBeenCalled();
+ });
+
test("calls create_survey through the MCP route", async () => {
vi.mocked(createV3SurveyResponseFromRawInput).mockResolvedValue(
createdResponse(
diff --git a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.test.ts b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.test.ts
index 5d205577b589..247dbc7c75c4 100644
--- a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.test.ts
+++ b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.test.ts
@@ -5,14 +5,17 @@ import {
getActiveTaxonomyTree,
getTaxonomyRun,
listTaxonomyFields,
+ listTaxonomyNodeRecordCounts,
listTaxonomyNodeRecords,
listTaxonomyRuns,
removeTaxonomyNode,
renameTaxonomyNode,
} from "@/modules/hub/service";
import type { FeedbackRecordData, TaxonomyNode, TaxonomyRun } from "@/modules/hub/types";
+import { NO_CONFIG_ERROR } from "@/modules/hub/utils";
import { getSessionUserId, requireUnifyDirectoryAccess } from "./access";
import {
+ getV3TaxonomyNodeRecordCounts,
getV3TaxonomyNodeRecords,
getV3TaxonomyRun,
getV3TaxonomyState,
@@ -36,6 +39,7 @@ vi.mock("@/modules/hub/service", () => ({
getTaxonomyRun: vi.fn(),
createTaxonomyRun: vi.fn(),
listTaxonomyNodeRecords: vi.fn(),
+ listTaxonomyNodeRecordCounts: vi.fn(),
renameTaxonomyNode: vi.fn(),
removeTaxonomyNode: vi.fn(),
}));
@@ -80,6 +84,23 @@ const node: TaxonomyNode = {
updated_at: "2026-07-01T00:00:00.000Z",
};
+/**
+ * Hub failures, shaped the way the SDK actually produces them: it has no `message` field to read off an
+ * RFC 9457 body, so it stringifies the whole body — internal problem URLs included — into `message`.
+ * Nothing built from these may reach the response, which is what the `toContain` guards below check.
+ */
+const HUB_INTERNAL_MARKER = "hub.formbricks.com/problems";
+const hubNotFound = {
+ status: 404,
+ message: `404 {"type":"https://${HUB_INTERNAL_MARKER}/not-found","title":"Not Found"}`,
+ detail: "",
+};
+const hubServerError = {
+ status: 500,
+ message: `500 {"type":"https://${HUB_INTERNAL_MARKER}/internal","title":"Internal Server Error"}`,
+ detail: "",
+};
+
const record: FeedbackRecordData = {
id: "rec-1",
collected_at: "2026-07-01T00:00:00.000Z",
@@ -109,19 +130,15 @@ describe("listV3TaxonomyFields", () => {
expect(await response.json()).toEqual({ data: { fields: [field], unavailable: false } });
});
- test("returns 200 with unavailable=true on a Hub error (no false gate)", async () => {
- vi.mocked(listTaxonomyFields).mockResolvedValue({
- data: null,
- error: { status: 503, message: "Embeddings not configured", detail: "" },
- });
+ test("returns 200 with a bare unavailable=true on a Hub error (no false gate, no Hub text)", async () => {
+ vi.mocked(listTaxonomyFields).mockResolvedValue({ data: null, error: hubServerError });
const response = await listV3TaxonomyFields(base);
const body = await response.json();
expect(response.status).toBe(200);
- expect(body.data.unavailable).toBe(true);
- expect(body.data.unavailableMessage).toBe("Embeddings not configured");
- expect(body.data.fields).toEqual([]);
+ expect(body.data).toEqual({ fields: [], unavailable: true });
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
});
test("returns the auth Response and skips the Hub call when access is denied", async () => {
@@ -171,21 +188,27 @@ describe("getV3TaxonomyState", () => {
expect(body.data.unavailable).toBe(false);
});
- test("returns unavailable=true when the runs call errors", async () => {
- vi.mocked(getActiveTaxonomyTree).mockResolvedValue({
- data: null,
- error: { status: 500, message: "x", detail: "" },
- });
- vi.mocked(listTaxonomyRuns).mockResolvedValue({
- data: null,
- error: { status: 500, message: "boom", detail: "" },
- });
+ test("returns a bare unavailable=true when the runs call errors, without the Hub's own text", async () => {
+ vi.mocked(getActiveTaxonomyTree).mockResolvedValue({ data: null, error: hubServerError });
+ vi.mocked(listTaxonomyRuns).mockResolvedValue({ data: null, error: hubServerError });
const response = await getV3TaxonomyState(stateParams);
const body = await response.json();
expect(response.status).toBe(200);
- expect(body.data.unavailable).toBe(true);
+ expect(body.data).toEqual({ activeTree: null, runs: [], unavailable: true });
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
+
+ test("flags a tree outage without the Hub's own text when only the tree call errors", async () => {
+ vi.mocked(getActiveTaxonomyTree).mockResolvedValue({ data: null, error: hubServerError });
+ vi.mocked(listTaxonomyRuns).mockResolvedValue({ data: { data: [run] }, error: null });
+
+ const response = await getV3TaxonomyState(stateParams);
+ const body = await response.json();
+
+ expect(body.data).toEqual({ activeTree: null, runs: [run], unavailable: true });
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
});
});
@@ -199,16 +222,96 @@ describe("getV3TaxonomyRun", () => {
expect(await response.json()).toEqual({ data: run });
});
- test("returns 502 on a Hub error", async () => {
+ test("returns 404, not 502, when the Hub does not have the run", async () => {
+ vi.mocked(getTaxonomyRun).mockResolvedValue({ data: null, error: hubNotFound });
+
+ const response = await getV3TaxonomyRun({ ...base, runId: run.id });
+ const body = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(body.code).toBe("not_found");
+ expect(body.detail).toBe("Taxonomy run not found");
+ expect(body.details).toEqual({ resource_type: "Taxonomy run", resource_id: run.id });
+ // Only the id the caller already sent — no run payload, and none of the Hub's own error text.
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ expect(JSON.stringify(body)).not.toContain(run.tenant_id);
+ });
+
+ test("returns 502 with a sanitized detail on a Hub 5xx", async () => {
+ vi.mocked(getTaxonomyRun).mockResolvedValue({ data: null, error: hubServerError });
+
+ const response = await getV3TaxonomyRun({ ...base, runId: run.id });
+ const body = await response.json();
+
+ expect(response.status).toBe(502);
+ expect(body.detail).toBe("Failed to load taxonomy run");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
+
+ test("returns 502 on a connection failure, which has no status", async () => {
vi.mocked(getTaxonomyRun).mockResolvedValue({
data: null,
- error: { status: 500, message: "boom", detail: "" },
+ error: { status: 0, message: "Connection error.", detail: "Connection error." },
});
const response = await getV3TaxonomyRun({ ...base, runId: run.id });
expect(response.status).toBe(502);
});
+
+ test("returns 503 when the Hub integration is not configured", async () => {
+ vi.mocked(getTaxonomyRun).mockResolvedValue({ data: null, error: { ...NO_CONFIG_ERROR } });
+
+ const response = await getV3TaxonomyRun({ ...base, runId: run.id });
+ const body = await response.json();
+
+ expect(response.status).toBe(503);
+ expect(body.code).toBe("service_unavailable");
+ // The sentinel names the env var; the response must not.
+ expect(body.detail).not.toContain("HUB_API_KEY");
+ });
+
+ test("returns 502 when the Hub reports success but no payload", async () => {
+ vi.mocked(getTaxonomyRun).mockResolvedValue({ data: null, error: null });
+
+ const response = await getV3TaxonomyRun({ ...base, runId: run.id });
+
+ expect(response.status).toBe(502);
+ });
+});
+
+describe("getV3TaxonomyNodeRecordCounts", () => {
+ test("returns the per-node counts on success", async () => {
+ const counts = [{ node_id: node.id, record_count: 12 }];
+ vi.mocked(listTaxonomyNodeRecordCounts).mockResolvedValue({ data: { counts }, error: null });
+
+ const response = await getV3TaxonomyNodeRecordCounts({ ...base, runId: run.id });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ data: { counts } });
+ });
+
+ test("returns 404, not 502, when the Hub does not have the run", async () => {
+ vi.mocked(listTaxonomyNodeRecordCounts).mockResolvedValue({ data: null, error: hubNotFound });
+
+ const response = await getV3TaxonomyNodeRecordCounts({ ...base, runId: run.id });
+ const body = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(body.details).toEqual({ resource_type: "Taxonomy run", resource_id: run.id });
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
+
+ test("returns 502 with a sanitized detail on a Hub 5xx", async () => {
+ vi.mocked(listTaxonomyNodeRecordCounts).mockResolvedValue({ data: null, error: hubServerError });
+
+ const response = await getV3TaxonomyNodeRecordCounts({ ...base, runId: run.id });
+ const body = await response.json();
+
+ expect(response.status).toBe(502);
+ expect(body.detail).toBe("Failed to load record counts");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
});
describe("getV3TaxonomyNodeRecords", () => {
@@ -226,15 +329,28 @@ describe("getV3TaxonomyNodeRecords", () => {
expect(body.meta).toEqual({ limit: 100 });
});
- test("returns 502 on a Hub error", async () => {
- vi.mocked(listTaxonomyNodeRecords).mockResolvedValue({
- data: null,
- error: { status: 500, message: "boom", detail: "" },
- });
+ test("returns 404, not 502, when the Hub does not have the node", async () => {
+ vi.mocked(listTaxonomyNodeRecords).mockResolvedValue({ data: null, error: hubNotFound });
const response = await getV3TaxonomyNodeRecords({ ...base, nodeId: node.id, limit: 100 });
+ const body = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(body.details).toEqual({ resource_type: "Taxonomy node", resource_id: node.id });
+ // No record sample leaks onto the not-found path.
+ expect(body).not.toHaveProperty("data");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
+
+ test("returns 502 with a sanitized detail on a Hub 5xx", async () => {
+ vi.mocked(listTaxonomyNodeRecords).mockResolvedValue({ data: null, error: hubServerError });
+
+ const response = await getV3TaxonomyNodeRecords({ ...base, nodeId: node.id, limit: 100 });
+ const body = await response.json();
expect(response.status).toBe(502);
+ expect(body.detail).toBe("Failed to load feedback records");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
});
});
@@ -287,6 +403,28 @@ describe("triggerV3TaxonomyRun", () => {
expect(response.status).toBe(401);
expect(createTaxonomyRun).not.toHaveBeenCalled();
});
+
+ test("keeps a Hub 404 as a 502 — there is no resource to report missing on a create", async () => {
+ vi.mocked(createTaxonomyRun).mockResolvedValue({ data: null, error: hubNotFound });
+
+ const response = await triggerV3TaxonomyRun(runParams);
+ const body = await response.json();
+
+ expect(response.status).toBe(502);
+ expect(body.detail).toBe("Failed to start taxonomy generation");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
+
+ test("returns 502 with a sanitized detail on a Hub 5xx", async () => {
+ vi.mocked(createTaxonomyRun).mockResolvedValue({ data: null, error: hubServerError });
+
+ const response = await triggerV3TaxonomyRun(runParams);
+ const body = await response.json();
+
+ expect(response.status).toBe(502);
+ expect(body.detail).toBe("Failed to start taxonomy generation");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
});
describe("renameV3TaxonomyNode", () => {
@@ -300,15 +438,26 @@ describe("renameV3TaxonomyNode", () => {
expect(await response.json()).toEqual({ data: renamed });
});
- test("returns 502 on a Hub error", async () => {
- vi.mocked(renameTaxonomyNode).mockResolvedValue({
- data: null,
- error: { status: 500, message: "boom", detail: "" },
- });
+ test("returns 404, not 502, when the node was already removed", async () => {
+ vi.mocked(renameTaxonomyNode).mockResolvedValue({ data: null, error: hubNotFound });
const response = await renameV3TaxonomyNode({ ...base, nodeId: node.id, label: "Copilot" });
+ const body = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(body.details).toEqual({ resource_type: "Taxonomy node", resource_id: node.id });
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
+
+ test("returns 502 with a sanitized detail on a Hub 5xx", async () => {
+ vi.mocked(renameTaxonomyNode).mockResolvedValue({ data: null, error: hubServerError });
+
+ const response = await renameV3TaxonomyNode({ ...base, nodeId: node.id, label: "Copilot" });
+ const body = await response.json();
expect(response.status).toBe(502);
+ expect(body.detail).toBe("Failed to rename taxonomy node");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
});
});
@@ -321,14 +470,25 @@ describe("removeV3TaxonomyNode", () => {
expect(response.status).toBe(204);
});
- test("returns 502 on a Hub error", async () => {
- vi.mocked(removeTaxonomyNode).mockResolvedValue({
- data: null,
- error: { status: 500, message: "boom", detail: "" },
- });
+ test("returns 404, not 502, when the node was already removed", async () => {
+ vi.mocked(removeTaxonomyNode).mockResolvedValue({ data: null, error: hubNotFound });
+
+ const response = await removeV3TaxonomyNode({ ...base, nodeId: node.id });
+ const body = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(body.details).toEqual({ resource_type: "Taxonomy node", resource_id: node.id });
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
+ });
+
+ test("returns 502 with a sanitized detail on a Hub 5xx", async () => {
+ vi.mocked(removeTaxonomyNode).mockResolvedValue({ data: null, error: hubServerError });
const response = await removeV3TaxonomyNode({ ...base, nodeId: node.id });
+ const body = await response.json();
expect(response.status).toBe(502);
+ expect(body.detail).toBe("Failed to remove taxonomy node");
+ expect(JSON.stringify(body)).not.toContain(HUB_INTERNAL_MARKER);
});
});
diff --git a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.ts b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.ts
index 3ed45e781f0e..1b3dbdeffe06 100644
--- a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.ts
+++ b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/operations.ts
@@ -2,6 +2,8 @@ import "server-only";
import {
noContentResponse,
problemBadGateway,
+ problemNotFound,
+ problemServiceUnavailable,
problemUnauthorized,
successListResponse,
successResponse,
@@ -19,6 +21,7 @@ import {
renameTaxonomyNode,
} from "@/modules/hub/service";
import type { TaxonomyScopeInput, TaxonomyScopeType } from "@/modules/hub/types";
+import { type HubError, isHubNotConfigured } from "@/modules/hub/utils";
import { getSessionUserId, requireUnifyDirectoryAccess } from "./access";
type TBaseParams = {
@@ -53,10 +56,61 @@ function buildTaxonomyScope(
};
}
+type THubFailureOptions = {
+ requestId: string;
+ instance: string;
+ /** The 502 detail. Static text only — never the Hub's own message (see below). */
+ fallbackDetail: string;
+ /**
+ * When set, a Hub 404 maps to a 404 for this resource. Omit it on creates, where "not found" says
+ * nothing useful about the request.
+ */
+ notFound?: { resourceType: string; resourceId: string };
+};
+
+/**
+ * Turns a failed Hub call into the right problem response.
+ *
+ * Not every Hub failure is a fault: a 404 is the benign "gone, or never existed" — a stale run id, a
+ * node someone else just removed — and returning that as a 502 both misreads to the caller as a server
+ * crash and counts a normal outcome towards the 5xx rate. NO_CONFIG means the integration is switched
+ * off on this deployment, which is a 503. Everything else — 5xx, timeout, connection, or a null payload
+ * with no error at all — is a genuine upstream failure and stays a 502.
+ *
+ * The 404 is not an existence oracle: every caller checks directory access first and scopes the Hub
+ * call by `tenant_id`, so it only ever means "not in *your* directory".
+ *
+ * The Hub's own error text is never relayed. The SDK folds the entire RFC 9457 problem body into
+ * `message`, so echoing it puts internal Hub URLs and problem codes into a customer-facing response.
+ * The full error is already logged in `@/modules/hub/service`; correlate on `requestId`.
+ */
+function hubFailureResponse(error: HubError | null, options: THubFailureOptions): Response {
+ const { requestId, instance, fallbackDetail, notFound } = options;
+
+ if (error) {
+ if (error.status === 404 && notFound) {
+ return problemNotFound(requestId, notFound.resourceType, notFound.resourceId, instance);
+ }
+ if (isHubNotConfigured(error)) {
+ return problemServiceUnavailable(
+ requestId,
+ "The Hub integration is not configured on this deployment.",
+ instance
+ );
+ }
+ }
+
+ return problemBadGateway(requestId, fallbackDetail, instance);
+}
+
/**
* `fields` and `state` return 200 with an `unavailable` flag on Hub error / NO_CONFIG (mirroring the
* legacy actions) so a transient Hub blip never trips a false "not enough feedback"/"embedding" gate.
- * The other endpoints return 502 so React Query surfaces an error state and the UI can retry.
+ * The other endpoints return a problem response (see `hubFailureResponse`) so React Query surfaces an
+ * error state and the UI can retry.
+ *
+ * The flag carries no message on purpose: the UI renders a localized alert off the boolean, so any
+ * string sent from here would either go unused or ship untranslated.
*/
export async function listV3TaxonomyFields(params: TBaseParams): Promise {
@@ -74,14 +128,7 @@ export async function listV3TaxonomyFields(params: TBaseParams): Promise
+ workflowsHandlers.archive({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/[workflowId]/disable/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/disable/route.ts
new file mode 100644
index 000000000000..2ed24d0c7062
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/[workflowId]/disable/route.ts
@@ -0,0 +1,19 @@
+/**
+ * POST /api/v3/workflows/{workflowId}/disable — move an enabled workflow to disabled.
+ * Thin adapter delegating to the framework-agnostic handler in @formbricks/workflows/server.
+ */
+import { ZWorkflowIdInput } from "@formbricks/workflows";
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context";
+
+export const POST = withV3ApiWrapper({
+ auth: "both",
+ action: "updated",
+ targetType: "workflow",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) =>
+ workflowsHandlers.disable({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/[workflowId]/duplicate/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/duplicate/route.ts
new file mode 100644
index 000000000000..4e0331bddeac
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/[workflowId]/duplicate/route.ts
@@ -0,0 +1,20 @@
+/**
+ * POST /api/v3/workflows/{workflowId}/duplicate — clone a workflow into a new draft.
+ * Thin adapter delegating to the framework-agnostic handler in `@formbricks/workflows/server`.
+ */
+import { ZWorkflowIdInput } from "@formbricks/workflows";
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context";
+
+export const POST = withV3ApiWrapper({
+ auth: "both",
+ action: "created",
+ targetType: "workflow",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ req, parsedInput, authentication, auditLog, requestId, instance }) =>
+ workflowsHandlers.duplicate({
+ req,
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/[workflowId]/enable/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/enable/route.ts
new file mode 100644
index 000000000000..22dd8da8688f
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/[workflowId]/enable/route.ts
@@ -0,0 +1,19 @@
+/**
+ * POST /api/v3/workflows/{workflowId}/enable — validate executability, snapshot an immutable
+ * version, and move the workflow to enabled. Thin adapter delegating to @formbricks/workflows/server.
+ */
+import { ZWorkflowIdInput } from "@formbricks/workflows";
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context";
+
+export const POST = withV3ApiWrapper({
+ auth: "both",
+ action: "updated",
+ targetType: "workflow",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) =>
+ workflowsHandlers.enable({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/[workflowId]/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/route.ts
new file mode 100644
index 000000000000..8e88b0117d71
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/[workflowId]/route.ts
@@ -0,0 +1,45 @@
+/**
+ * /api/v3/workflows/{workflowId} — retrieve, update, and delete a single workflow.
+ * Unknown / cross-workspace ids return 403 (not 404) to avoid leaking existence.
+ *
+ * Thin adapters: the wrapper validates the path param with the contract schema, then delegates to
+ * the framework-agnostic handlers in `@formbricks/workflows/server`.
+ */
+import { ZWorkflowIdInput } from "@formbricks/workflows";
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../lib/context";
+
+export const GET = withV3ApiWrapper({
+ auth: "both",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ parsedInput, authentication, requestId, instance }) =>
+ workflowsHandlers.get({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance),
+ params: parsedInput.params,
+ }),
+});
+
+export const PATCH = withV3ApiWrapper({
+ auth: "both",
+ action: "updated",
+ targetType: "workflow",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ req, parsedInput, authentication, auditLog, requestId, instance }) =>
+ workflowsHandlers.patch({
+ req,
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ params: parsedInput.params,
+ }),
+});
+
+export const DELETE = withV3ApiWrapper({
+ auth: "both",
+ action: "deleted",
+ targetType: "workflow",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) =>
+ workflowsHandlers.delete({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/[workflowId]/test/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/test/route.ts
new file mode 100644
index 000000000000..d51850de5b66
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/[workflowId]/test/route.ts
@@ -0,0 +1,19 @@
+/**
+ * POST /api/v3/workflows/{workflowId}/test — dry-run (test) a workflow: validate that its live
+ * definition would execute and that the trigger's referenced survey + ending cards resolve.
+ * No run is created and no side effects occur; the response reports `{ ok, problems }`. Thin
+ * adapter delegating to @formbricks/workflows/server.
+ */
+import { ZWorkflowIdInput } from "@formbricks/workflows";
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context";
+
+export const POST = withV3ApiWrapper({
+ auth: "both",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ parsedInput, authentication, requestId, instance }) =>
+ workflowsHandlers.testWorkflow({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/[workflowId]/unarchive/route.ts b/apps/web/app/api/v3/workflows/[workflowId]/unarchive/route.ts
new file mode 100644
index 000000000000..fa0f521aa521
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/[workflowId]/unarchive/route.ts
@@ -0,0 +1,19 @@
+/**
+ * POST /api/v3/workflows/{workflowId}/unarchive — restore an archived workflow to draft.
+ * Thin adapter delegating to the framework-agnostic handler in `@formbricks/workflows/server`.
+ */
+import { ZWorkflowIdInput } from "@formbricks/workflows";
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context";
+
+export const POST = withV3ApiWrapper({
+ auth: "both",
+ action: "updated",
+ targetType: "workflow",
+ schemas: { params: ZWorkflowIdInput },
+ handler: async ({ parsedInput, authentication, auditLog, requestId, instance }) =>
+ workflowsHandlers.unarchive({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/lib/context.test.ts b/apps/web/app/api/v3/workflows/lib/context.test.ts
new file mode 100644
index 000000000000..fbea286126f9
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/lib/context.test.ts
@@ -0,0 +1,257 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import type { TAuthenticationApiKey } from "@formbricks/types/auth";
+import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
+import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types";
+import { getOrganizationMemberEmails } from "@/lib/organization/service";
+import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
+import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils";
+import { buildWorkflowApiContext } from "./context";
+
+const { surveyFindUnique } = vi.hoisted(() => ({ surveyFindUnique: vi.fn() }));
+vi.mock("@formbricks/database", () => ({
+ prisma: { workflow: {}, survey: { findUnique: surveyFindUnique } },
+}));
+vi.mock("@formbricks/logger", () => ({
+ logger: { withContext: vi.fn(() => ({ warn: vi.fn(), error: vi.fn() })) },
+}));
+vi.mock("@/app/api/v3/lib/auth", () => ({ requireV3WorkspaceAccess: vi.fn() }));
+vi.mock("@/lib/utils/helper", () => ({ getOrganizationIdFromWorkspaceId: vi.fn() }));
+vi.mock("@/lib/organization/service", () => ({ getOrganizationMemberEmails: vi.fn() }));
+vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsWorkflowsEnabled: vi.fn() }));
+
+const baseAuditLog = (): TV3AuditLog => ({
+ action: "updated",
+ targetType: "workflow",
+ userId: "unknown",
+ targetId: "unknown",
+ organizationId: "unknown",
+ status: "failure",
+ oldObject: undefined,
+ newObject: undefined,
+ userType: "api",
+ apiUrl: "https://app.formbricks.com/api/v3/workflows/wf_1",
+});
+
+const sessionAuth = {
+ user: { id: "cm9zr52kh000508l8e3q7bw9j" },
+ expires: "2026-12-01",
+} as unknown as TV3Authentication;
+const apiKeyAuth = {
+ type: "apiKey",
+ apiKeyId: "key_1",
+ organizationId: "org_1",
+ organizationAccess: { accessControl: { read: true, write: true } },
+ workspacePermissions: [],
+} as unknown as TAuthenticationApiKey;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ // Entitled by default so authorization-focused tests exercise the workspace-access behavior.
+ vi.mocked(getIsWorkflowsEnabled).mockResolvedValue(true);
+});
+
+describe("buildWorkflowApiContext", () => {
+ test("derives userId from a session", () => {
+ const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "https://app.formbricks.com");
+ expect(ctx.userId).toBe("cm9zr52kh000508l8e3q7bw9j");
+ });
+
+ test("leaves userId null for API-key authentication", () => {
+ expect(buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").userId).toBeNull();
+ });
+
+ test("leaves userId null for unauthenticated requests", () => {
+ expect(buildWorkflowApiContext(null, "req_1", "inst").userId).toBeNull();
+ });
+
+ test("authorize delegates to requireV3WorkspaceAccess and returns its result when entitled", async () => {
+ const resolved = { workspaceId: "ws_1", organizationId: "org_1" };
+ vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(resolved);
+
+ const ctx = buildWorkflowApiContext(apiKeyAuth, "req_1", "https://app.formbricks.com");
+ const result = await ctx.authorize("ws_1", "readWrite");
+
+ expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
+ apiKeyAuth,
+ "ws_1",
+ "readWrite",
+ "req_1",
+ "https://app.formbricks.com"
+ );
+ // The entitlement is checked against the organization resolved by workspace access.
+ expect(getIsWorkflowsEnabled).toHaveBeenCalledWith("org_1");
+ expect(result).toEqual(resolved);
+ });
+
+ test("authorize returns a 403 problem when the organization lacks the workflows entitlement", async () => {
+ vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ workspaceId: "ws_1", organizationId: "org_1" });
+ vi.mocked(getIsWorkflowsEnabled).mockResolvedValue(false);
+
+ const ctx = buildWorkflowApiContext(apiKeyAuth, "req_1", "https://app.formbricks.com");
+ const result = await ctx.authorize("ws_1", "read");
+
+ expect(result).toBeInstanceOf(Response);
+ const response = result as Response;
+ expect(response.status).toBe(403);
+ const body = await response.json();
+ expect(body).toMatchObject({
+ status: 403,
+ detail: "Workflows are not enabled for this organization",
+ });
+ });
+
+ test("authorize short-circuits on a workspace-access failure without checking the entitlement", async () => {
+ const denied = new Response(null, { status: 403 });
+ vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied);
+
+ const ctx = buildWorkflowApiContext(apiKeyAuth, "req_1", "inst");
+ const result = await ctx.authorize("ws_1", "read");
+
+ expect(result).toBe(denied);
+ expect(getIsWorkflowsEnabled).not.toHaveBeenCalled();
+ });
+});
+
+describe("verifyTriggerSurvey (validates a workflow trigger's referenced survey)", () => {
+ const verify = (input: { workspaceId: string; surveyId: string; endingCardIds: string[] }) =>
+ buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyTriggerSurvey(input);
+
+ // The adapter parses `survey.endings` with `ZSurveyEndings`, so mocked endings must be valid
+ // ending cards (cuid2 id + type), matching how the survey is stored.
+ const endingId1 = "cm9zr4q7i000108l84goze001";
+ const endingId2 = "cm9zr4q7i000108l84goze002";
+ const endScreen = (id: string) => ({ id, type: "endScreen" as const });
+
+ test("rejects a workflow trigger whose survey no longer exists in the workspace", async () => {
+ surveyFindUnique.mockResolvedValue(null);
+
+ const result = await verify({ workspaceId: "ws_1", surveyId: "s_1", endingCardIds: [endingId1] });
+
+ expect(result).toEqual({ surveyExists: false, missingEndingCardIds: [] });
+ expect(surveyFindUnique).toHaveBeenCalledWith({
+ where: { id_workspaceId: { id: "s_1", workspaceId: "ws_1" } },
+ select: { endings: true },
+ });
+ });
+
+ test("flags the trigger's ending-card ids that are missing from the survey", async () => {
+ surveyFindUnique.mockResolvedValue({ endings: [endScreen(endingId1), endScreen(endingId2)] });
+
+ const result = await verify({
+ workspaceId: "ws_1",
+ surveyId: "s_1",
+ endingCardIds: [endingId1, "ending_missing"],
+ });
+
+ expect(result).toEqual({ surveyExists: true, missingEndingCardIds: ["ending_missing"] });
+ });
+
+ test("accepts a workflow trigger whose survey and ending cards all exist", async () => {
+ surveyFindUnique.mockResolvedValue({ endings: [endScreen(endingId1)] });
+
+ const result = await verify({ workspaceId: "ws_1", surveyId: "s_1", endingCardIds: [endingId1] });
+
+ expect(result).toEqual({ surveyExists: true, missingEndingCardIds: [] });
+ });
+});
+
+describe("verifyRecipientsAllowed (recipient allowlist for send_email, ENG-2029)", () => {
+ test("returns the literal recipients that are not organization members (case-insensitive)", async () => {
+ vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1");
+ vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["member@corp.example"]));
+
+ const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({
+ workspaceId: "ws_1",
+ emails: ["Member@corp.example", "attacker@external-evil.example"],
+ });
+
+ expect(getOrganizationMemberEmails).toHaveBeenCalledWith("org_1");
+ expect(result).toEqual({ disallowedEmails: ["attacker@external-evil.example"] });
+ });
+
+ test("allows all recipients when each is an organization member", async () => {
+ vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1");
+ vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["a@corp.example", "b@corp.example"]));
+
+ const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({
+ workspaceId: "ws_1",
+ emails: ["a@corp.example", "b@corp.example"],
+ });
+
+ expect(result).toEqual({ disallowedEmails: [] });
+ });
+});
+
+describe("recordAudit (binds the audit sink to the request's audit log)", () => {
+ test("is not exposed when no audit log is threaded in (read-only routes)", () => {
+ const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst");
+ expect(ctx.recordAudit).toBeUndefined();
+ });
+
+ test("writes targetId + before/after snapshots onto the audit log", async () => {
+ vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_resolved");
+ const auditLog = baseAuditLog();
+ const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog);
+
+ await ctx.recordAudit?.({
+ targetId: "wf_1",
+ workspaceId: "ws_1",
+ oldObject: { status: "draft" },
+ newObject: { status: "enabled" },
+ });
+
+ expect(auditLog.targetId).toBe("wf_1");
+ expect(auditLog.oldObject).toEqual({ status: "draft" });
+ expect(auditLog.newObject).toEqual({ status: "enabled" });
+ });
+
+ test("resolves the workflow's organization from detail.workspaceId for session auth", async () => {
+ vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_resolved");
+ const auditLog = baseAuditLog();
+ const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog);
+
+ await ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", newObject: { status: "draft" } });
+
+ expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_1");
+ expect(auditLog.organizationId).toBe("org_resolved");
+ });
+
+ test("resolves org from detail.workspaceId on the delete path (oldObject only, no newObject)", async () => {
+ vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_resolved");
+ const auditLog = baseAuditLog();
+ const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog);
+
+ // Delete-style: a pre-mutation snapshot only, no newObject — org must still resolve from the
+ // explicit workspaceId (never inferred from a snapshot that the delete path may not carry).
+ await ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", oldObject: { status: "draft" } });
+
+ expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_1");
+ expect(auditLog.organizationId).toBe("org_resolved");
+ expect(auditLog.oldObject).toEqual({ status: "draft" });
+ expect(auditLog.newObject).toBeUndefined();
+ });
+
+ test("keeps the API-key path's organization and does not re-resolve from the workspace", async () => {
+ const auditLog = { ...baseAuditLog(), organizationId: "org_from_key" };
+ const ctx = buildWorkflowApiContext(apiKeyAuth as TV3Authentication, "req_1", "inst", auditLog);
+
+ await ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", newObject: { status: "draft" } });
+
+ expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
+ expect(auditLog.organizationId).toBe("org_from_key");
+ });
+
+ test("never throws when organization resolution fails; snapshots are still recorded", async () => {
+ vi.mocked(getOrganizationIdFromWorkspaceId).mockRejectedValue(new Error("workspace lookup failed"));
+ const auditLog = baseAuditLog();
+ const ctx = buildWorkflowApiContext(sessionAuth, "req_1", "inst", auditLog);
+
+ await expect(
+ ctx.recordAudit?.({ targetId: "wf_1", workspaceId: "ws_1", newObject: { status: "draft" } })
+ ).resolves.toBeUndefined();
+
+ expect(auditLog.targetId).toBe("wf_1");
+ // Resolution failed, so the session org stays at its default rather than corrupting the event.
+ expect(auditLog.organizationId).toBe("unknown");
+ });
+});
diff --git a/apps/web/app/api/v3/workflows/lib/context.ts b/apps/web/app/api/v3/workflows/lib/context.ts
new file mode 100644
index 000000000000..9c280243a658
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/lib/context.ts
@@ -0,0 +1,148 @@
+import { prisma } from "@formbricks/database";
+import { logger } from "@formbricks/logger";
+import { ZSurveyEndings } from "@formbricks/types/surveys/types";
+import {
+ type WorkflowApiContext,
+ type WorkflowAuditDetail,
+ createWorkflowsHandlers,
+ createWorkflowsService,
+} from "@formbricks/workflows/server";
+import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
+import { problemForbidden } from "@/app/api/v3/lib/response";
+import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types";
+import { ENCRYPTION_KEY } from "@/lib/constants";
+import { getOrganizationMemberEmails } from "@/lib/organization/service";
+import { normalizeEmailForComparison } from "@/lib/utils/email";
+import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
+import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils";
+
+/**
+ * Adapter glue between the Next.js v3 routes and the framework-agnostic `@formbricks/workflows`
+ * handlers. The package owns business logic, validation, serialization, and error mapping; this
+ * file injects the app's concrete `prisma`/`logger` and binds an `authorize` capability to the
+ * authenticated request. The real Prisma client structurally satisfies the package's narrow
+ * `WorkflowsDb` port, so no cast is needed; the package never imports `@formbricks/database`.
+ */
+const service = createWorkflowsService({ prisma });
+
+/** Singleton handlers; they are stateless and only close over the injected service. */
+export const workflowsHandlers = createWorkflowsHandlers(service);
+
+const getUserId = (authentication: TV3Authentication): string | null =>
+ authentication && "user" in authentication && authentication.user?.id ? authentication.user.id : null;
+
+/**
+ * Confirm a workflow trigger's referenced survey + ending cards exist in the workspace. Injected so
+ * `@formbricks/workflows` stays survey-agnostic. Scoped by the survey's `(id, workspaceId)` composite
+ * key; ending ids come from the survey's `endings`, parsed through `ZSurveyEndings` so the JSON
+ * column is validated (not accessed untyped) before reading ids.
+ */
+const verifyTriggerSurvey: WorkflowApiContext["verifyTriggerSurvey"] = async ({
+ workspaceId,
+ surveyId,
+ endingCardIds,
+}) => {
+ const survey = await prisma.survey.findUnique({
+ where: { id_workspaceId: { id: surveyId, workspaceId } },
+ select: { endings: true },
+ });
+
+ if (!survey) {
+ return { surveyExists: false, missingEndingCardIds: [] };
+ }
+
+ const endingIds = new Set(ZSurveyEndings.parse(survey.endings).map((ending) => ending.id));
+ return {
+ surveyExists: true,
+ missingEndingCardIds: endingCardIds.filter((endingCardId) => !endingIds.has(endingCardId)),
+ };
+};
+
+/**
+ * Bind the framework-agnostic audit sink to this request's audit log. The handlers call it once,
+ * post-mutation, with the affected workflow id + workspace id + before/after snapshots; we copy
+ * those onto `auditLog` (target id, old/new object) so the v3 wrapper queues a complete Enterprise
+ * event.
+ *
+ * Organization resolution: the API-key path already set `auditLog.organizationId` from the key's
+ * org (see `buildV3AuditLog`); the session path leaves it as `UNKNOWN_DATA`, so we resolve the
+ * workflow's real org from `detail.workspaceId` (a first-class field — never inferred from the
+ * snapshots, so snapshot reshaping or PII redaction can't silently regress it). Any failure is
+ * swallowed and logged — an audit problem must never break or alter an already-successful mutation.
+ */
+const buildRecordAudit =
+ (
+ auditLog: TV3AuditLog,
+ authentication: TV3Authentication,
+ requestId: string
+ ): NonNullable =>
+ async (detail: WorkflowAuditDetail) => {
+ try {
+ auditLog.targetId = detail.targetId;
+ auditLog.oldObject = detail.oldObject;
+ auditLog.newObject = detail.newObject;
+
+ // API-key auth already carries the org; only the session path needs resolution.
+ const isApiKey = !!authentication && "apiKeyId" in authentication;
+ if (!isApiKey) {
+ auditLog.organizationId = await getOrganizationIdFromWorkspaceId(detail.workspaceId);
+ }
+ } catch (error) {
+ logger.withContext({ requestId }).error({ error }, "Failed to record workflow audit detail");
+ }
+ };
+
+/**
+ * Recipient allowlist for `send_email` actions. Injected so `@formbricks/workflows` stays
+ * organization-agnostic: given literal recipient emails, returns the subset that does NOT belong to
+ * the workspace's organization. Enable/test use it to block a workflow from silently forwarding
+ * response data to an arbitrary external inbox (ENG-2029). Emails are compared case-insensitively.
+ */
+const verifyRecipientsAllowed: WorkflowApiContext["verifyRecipientsAllowed"] = async ({
+ workspaceId,
+ emails,
+}) => {
+ const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
+ const memberEmails = await getOrganizationMemberEmails(organizationId);
+ const disallowedEmails = emails.filter((email) => !memberEmails.has(normalizeEmailForComparison(email)));
+ return { disallowedEmails };
+};
+
+export const buildWorkflowApiContext = (
+ authentication: TV3Authentication,
+ requestId: string,
+ instance: string,
+ auditLog?: TV3AuditLog
+): WorkflowApiContext => ({
+ userId: getUserId(authentication),
+ requestId,
+ instance,
+ logger: logger.withContext({ requestId }),
+ // HMAC key for redacting PII markers in audit snapshots; reuses the app's audit/encryption secret
+ // so markers aren't offline-guessable. Injected as data to keep `@formbricks/workflows` agnostic.
+ auditRedactionKey: ENCRYPTION_KEY,
+ // Workspace access first, then the workflows entitlement (Cloud plan / self-hosted EE license)
+ // for the resolved organization. Every v3 route handler and MCP tool authorizes through this
+ // capability, so this is the single enforcement point for both surfaces; the returned problem
+ // Response short-circuits through the package's error mapping like any authorization failure.
+ authorize: async (workspaceId, access) => {
+ const authorized = await requireV3WorkspaceAccess(
+ authentication,
+ workspaceId,
+ access,
+ requestId,
+ instance
+ );
+ if (authorized instanceof Response) {
+ return authorized;
+ }
+ const isWorkflowsEnabled = await getIsWorkflowsEnabled(authorized.organizationId);
+ if (!isWorkflowsEnabled) {
+ return problemForbidden(requestId, "Workflows are not enabled for this organization", instance);
+ }
+ return authorized;
+ },
+ verifyTriggerSurvey,
+ verifyRecipientsAllowed,
+ ...(auditLog ? { recordAudit: buildRecordAudit(auditLog, authentication, requestId) } : {}),
+});
diff --git a/apps/web/app/api/v3/workflows/route.ts b/apps/web/app/api/v3/workflows/route.ts
new file mode 100644
index 000000000000..e68960c62059
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/route.ts
@@ -0,0 +1,26 @@
+/**
+ * /api/v3/workflows — list and create workflow management resources.
+ * Session cookie or x-api-key; scope by workspaceId only.
+ *
+ * Thin adapter: authenticate via the shared wrapper, build the workflow API context, and delegate
+ * to the framework-agnostic handlers in `@formbricks/workflows/server`.
+ */
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "./lib/context";
+
+export const GET = withV3ApiWrapper({
+ auth: "both",
+ handler: async ({ req, authentication, requestId, instance }) =>
+ workflowsHandlers.list({ req, ctx: buildWorkflowApiContext(authentication, requestId, instance) }),
+});
+
+export const POST = withV3ApiWrapper({
+ auth: "both",
+ action: "created",
+ targetType: "workflow",
+ handler: async ({ req, authentication, auditLog, requestId, instance }) =>
+ workflowsHandlers.create({
+ req,
+ ctx: buildWorkflowApiContext(authentication, requestId, instance, auditLog),
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/runs/[runId]/route.ts b/apps/web/app/api/v3/workflows/runs/[runId]/route.ts
new file mode 100644
index 000000000000..8d55ab1e5e03
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/runs/[runId]/route.ts
@@ -0,0 +1,20 @@
+/**
+ * /api/v3/workflows/runs/{runId} — retrieve a single workflow run with its ordered step logs.
+ * Unknown / cross-workspace run ids return 403 (not 404) to avoid leaking existence.
+ *
+ * Thin adapter: the wrapper validates the path param with the contract schema, then delegates to
+ * the framework-agnostic handler in `@formbricks/workflows/server`.
+ */
+import { ZWorkflowRunIdInput } from "@formbricks/workflows";
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../../lib/context";
+
+export const GET = withV3ApiWrapper({
+ auth: "both",
+ schemas: { params: ZWorkflowRunIdInput },
+ handler: async ({ parsedInput, authentication, requestId, instance }) =>
+ workflowsHandlers.getRun({
+ ctx: buildWorkflowApiContext(authentication, requestId, instance),
+ params: parsedInput.params,
+ }),
+});
diff --git a/apps/web/app/api/v3/workflows/runs/route.ts b/apps/web/app/api/v3/workflows/runs/route.ts
new file mode 100644
index 000000000000..c1624700673d
--- /dev/null
+++ b/apps/web/app/api/v3/workflows/runs/route.ts
@@ -0,0 +1,17 @@
+/**
+ * /api/v3/workflows/runs — list workflow runs for a workspace (newest first).
+ * Session cookie or x-api-key; scoped by the required `workspaceId` query param, with optional
+ * `workflowId` / `responseId` / `filter[status][in]` / `filter[isDryRun][eq]` filters. A static
+ * `runs` segment, so it never collides with `/api/v3/workflows/{workflowId}`.
+ *
+ * Thin adapter: authenticate via the shared wrapper, build the workflow API context, and delegate
+ * to the framework-agnostic handlers in `@formbricks/workflows/server`.
+ */
+import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
+import { buildWorkflowApiContext, workflowsHandlers } from "../lib/context";
+
+export const GET = withV3ApiWrapper({
+ auth: "both",
+ handler: async ({ req, authentication, requestId, instance }) =>
+ workflowsHandlers.listRuns({ req, ctx: buildWorkflowApiContext(authentication, requestId, instance) }),
+});
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 64b9e4bf47f4..2316a978cc59 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -84,6 +84,8 @@ checksums:
auth/oauth/scopes/profile: ea073dc28851ee1a00365047be103d50
auth/oauth/scopes/surveys_read: 8280264457fe7c3b759c82422052aed2
auth/oauth/scopes/surveys_write: fb128cebe62dfd5685d086d706c432c2
+ auth/oauth/scopes/workflows_read: b3b216cd6e77b26500068bdf7afad004
+ auth/oauth/scopes/workflows_write: c9ce0488b4188718bf4c235855ca5c5a
auth/oauth/unknown_client: 561bc5afdb90606fbc1ed97f20698ead
auth/password_compromised: d4ab0f66aaed039f956c2515328ed1c9
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
@@ -104,6 +106,8 @@ checksums:
auth/signup/title: 96addc349f834eaa5d14c786d5478b1c
auth/signup_without_verification_success/user_successfully_created: e7ee262de26fdbd500023d9fb1982cf9
auth/signup_without_verification_success/user_successfully_created_info: 0502de5e7ef8594cc65cf890f13f9fcc
+ auth/verification-requested/email_not_configured_description: bea45846d6b8bd77c7118bf9cde12021
+ auth/verification-requested/email_not_configured_title: 544c22243fe09611f559c33a7d3e105a
auth/verification-requested/invalid_email_address: b2d9f25626f2d15c7c63e0281bccc247
auth/verification-requested/invalid_token: 9008919416c117067e95efda0024655d
auth/verification-requested/new_email_verification_success: d76f50ad638163a1cb964a191bcf7176
@@ -122,6 +126,7 @@ checksums:
common/accepted: ea2ed23f35f8b090b5a994ac64ec588a
common/account: 01215c12fb1cdb93bd0c84c1382bef56
common/account_settings: df8e9882a1f5c75951f3a05ddfed72ba
+ common/act: b6e0bff3738c7597f2500bc124648103
common/action: c92af0bdf1698b0d10cf5b28d2ad4945
common/actions: c46571856723b03262fd33f511116298
common/actions_description: 8e35b1538d1006fa8470183310ad21ef
@@ -156,6 +161,7 @@ checksums:
common/archive: fa813ab3074103e5daad07462af25789
common/archived: cf5127ecfd7e43a35466a1ba5fe16450
common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
+ common/attempt: fbe2d20432424e164245e13ff9195310
common/attributes: 86d0ae6fea0fbb119722ed3841f8385a
common/authorized_apps: ffed21922e6e9c0975ddcde28e0a266b
common/back: f541015a827e37cb3b1234e56bc2aa3c
@@ -164,6 +170,7 @@ checksums:
common/bottom_left: af9c28e07d6a12af1f18bce2f580d93d
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade
+ common/canceled: 342a409089d71ce45725add1c62b0ade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
@@ -236,9 +243,11 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
+ common/editor: 15d1c1521efc08cd482f7ac9e1df0acf
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/enable: 463972a7a95f50f3105d09b92508f2cd
+ common/enabled: 20236664b7e62df0e767921b4450205f
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
@@ -249,6 +258,7 @@ checksums:
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
+ common/failed: 99f87615af1fffa2b8802866b096705a
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
@@ -259,6 +269,7 @@ checksums:
common/file_upload_service_unavailable: 93a6a904cef89cc18d2c4a65e2d581cc
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
+ common/finished_at: 05d2fa31cf3b2e2255729ec7898240c2
common/first_name: cf040a5d6a9fd696be400380cc99f54b
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
@@ -281,6 +292,7 @@ checksums:
common/imprint: c4e5f2a1994d3cc5896b200709cc499c
common/in_progress: 3de9afebcb9d4ce8ac42e14995f79ffd
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
+ common/input: c281c28cbb062bc3538cbd4a42d79cf6
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf
@@ -321,6 +333,7 @@ checksums:
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/name: 9368b5a047572b6051f334af5aa76819
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
+ common/new_workflow: 556be5b31c361973a19d3e2f7375d4f3
common/next: 89ddbcf710eba274963494f312bdc8a9
common/no: 8c708225830b06df2d1141c536f2a0d6
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
@@ -359,10 +372,12 @@ checksums:
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
+ common/output: c1e7f08b62c52a91234e00b88f52acc2
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
common/paused: edb1f7b7219e1c9b7aa67159090d6991
+ common/pending: 030a6f3395d5d4efddd3cc67d6009039
common/pending_downgrade: d6796fc1d4df21591c69fbbd39ba53ff
common/people_manager: c1a2f206157ec618f9fe74bf99a06b85
common/person: b6e3064ca6b67285dc1ebb2590d6094f
@@ -383,6 +398,7 @@ checksums:
common/question: 2a47e06b62410b16003c4979dee0099f
common/question_id: d0c3672976c281411bdccf749faf5ffd
common/questions: 38d08215fd7a8026077c7b64eea6bb59
+ common/queued: e19b621b39112ea373d249523599ac98
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
common/quotas: e6afead11b5b8ae627885ce2b84a548f
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
@@ -395,15 +411,20 @@ checksums:
common/replace: 98b2268975b1a737b2e4ad837df96703
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
+ common/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
common/reset_to_default: 68ee98b46677392f44b505b268053b26
common/resize: 20887e5af5294f08bc72cdedeee6e7a8
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
+ common/response_completed: d4c3d86374bb9ff3fef904ccf5c3a3af
common/response_id: 73375099cc976dc7203b8e27f5f709e0
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/retry: 6e44d18639560596569a1278f9c83676
common/role: 53743bbb6ca938f5b893552e839d067f
common/row_n: f90f7018a69f2d7025ad99a90bd23dc9
+ common/run_data: 5bad3e03035c08da471b768ba73e61cf
+ common/running: 010d4795c3d5df31edde92a3441d7017
+ common/runs: 8d49d75b6db650c168fb8465270b90fd
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
@@ -439,12 +460,16 @@ checksums:
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/sort_by: 8adf3dbc5668379558957662f0c43563
+ common/sort_by_value: 3a1cd12ad5811afe085f5751cae9ec6c
+ common/started_at: 16d19d3011b3045aadc90809d09eb820
common/status: 4e1fcce15854d824919b4a582c697c90
+ common/steps: 12d58a289584cb648103d66b327ae833
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
common/string: 4ddccc1974775ed7357f9beaf9361cec
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
common/subheader: 73a37d57cb9807e574a42bd0c7e334ed
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
+ common/succeeded: 5a6a378853fa4ad6790e5fcaf8695b7c
common/summary: 13eb7b8a239fb4702dfdaee69100a220
common/survey: b659d270a53dada994d926e0cc6e9a54
common/survey_completed: 5d1974ef76d4436daee96b2b76eddd20
@@ -477,8 +502,11 @@ checksums:
common/trial_expired: ca9f0532ac40ca427ca1ba4c86454e07
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
common/trial_plan_badge: b7928bd1938c56199e7d8aada43e587e
+ common/trigger: 25f7594d1ac2f32a3d2774dcd11dddfe
+ common/trigger_payload: 50e9c46ecee798b95d23abf3896b86e7
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
+ common/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
common/undo: 6fa10b811e2894dcdd73718f66c1b481
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
common/update: 079fc039262fd31b10532929685c2d1b
@@ -498,6 +526,7 @@ checksums:
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
common/view: 36a9b5e3dc153c036d320460d72a03c3
+ common/view_workflow: c3d3675fc33f5793cfe5fa86155224bb
common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c
common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9
common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
@@ -507,6 +536,9 @@ checksums:
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/whats_new: e426b9bfcc875da20c66192faa148206
+ common/workflow_name: faf88fb043779199c6bdcd78e45ff7c4
+ common/workflow_runs: 80c2d42cb9f4550f0f56ebe72c42a03d
+ common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
@@ -570,7 +602,6 @@ checksums:
emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f
emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9
emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9
- emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5
emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06
emails/select_a_date: 521e4a705800da06d091fde3e801ce02
emails/survey_response_finished_email_congrats: 4cc39698d6a16f68cf7d9db902c5978e
@@ -2358,13 +2389,23 @@ checksums:
workspace/settings/billing/comparison_row_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
workspace/settings/billing/comparison_row_unify_feedback: a5f13efd8f6df6163a2b4372b50d232e
workspace/settings/billing/comparison_row_unlimited_seats: 478092018a27a4e588cb5c70968ef11a
+ workspace/settings/billing/comparison_row_workflows: b0c9c8615a9ba7d9cb73e767290a7f72
workspace/settings/billing/comparison_row_workspaces: 8ba082a84aa35cf851af1cf874b853e2
workspace/settings/billing/comparison_section_all_plans: 2a4543269f18647b8b2bd5f5cb399c24
workspace/settings/billing/comparison_section_basic_usage: 16bd8c505c287b38de311ad6a2eaa3e9
workspace/settings/billing/comparison_section_pro_unlocks: 9064c532bdd5a57b6979575793772b35
workspace/settings/billing/comparison_section_scale_unlocks: 8d043ca2c2ff294dfc8ee41ab8830b09
- workspace/settings/billing/confirm_upgrade_body: 136720e5ee76ea2888f088201b306603
- workspace/settings/billing/confirm_upgrade_body_with_charge: ff81c2841016a54cd139aa00e3a7539c
+ workspace/settings/billing/confirm_hobby_downgrade_body: 9d890ad7c73f7c19b7ddb2c58a5193bd
+ workspace/settings/billing/confirm_hobby_downgrade_description: 263d21901fbb5741bc31f2dbcaacfce1
+ workspace/settings/billing/confirm_hobby_downgrade_title: ec0ad17a5e1c6823634ef7c383d94bd5
+ workspace/settings/billing/confirm_trial_continue_body: b5c3ed4d94b0ee2521e688f010479694
+ workspace/settings/billing/confirm_trial_continue_body_fallback: 6e2a07e8420c93bb378c531e0fb2ba01
+ workspace/settings/billing/confirm_trial_continue_description: 802d3a25618fa1922fdb5ffca0594817
+ workspace/settings/billing/confirm_trial_continue_pay_now: 1c1d95c36a21cfb3f321904465b6d13a
+ workspace/settings/billing/confirm_trial_continue_pay_now_generic: 2729650780f5a79693b940e68b07cb8e
+ workspace/settings/billing/confirm_trial_continue_title: fca0dbe7c96589106dd51ac40d4f4cc5
+ workspace/settings/billing/confirm_upgrade_body: bcaa12d47184cf7b93bbfdaeeba01f2b
+ workspace/settings/billing/confirm_upgrade_body_with_charge: 472cdde8bb2781af353ea4404124e538
workspace/settings/billing/confirm_upgrade_button: 0c91db4f2f989972d493fed2311cbbc0
workspace/settings/billing/confirm_upgrade_calculating: a738aa8430b5e2e8022784078d2c4ec3
workspace/settings/billing/confirm_upgrade_description: 802d3a25618fa1922fdb5ffca0594817
@@ -2372,7 +2413,6 @@ checksums:
workspace/settings/billing/contact_sales_cta: 5dc274e84dd0ac81892fe67bf66360fd
workspace/settings/billing/contact_sales_description: c79ce0fe56b31adcca9c888bdd4fcd52
workspace/settings/billing/contact_sales_title: 94537da970ffef6210bba8fcecb74a8c
- workspace/settings/billing/continue_with_plan_after_trial: 96f01459c998341b8467fe239e890875
workspace/settings/billing/current_plan_badge: 27f172f76ac28e72cb062f80002b0ad5
workspace/settings/billing/current_plan_cta: 53ac259fd40a361274861ee7c7498424
workspace/settings/billing/custom_plan_description: 53faa38123cc74e5adc7e59630641d66
@@ -2444,6 +2484,7 @@ checksums:
workspace/settings/billing/plan_scale_feature_responses: f2be033ebf6c86a664b812b4a918647f
workspace/settings/billing/plan_scale_feature_security: 6671961cf8d8413d1740b13901bcc033
workspace/settings/billing/plan_scale_feature_semantic_analysis: 1441e34cacd26f0aa27af4ebad6e5c54
+ workspace/settings/billing/plan_scale_feature_workflows: b0c9c8615a9ba7d9cb73e767290a7f72
workspace/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef
workspace/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599
workspace/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d
@@ -2471,21 +2512,21 @@ checksums:
workspace/settings/billing/switch_at_period_end: 9c91b2287886e077a0571efab8908623
workspace/settings/billing/switch_plan_now: dad56622a1916fe5d1a2bda5b0393194
workspace/settings/billing/this_includes: 127e0fe104f47886b54106a057a6b26f
- workspace/settings/billing/trial_alert_description: e8c20d27d8cd41690db0373463c2eacb
+ workspace/settings/billing/trial_alert_description: bd1374047e8bde3771cec7a8cfc45c42
workspace/settings/billing/trial_already_used: 5433347ff7647fe0aba0fe91a44560ba
workspace/settings/billing/trial_cancels_automatically: a6805d9b040b561f50e6deae244a600b
workspace/settings/billing/trial_ending_add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
workspace/settings/billing/trial_ending_description: 8be0ee811be739704ce92391593ca544
workspace/settings/billing/trial_ending_title: 3c2b88e6693450a7f38255076414eed5
- workspace/settings/billing/trial_payment_method_added_description: 917727553a379d52b8a9c8ebce370061
workspace/settings/billing/trial_warning_200_description: b62a8996f7002340e1ed89e37ca713d2
workspace/settings/billing/trial_warning_200_title: 804fae1197fba14b15e2d697e62f34cd
workspace/settings/billing/trial_warning_250_description: 8142e4b105b47443895a315e4b4a156e
workspace/settings/billing/trial_warning_250_title: 93d34846276f30d6b394a8a3a626a291
- workspace/settings/billing/trial_warning_add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
+ workspace/settings/billing/trial_warning_add_payment_method: 5e6879babc7acb05a258de64fef57262
workspace/settings/billing/trial_warning_remind_me_later: a12f38fb4352c31ca0d43c05303b7257
workspace/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
workspace/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
+ workspace/settings/billing/unlock_all_plan_features: f494466ed4d974763434fb15c0d63750
workspace/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
workspace/settings/billing/upgrade_checkout_pending: a518f2c29034c9de6ed60644b8219577
workspace/settings/billing/upgrade_checkout_success: ff3aa28f9db371c88c1ee456465a54e7
@@ -3048,7 +3089,10 @@ checksums:
workspace/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
workspace/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
workspace/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88
+ workspace/surveys/edit/follow_ups_modal_action_email_already_added: 14fdaf283e2f26c7fca18c803a1afd58
workspace/surveys/edit/follow_ups_modal_action_email_content: 9825583500908e6b16f7ffffb5a3aef4
+ workspace/surveys/edit/follow_ups_modal_action_email_input_placeholder: ea0d5679e1d1c3219ca3c61fd9afdabe
+ workspace/surveys/edit/follow_ups_modal_action_email_invalid: 8de4bc8832b11b380bc4cbcedc16e48b
workspace/surveys/edit/follow_ups_modal_action_email_settings: 18728b7e2096854c12f442e323dc10c0
workspace/surveys/edit/follow_ups_modal_action_from_description: 6bc80080a4a3513e0e7c7e7194b2d4ad
workspace/surveys/edit/follow_ups_modal_action_from_label: 3d84daca8c92c8609deeab4b294b4afb
@@ -3076,6 +3120,7 @@ checksums:
workspace/surveys/edit/follow_ups_modal_trigger_type_response: 8b0e49e76ba09241f512201871bef0f2
workspace/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
workspace/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
+ workspace/surveys/edit/follow_ups_workflows_alert_title: eeeab44d06dae5afe46c6b990154a1c6
workspace/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
workspace/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
workspace/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
@@ -3973,6 +4018,117 @@ checksums:
workspace/unify/value_id: 5e0222661639b5ef6aab7ccff11363a1
workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675
workspace/unify/value_text: e097a597cc507c716401ad18255de578
+ workspace/workflows/add_action: 66fefc4dd6a7b939c2224272cf0d2669
+ workspace/workflows/add_trigger: c905d42a09d57b725c9dbad1837ad981
+ workspace/workflows/add_trigger_description: 4a99aa50c2d2ed4c1a5f8379d9277766
+ workspace/workflows/all_changes_saved: 1aeb862fc41b69307e3acc682b41818b
+ workspace/workflows/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
+ workspace/workflows/archive_confirm_body: faab86aaacb71d5b5dd7b63c00f534c3
+ workspace/workflows/archive_confirm_title: 2ddbf492c023f8fef2df49a008afbcd4
+ workspace/workflows/archive_failed: 111d35d82bc91d3e279f588a7d849d38
+ workspace/workflows/archive_success: e0e280e53988b78d0f8caf26c8ea006b
+ workspace/workflows/archive_workflow: 728063f8bfe0b9eb1c64bee9772db71d
+ workspace/workflows/archive_workflow_confirmation: 83f351c186e67a3c5308af82bf971d65
+ workspace/workflows/archive_workflow_description: 06e437998a762e504d7d0101b0c53565
+ workspace/workflows/auto_layout: 553d4f054655684130a8b5bc8f800bf0
+ workspace/workflows/autosave_failed: 5d9b22b31b83828d5eeaeae87d2b6d14
+ workspace/workflows/autosave_failed_tooltip: f4cb6255fd863b741275a7ff6d2701c6
+ workspace/workflows/autosave_failed_tooltip_rejected: 5f90aa698c936629a7b959520dc9358e
+ workspace/workflows/collapse_inspector: d3bd60a39c7d42e98c78df07c756ac22
+ workspace/workflows/create_failed: b67532a43f2b040b3738ffba1d3c0ce7
+ workspace/workflows/delete_failed: df67d2e7a4952b19c8371e592ea03c65
+ workspace/workflows/delete_success: 4b203fb057b84c888518b5ee885d7533
+ workspace/workflows/delete_workflow_confirmation: c282ef533424f60f04f8ff2da3354eb7
+ workspace/workflows/disable_failed: 02640d94b113075d6e03d50dac8be8a3
+ workspace/workflows/disable_success: d107eb1a5c608c93695eecbc31bf26df
+ workspace/workflows/duplicate_failed: 56b15c01ba3e3fafbfd226520023204f
+ workspace/workflows/duplicate_success: b94c796f27301d2957a710dc06e73679
+ workspace/workflows/edit_blocked_active: 11f02e65cffdf7a8533906eff1de4ffb
+ workspace/workflows/email_attach_response_data_description: 7d312092941888d63361690ee8706102
+ workspace/workflows/email_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
+ workspace/workflows/email_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
+ workspace/workflows/email_body_placeholder: 5d75f0b732254eff49d0e8c5c40de7cd
+ workspace/workflows/email_body_required: ec8193d07de034ad764978b538d49d06
+ workspace/workflows/email_from_label: 3d84daca8c92c8609deeab4b294b4afb
+ workspace/workflows/email_include_hidden_fields_label: 8b72f10c43491126650c723819faa454
+ workspace/workflows/email_include_variables_label: 9710532ac5935c4540bf98bc292c1166
+ workspace/workflows/email_needs_survey: 8d8843fe947bac3ef178d13ef96940fe
+ workspace/workflows/email_reply_to_label: e7f83bcc57c1e4248e6835477cd8e28a
+ workspace/workflows/email_set_up_trigger: fbfe074697929bec889438a92497c8cb
+ workspace/workflows/email_subject_label: de5b885eb327b2f233f3b67aab4c4c0a
+ workspace/workflows/email_subject_placeholder: 9cd2b8d4de6fb9daade571497bc39864
+ workspace/workflows/email_subject_required: e6eb6e2f6f952f58bcbc6931ca303079
+ workspace/workflows/email_to_label: fc84f35b3c44796dfcdd4b096c9b8d3d
+ workspace/workflows/email_to_placeholder: 1323bfdf1926a863c93bc0b37ae61218
+ workspace/workflows/email_to_required: d29dd2c5bdebdde1047f4baae12269a4
+ workspace/workflows/enable_blocked_unsaved_changes: 07fc359db9158b09d612f4f0e0e2df5e
+ workspace/workflows/enable_failed: 997437c65cc018c4d3cf7622e043d48d
+ workspace/workflows/enable_success: 384d5e80e013c74f8e092f41ed5bae5c
+ workspace/workflows/expand_inspector: 8d05989d5b8c5b6012b5c3e2651c0a92
+ workspace/workflows/if_else: a9f31cee9ec7c33fa80d6f8dedf9060a
+ workspace/workflows/if_else_summary: f166c79dcfd8a9595ac364c96caaf7e1
+ workspace/workflows/inspector_unsupported_node: 478f0de84863ccf95702428b1cc95be0
+ workspace/workflows/load_failed: 4e79a59e05cfc390294673e4aa111f02
+ workspace/workflows/name_required: a2866fa94293bc08be10ef65e0939423
+ workspace/workflows/no_results_description: b358733531cc8290f8b311a8cc3bf05d
+ workspace/workflows/no_results_title: e3f2c57c4024d33a20f192d45284d709
+ workspace/workflows/no_workflows_description: 8a8152d86459e52a07e1ab28bd347121
+ workspace/workflows/no_workflows_title: f1a5ada694cc4d773d2b56b105306a09
+ workspace/workflows/node_actions: 60413ce1864388744a65164a552d5079
+ workspace/workflows/node_needs_email_content: 46bee688ab2d335136684536d8fdf69f
+ workspace/workflows/node_needs_survey: 5e51ca0392e98011a8a72e75a223e5dd
+ workspace/workflows/pan_mode: 7d97982816ad18072903021c89153bce
+ workspace/workflows/pointer_mode: 7af7ee96c6d8e9a15f0569c1b2376def
+ workspace/workflows/read_only: 9109fd6e72125af271d41181f1f35dd2
+ workspace/workflows/relative_date: b9249095eeb4436a1d0d5ca5f9ecd346
+ workspace/workflows/relative_days_ago: d9da4648c4fa3cb86584d3f9f48df6a2
+ workspace/workflows/relative_today: 86945b299a1fb38b11917873dd0bea40
+ workspace/workflows/relative_yesterday: dfc275052c70eb9e21522e13a7e77e1e
+ workspace/workflows/response_completed: d4c3d86374bb9ff3fef904ccf5c3a3af
+ workspace/workflows/response_completed_description: 2b7facad97819a57c830e16bcf8dd582
+ workspace/workflows/save_failed: 959fe0d656afa7990c9fc476f2a4c7bf
+ workspace/workflows/save_success: 95ac20f8626471d0372614fb0831e016
+ workspace/workflows/saving_changes: ee98ff32f9a585a9108c8cd961d6077e
+ workspace/workflows/search_by_workflow_name: 9ce39ab494b1fa76252aa9bee55d22f8
+ workspace/workflows/send_email: 0ef83c0bb40de25921a9ee7fa05babec
+ workspace/workflows/send_email_description: a05ecd60166cdb3e3667e674afae556b
+ workspace/workflows/send_email_summary: 23c31e7b1539ee670994c0e70cf7fdfe
+ workspace/workflows/send_email_unconfigured: 306f5a6768ec08c316c3234ce11f8b32
+ workspace/workflows/trigger_ending_cards_label: 376d9519a9eb7c4dff7a6c1ab4d7d3ae
+ workspace/workflows/trigger_ending_cards_none: baf1774aad23796cc5325989015e0487
+ workspace/workflows/trigger_ending_cards_pick_survey: 8238b366d1bf21f1192110e6963939d7
+ workspace/workflows/trigger_ending_cards_scope_all: 7f134a310395059accd3fd141af9b345
+ workspace/workflows/trigger_ending_cards_scope_specific: 5db478fd10c210d5a3def79b670f0d43
+ workspace/workflows/trigger_ending_cards_select_at_least_one: 3d7d6a487662d8c5ad84703fb8f5ad7e
+ workspace/workflows/trigger_summary_all_endings: 00342d8a7bd08cefd89dae7d6f88cf5b
+ workspace/workflows/trigger_summary_ending_cards: efbd2748e2d99cc1c624f54b9a7779b0
+ workspace/workflows/trigger_survey_description: 29db20a64eeb3fc7b690f72883f7f538
+ workspace/workflows/trigger_survey_empty: 922f064c2082c3e71fc17001ef61cc55
+ workspace/workflows/trigger_survey_label: b659d270a53dada994d926e0cc6e9a54
+ workspace/workflows/trigger_survey_placeholder: 1f49086dfb874307aae1136e88c3d514
+ workspace/workflows/triggers: 66488f38662a4199fb8a18967239c992
+ workspace/workflows/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
+ workspace/workflows/unarchive_failed: 16568ba93941e8e71f9b4cc997d5eaf2
+ workspace/workflows/unarchive_success: b1ae787ba6e1d4e9e8631ad35d73e5ee
+ workspace/workflows/upgrade_prompt_description: 526faca22b7a0b613fdce0ace49c0aa7
+ workspace/workflows/upgrade_prompt_title: 5546aa81302c33f2b53857b9c62c16e2
+ workspace/workflows/validation_failed: fbba0e27eca222e81a86d3036919d11a
+ workspace/workflows/validation_problem_fix_label: fe748ab52e0ad49e8a96247ec5814bb2
+ workspace/workflows/validation_problem_flow_invalid: e2aaaf90f60d72553f93c3153202c542
+ workspace/workflows/validation_problem_generic: a58601a8dd434a0ee3f8cdd4aca86213
+ workspace/workflows/validation_problem_name_missing: b116eed6f771dff219090d142c1f6bd1
+ workspace/workflows/validation_problem_step_incomplete: 7736f8e9de3307aed23ec50790122aa0
+ workspace/workflows/validation_problem_step_not_executable: 144beddd4ac41cececf07882089f08d8
+ workspace/workflows/validation_problem_trigger_ending_not_found: 2a288324d1a1c0c96dfb24ec7df1111b
+ workspace/workflows/validation_problem_trigger_missing: 7a9243dac736efe6023063de872b716f
+ workspace/workflows/validation_problem_trigger_not_connected: 69a851839a4179bb9c2e995140b2ba93
+ workspace/workflows/validation_problem_trigger_survey_unbound: ad18f1043275f229a8d4d2ff5ffc4e45
+ workspace/workflows/validation_problems_count: 1855046d8540d697167fa8d2be11beba
+ workspace/workflows/validation_problems_description: 3d86d4b78cd9444a4d0161e13c29523f
+ workspace/workflows/validation_problems_title: dca6132844fcd065bc5ab7fd2dd3ca40
+ workspace/workflows/validation_status_valid: 4a3ddc0ebe876ff26001201203348d2c
+ workspace/workflows/zoom_in: 20244d74cc44e8606cbf4bb8b4e16e4b
+ workspace/workflows/zoom_out: b723e467be28d8659338502c470821fd
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
workspace/xm-templates/ces_description: d90ab573eed017c45e45527a325c9bda
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
diff --git a/apps/web/instrumentation-jobs.test.ts b/apps/web/instrumentation-jobs.test.ts
index 1bfb1a11cb36..ae6998069df7 100644
--- a/apps/web/instrumentation-jobs.test.ts
+++ b/apps/web/instrumentation-jobs.test.ts
@@ -13,6 +13,10 @@ const mockGetJobsWorkerBootstrapConfig = vi.fn();
const mockProcessResponsePipelineJob = vi.fn();
const mockProcessSurveySchedulingJob = vi.fn();
const mockProcessSurveyArchivePurgeJob = vi.fn();
+const mockProcessWorkflowRunJob = vi.fn();
+const mockRemoveRecurringWorkflowRunReconcileJobSchedule = vi.fn();
+const mockUpsertRecurringWorkflowRunReconcileJobSchedule = vi.fn();
+const mockProcessWorkflowRunReconcileJob = vi.fn();
const TEST_TIMEOUT_MS = 15_000;
const slowTest = (name: string, fn: () => Promise): void => {
@@ -22,9 +26,11 @@ const slowTest = (name: string, fn: () => Promise): void => {
vi.mock("@formbricks/jobs", () => ({
removeRecurringSurveySchedulingJobSchedule: mockRemoveRecurringSurveySchedulingJobSchedule,
removeRecurringSurveyArchivePurgeJobSchedule: mockRemoveRecurringSurveyArchivePurgeJobSchedule,
+ removeRecurringWorkflowRunReconcileJobSchedule: mockRemoveRecurringWorkflowRunReconcileJobSchedule,
startJobsRuntime: mockStartJobsRuntime,
upsertRecurringSurveySchedulingJobSchedule: mockUpsertRecurringSurveySchedulingJobSchedule,
upsertRecurringSurveyArchivePurgeJobSchedule: mockUpsertRecurringSurveyArchivePurgeJobSchedule,
+ upsertRecurringWorkflowRunReconcileJobSchedule: mockUpsertRecurringWorkflowRunReconcileJobSchedule,
}));
vi.mock("@/lib/jobs/config", () => ({
@@ -53,6 +59,14 @@ vi.mock("@/modules/survey/archive/lib/process-survey-archive-purge-job", () => (
processSurveyArchivePurgeJob: mockProcessSurveyArchivePurgeJob,
}));
+vi.mock("@/modules/ee/workflows/lib/runner/process-workflow-run-job", () => ({
+ processWorkflowRunJob: mockProcessWorkflowRunJob,
+}));
+
+vi.mock("@/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job", () => ({
+ processWorkflowRunReconcileJob: mockProcessWorkflowRunReconcileJob,
+}));
+
describe("instrumentation-jobs", () => {
beforeEach(() => {
vi.resetModules();
@@ -65,6 +79,7 @@ describe("instrumentation-jobs", () => {
name: "survey-archive-purge.process",
queueName: "background-jobs",
});
+ mockRemoveRecurringWorkflowRunReconcileJobSchedule.mockResolvedValue(true);
mockGetJobsQueueingConfig.mockReturnValue({
enabled: false,
redisUrl: null,
@@ -124,7 +139,9 @@ describe("instrumentation-jobs", () => {
"response-pipeline.process": expect.any(Function),
"survey-scheduling.reconcile": expect.any(Function),
"survey-archive-purge.process": expect.any(Function),
+ "workflow-run.process": expect.any(Function),
"test-log.process": mockExistingOverride,
+ "workflow-run.reconcile": expect.any(Function),
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
@@ -132,6 +149,7 @@ describe("instrumentation-jobs", () => {
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
const responsePipelineOverride = overrides?.["response-pipeline.process"];
const surveySchedulingOverride = overrides?.["survey-scheduling.reconcile"];
+ const workflowRunOverride = overrides?.["workflow-run.process"];
await responsePipelineOverride?.(
{
@@ -160,6 +178,20 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
+ await workflowRunOverride?.(
+ {
+ workflowRunId: "run_123",
+ workflowId: "wf_123",
+ workspaceId: "ws_123",
+ },
+ {
+ attempt: 1,
+ jobId: "job_789",
+ jobName: "workflow-run.process",
+ maxAttempts: 3,
+ queueName: "background-jobs",
+ }
+ );
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
{
@@ -188,6 +220,42 @@ describe("instrumentation-jobs", () => {
queueName: "background-jobs",
}
);
+
+ const workflowRunReconcileOverride = overrides?.["workflow-run.reconcile"];
+ await workflowRunReconcileOverride?.(
+ { scope: "global" },
+ {
+ attempt: 1,
+ jobId: "job_789",
+ jobName: "workflow-run.reconcile",
+ maxAttempts: 3,
+ queueName: "background-jobs",
+ }
+ );
+ expect(mockProcessWorkflowRunJob).toHaveBeenCalledWith(
+ {
+ workflowRunId: "run_123",
+ workflowId: "wf_123",
+ workspaceId: "ws_123",
+ },
+ {
+ attempt: 1,
+ jobId: "job_789",
+ jobName: "workflow-run.process",
+ maxAttempts: 3,
+ queueName: "background-jobs",
+ }
+ );
+ expect(mockProcessWorkflowRunReconcileJob).toHaveBeenCalledWith(
+ { scope: "global" },
+ {
+ attempt: 1,
+ jobId: "job_789",
+ jobName: "workflow-run.reconcile",
+ maxAttempts: 3,
+ queueName: "background-jobs",
+ }
+ );
});
slowTest("reuses the in-flight startup promise", async () => {
@@ -288,12 +356,19 @@ describe("instrumentation-jobs", () => {
name: "survey-scheduling.reconcile",
queueName: "background-jobs",
});
+ mockUpsertRecurringWorkflowRunReconcileJobSchedule.mockResolvedValue({
+ id: "schedule-job-2",
+ name: "workflow-run.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");
const { SURVEY_ARCHIVE_PURGE_DAILY_CRON_PATTERN, SURVEY_ARCHIVE_PURGE_TIME_ZONE } =
await import("@/modules/survey/archive/lib/constants");
+ const { WORKFLOW_RUN_RECONCILE_INTERVAL_MS } =
+ await import("@/modules/ee/workflows/lib/runner/reconcile-constants");
await registerRecurringJobs();
await registerRecurringJobs();
@@ -339,6 +414,25 @@ describe("instrumentation-jobs", () => {
scope: "global",
}
);
+ expect(mockRemoveRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledTimes(1);
+ expect(mockRemoveRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledWith({
+ scheduleId: "workflow-run-reconcile",
+ scope: "global",
+ });
+ expect(mockUpsertRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledTimes(1);
+ expect(mockUpsertRecurringWorkflowRunReconcileJobSchedule).toHaveBeenCalledWith(
+ {
+ scheduleId: "workflow-run-reconcile",
+ scope: "global",
+ },
+ {
+ everyMs: WORKFLOW_RUN_RECONCILE_INTERVAL_MS,
+ kind: "every",
+ },
+ {
+ scope: "global",
+ }
+ );
}
);
diff --git a/apps/web/instrumentation-jobs.ts b/apps/web/instrumentation-jobs.ts
index 82a0b77a1584..e4033b1fdcd2 100644
--- a/apps/web/instrumentation-jobs.ts
+++ b/apps/web/instrumentation-jobs.ts
@@ -4,14 +4,25 @@ import {
type TResponsePipelineJobData,
type TSurveyArchivePurgeJobData,
type TSurveySchedulingJobData,
+ type TWorkflowRunJobData,
+ type TWorkflowRunReconcileJobData,
removeRecurringSurveyArchivePurgeJobSchedule,
removeRecurringSurveySchedulingJobSchedule,
+ removeRecurringWorkflowRunReconcileJobSchedule,
startJobsRuntime,
upsertRecurringSurveyArchivePurgeJobSchedule,
upsertRecurringSurveySchedulingJobSchedule,
+ upsertRecurringWorkflowRunReconcileJobSchedule,
} from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } from "@/lib/jobs/config";
+import { processWorkflowRunJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-job";
+import { processWorkflowRunReconcileJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job";
+import {
+ WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE,
+ WORKFLOW_RUN_RECONCILE_INTERVAL_MS,
+ WORKFLOW_RUN_RECONCILE_SCHEDULE_ID,
+} from "@/modules/ee/workflows/lib/runner/reconcile-constants";
import { processResponsePipelineJob } from "@/modules/response-pipeline/lib/process-response-pipeline-job";
import {
SURVEY_ARCHIVE_PURGE_DAILY_CRON_PATTERN,
@@ -43,6 +54,8 @@ const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
const SURVEY_SCHEDULING_JOB_NAME = "survey-scheduling.reconcile";
const SURVEY_ARCHIVE_PURGE_JOB_NAME = "survey-archive-purge.process";
+const WORKFLOW_RUN_JOB_NAME = "workflow-run.process";
+const WORKFLOW_RUN_RECONCILE_JOB_NAME = "workflow-run.reconcile";
const responsePipelineJobHandler: NonNullable = async (data, context) => {
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
@@ -53,6 +66,12 @@ const surveySchedulingJobHandler: NonNullable = asy
const surveyArchivePurgeJobHandler: NonNullable = async (data, context) => {
await processSurveyArchivePurgeJob(data as TSurveyArchivePurgeJobData, context);
};
+const workflowRunJobHandler: NonNullable = async (data, context) => {
+ await processWorkflowRunJob(data as TWorkflowRunJobData, context);
+};
+const workflowRunReconcileJobHandler: NonNullable = async (data, context) => {
+ await processWorkflowRunReconcileJob(data as TWorkflowRunReconcileJobData, context);
+};
const registerSurveySchedulingSchedule = async (): Promise => {
await removeRecurringSurveySchedulingJobSchedule({
@@ -98,6 +117,27 @@ const registerSurveyArchivePurgeSchedule = async (): Promise => {
);
};
+const registerWorkflowRunReconcileSchedule = async (): Promise => {
+ await removeRecurringWorkflowRunReconcileJobSchedule({
+ scheduleId: WORKFLOW_RUN_RECONCILE_SCHEDULE_ID,
+ scope: WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE,
+ });
+
+ await upsertRecurringWorkflowRunReconcileJobSchedule(
+ {
+ scheduleId: WORKFLOW_RUN_RECONCILE_SCHEDULE_ID,
+ scope: WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE,
+ },
+ {
+ everyMs: WORKFLOW_RUN_RECONCILE_INTERVAL_MS,
+ kind: "every",
+ },
+ {
+ scope: WORKFLOW_RUN_RECONCILE_GLOBAL_SCOPE,
+ }
+ );
+};
+
const clearRecurringJobsRetryTimeout = (): void => {
if (globalForJobsRuntime.formbricksJobsRecurringRetryTimeout) {
clearTimeout(globalForJobsRuntime.formbricksJobsRecurringRetryTimeout);
@@ -169,6 +209,7 @@ export const registerRecurringJobs = async (): Promise => {
globalForJobsRuntime.formbricksJobsRecurringRegistration = (async () => {
await registerSurveySchedulingSchedule();
await registerSurveyArchivePurgeSchedule();
+ await registerWorkflowRunReconcileSchedule();
clearRecurringJobsRetryTimeout();
globalForJobsRuntime.formbricksJobsRecurringRegistered = true;
globalForJobsRuntime.formbricksJobsRecurringRegistration = undefined;
@@ -208,11 +249,15 @@ export const registerJobsWorker = async (): Promise =>
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[SURVEY_ARCHIVE_PURGE_JOB_NAME]: surveyArchivePurgeJobHandler,
+ [WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
+ [WORKFLOW_RUN_RECONCILE_JOB_NAME]: workflowRunReconcileJobHandler,
}
: {
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
[SURVEY_SCHEDULING_JOB_NAME]: surveySchedulingJobHandler,
[SURVEY_ARCHIVE_PURGE_JOB_NAME]: surveyArchivePurgeJobHandler,
+ [WORKFLOW_RUN_JOB_NAME]: workflowRunJobHandler,
+ [WORKFLOW_RUN_RECONCILE_JOB_NAME]: workflowRunReconcileJobHandler,
};
globalForJobsRuntime.formbricksJobsRuntimeInitializing = (async () => {
diff --git a/apps/web/integration/gen-boolean-client.mjs b/apps/web/integration/gen-boolean-client.mjs
index 99f77d63d714..7da92e82cc44 100644
--- a/apps/web/integration/gen-boolean-client.mjs
+++ b/apps/web/integration/gen-boolean-client.mjs
@@ -1,50 +1,70 @@
// Generates a parallel Prisma client whose `emailVerified` is Boolean and `Account.type` is optional
// — i.e. the POST-CUTOVER shape Better Auth reads/writes (ENG-1054). The integration harness aliases
// @formbricks/database to a shim backed by this client so BA's real user/account creation works
-// against a real Postgres before the live schema is flipped. Derived from schema.prisma so it never
-// drifts. Output (generated/prisma-test) is gitignored. Run via `pnpm test:integration`.
+// against a real Postgres before the live schema is flipped. Derived from the multi-file schema in
+// packages/database/schema/ so it never drifts. Output (generated/prisma-test) is gitignored. Run via
+// `pnpm test:integration`.
import { execFileSync } from "node:child_process";
-import { readFileSync, writeFileSync } from "node:fs";
+import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { delimiter, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const dbDir = resolve(here, "../../../packages/database");
-const srcSchema = resolve(dbDir, "schema.prisma");
-const testSchema = resolve(dbDir, "schema.test-boolean.prisma");
+const srcSchemaDir = resolve(dbDir, "schema");
+const testSchemaDir = resolve(dbDir, "schema-test-boolean");
-let schema = readFileSync(srcSchema, "utf8");
+rmSync(testSchemaDir, { recursive: true, force: true });
+mkdirSync(testSchemaDir, { recursive: true });
-const before = schema;
-// 1. separate output dir so the real client is never clobbered
-schema = schema.replace('"./generated/prisma"', '"./generated/prisma-test"');
-// 2. emailVerified Date → Boolean (what BA writes)
-schema = schema.replace(
- 'emailVerified DateTime? @map(name: "email_verified")',
- 'emailVerified Boolean @default(false) @map(name: "email_verified")'
-);
-// 3. Account.type optional (BA creates accounts without a `type`), scoped to the Account model.
-// Locate the block by index (no schema-spanning regex) and patch only its `type` field. The
-// intra-line whitespace classes are `[ \t]` (never `\n`), so the match can't run across lines —
-// that's what keeps it linear; a `[\s\S]*?`/`\s*` pattern backtracks super-linearly because those
-// classes also match newlines. No-ops cleanly if `type` is already `String?`.
-const accountStart = schema.indexOf("model Account {");
-const accountEnd = accountStart === -1 ? -1 : schema.indexOf("}", accountStart);
-if (accountStart === -1 || accountEnd === -1) {
- throw new Error("gen-boolean-client: Account model block not found — schema.prisma shape changed; update this script.");
+let patchedAny = false;
+let accountFound = false;
+
+for (const file of readdirSync(srcSchemaDir)) {
+ if (!file.endsWith(".prisma")) continue;
+ let schema = readFileSync(resolve(srcSchemaDir, file), "utf8");
+ const before = schema;
+
+ // 1. separate output dir so the real client is never clobbered (path is relative to the schema dir)
+ schema = schema.replace('"../generated/prisma"', '"../generated/prisma-test"');
+ // 2. emailVerified Date → Boolean (what BA writes); no-ops once the live schema is flipped
+ schema = schema.replace(
+ 'emailVerified DateTime? @map(name: "email_verified")',
+ 'emailVerified Boolean @default(false) @map(name: "email_verified")'
+ );
+ // 3. Account.type optional (BA creates accounts without a `type`), scoped to the Account model.
+ // Locate the block by index (no schema-spanning regex) and patch only its `type` field. The
+ // intra-line whitespace classes are `[ \t]` (never `\n`), so the match can't run across lines —
+ // that's what keeps it linear; a `[\s\S]*?`/`\s*` pattern backtracks super-linearly because those
+ // classes also match newlines. No-ops cleanly if `type` is already `String?`.
+ const accountStart = schema.indexOf("model Account {");
+ if (accountStart !== -1) {
+ accountFound = true;
+ const accountEnd = schema.indexOf("}", accountStart);
+ if (accountEnd === -1) {
+ throw new Error(
+ "gen-boolean-client: Account model block not terminated — schema shape changed; update this script."
+ );
+ }
+ schema =
+ schema.slice(0, accountStart) +
+ schema.slice(accountStart, accountEnd).replace(/\n([ \t]*)type([ \t]+)String(\s)/, "\n$1type$2String?$3") +
+ schema.slice(accountEnd);
+ }
+
+ if (schema !== before) patchedAny = true;
+ writeFileSync(resolve(testSchemaDir, file), schema);
}
-schema =
- schema.slice(0, accountStart) +
- schema.slice(accountStart, accountEnd).replace(/\n([ \t]*)type([ \t]+)String(\s)/, "\n$1type$2String?$3") +
- schema.slice(accountEnd);
-if (schema === before) {
- throw new Error("gen-boolean-client: no replacements applied — schema.prisma shape changed; update this script.");
+if (!accountFound) {
+ throw new Error("gen-boolean-client: Account model block not found — schema shape changed; update this script.");
+}
+if (!patchedAny) {
+ throw new Error("gen-boolean-client: no replacements applied — schema shape changed; update this script.");
}
-writeFileSync(testSchema, schema);
-console.log("[gen-boolean-client] derived", testSchema);
+console.log("[gen-boolean-client] derived", testSchemaDir);
// Invoke the Prisma CLI directly through Node by ABSOLUTE path, rather than `pnpm exec prisma`:
// - process.execPath is a fixed, unwriteable path to the running Node binary, and the CLI path is
@@ -62,7 +82,7 @@ const prismaBin = typeof prismaPkg.bin === "string" ? prismaPkg.bin : prismaPkg.
const prismaDir = dirname(prismaPkgJson);
const prismaCli = resolve(prismaDir, prismaBin);
const nodeModulesBin = resolve(prismaDir, "../.bin");
-execFileSync(process.execPath, [prismaCli, "generate", "--schema", testSchema], {
+execFileSync(process.execPath, [prismaCli, "generate", "--schema", testSchemaDir], {
cwd: dbDir,
stdio: "inherit",
env: { ...process.env, PATH: `${nodeModulesBin}${delimiter}${process.env.PATH ?? ""}` },
diff --git a/apps/web/integration/setup.ts b/apps/web/integration/setup.ts
index a673dfe2c531..b35476369813 100644
--- a/apps/web/integration/setup.ts
+++ b/apps/web/integration/setup.ts
@@ -27,10 +27,36 @@ process.env.PASSWORD_HIBP_CHECK_DISABLED ??= "1";
// server-only is a Next.js build guard; no-op it under vitest.
vi.mock("server-only", () => ({}));
-// Capture transactional emails instead of sending via SMTP.
+// Capture transactional emails instead of sending via SMTP. These resolve `true` because the real
+// senders return Promise and a FALSY result means "not sent" — auth.ts treats that as a send
+// failure (ENG-2091), so a mock resolving undefined would fake an outage.
+//
+// Keep this the ONE place the module is mocked for integration tests. A per-file `vi.mock` of the same
+// module replaces this wholesale, so an incomplete copy silently drops senders or gets their return
+// type wrong — which is exactly how the undefined-vs-boolean bug above got in. Add senders here.
vi.mock("@/modules/email", () => ({
- sendVerificationLinkEmail: vi.fn(async () => undefined),
- sendPasswordResetLinkEmail: vi.fn(async () => undefined),
- sendPasswordResetNotifyEmail: vi.fn(async () => undefined),
- sendDeleteAccountConfirmationEmail: vi.fn(async () => undefined),
+ sendVerificationLinkEmail: vi.fn(async () => true),
+ sendPasswordResetLinkEmail: vi.fn(async () => true),
+ sendPasswordResetNotifyEmail: vi.fn(async () => true),
+ sendDeleteAccountConfirmationEmail: vi.fn(async () => true),
+ sendInviteAcceptedEmail: vi.fn(async () => undefined), // returns void, not boolean
+}));
+
+/**
+ * Analytics: stub only the exports that would do network I/O, and spread the rest so PURE helpers keep
+ * their real behaviour — `getEmailDomain` computes a property value, and stubbing it to `undefined`
+ * would quietly change what the code under test captures rather than just silencing a send.
+ *
+ * Spreading the real module is safe here: `posthogServerClient` is `null` without POSTHOG_KEY, and
+ * `server-only` is no-op'd above.
+ *
+ * Same rule as the mailer above — keep this the ONE place the module is mocked. Five integration files
+ * each had their own partial factory listing exports by hand, so when #8605 added `getEmailDomain` they
+ * all failed with "No export is defined on the mock". A spread cannot drift that way.
+ */
+vi.mock("@/lib/posthog", async (importOriginal) => ({
+ ...(await importOriginal()),
+ capturePostHogEvent: vi.fn(),
+ identifyPostHogPerson: vi.fn(),
+ groupIdentifyPostHog: vi.fn(),
}));
diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts
index 297501eeacdb..23feb36fb203 100644
--- a/apps/web/lib/constants.ts
+++ b/apps/web/lib/constants.ts
@@ -85,6 +85,13 @@ export const AIRTABLE_CLIENT_ID = env.AIRTABLE_CLIENT_ID;
export const SMTP_HOST = env.SMTP_HOST;
export const SMTP_PORT = env.SMTP_PORT;
+
+/**
+ * Whether the mailer can actually send. `sendEmail` returns `false` without throwing when this is
+ * false, which callers must treat as a failure (ENG-2091) — so it lives here next to the values it
+ * derives from rather than being recomputed per call site.
+ */
+export const IS_SMTP_CONFIGURED = Boolean(env.SMTP_HOST && env.SMTP_PORT);
export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1" || env.SMTP_PORT === "465";
export const SMTP_USER = env.SMTP_USER;
export const SMTP_PASSWORD = env.SMTP_PASSWORD;
diff --git a/apps/web/lib/jobs/pool-exhaustion.test.ts b/apps/web/lib/jobs/pool-exhaustion.test.ts
new file mode 100644
index 000000000000..f0d772199ea8
--- /dev/null
+++ b/apps/web/lib/jobs/pool-exhaustion.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "vitest";
+import { Prisma } from "@formbricks/database/prisma";
+import { DatabaseError } from "@formbricks/types/errors";
+import { isDatabasePoolExhaustionError } from "./pool-exhaustion";
+
+describe("isDatabasePoolExhaustionError", () => {
+ test("is true for the Prisma P2024 pool-timeout code", () => {
+ const error = new Prisma.PrismaClientKnownRequestError("pool timeout", {
+ code: "P2024",
+ clientVersion: "test",
+ });
+ expect(isDatabasePoolExhaustionError(error)).toBe(true);
+ });
+
+ test("is true for a connection-pool timeout message (plain Error or DatabaseError)", () => {
+ expect(
+ isDatabasePoolExhaustionError(new Error("Timed out fetching a new connection from the connection pool"))
+ ).toBe(true);
+ expect(isDatabasePoolExhaustionError(new DatabaseError("connection pool timeout while querying"))).toBe(
+ true
+ );
+ });
+
+ test("is false for other Prisma codes, unrelated messages, and non-errors", () => {
+ const notFound = new Prisma.PrismaClientKnownRequestError("not found", {
+ code: "P2025",
+ clientVersion: "test",
+ });
+ expect(isDatabasePoolExhaustionError(notFound)).toBe(false);
+ expect(isDatabasePoolExhaustionError(new Error("something unrelated"))).toBe(false);
+ expect(isDatabasePoolExhaustionError("nope")).toBe(false);
+ expect(isDatabasePoolExhaustionError(null)).toBe(false);
+ expect(isDatabasePoolExhaustionError(undefined)).toBe(false);
+ });
+});
diff --git a/apps/web/lib/jobs/pool-exhaustion.ts b/apps/web/lib/jobs/pool-exhaustion.ts
new file mode 100644
index 000000000000..c2ddde69b9f0
--- /dev/null
+++ b/apps/web/lib/jobs/pool-exhaustion.ts
@@ -0,0 +1,24 @@
+import { Prisma } from "@formbricks/database/prisma";
+import { DatabaseError } from "@formbricks/types/errors";
+
+/**
+ * True when an error is a transient database connection-pool exhaustion (Prisma `P2024`, or a
+ * connection-pool timeout surfaced as a message). These are retryable: a background job that hits
+ * one should propagate the error so it is retried, rather than swallow it and silently drop work.
+ *
+ * Shared by the response-pipeline job and the workflow runner enqueue so both classify retryable
+ * DB exhaustion the same way (and so the runner can rethrow it without importing the pipeline).
+ */
+export const isDatabasePoolExhaustionError = (error: unknown): boolean => {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2024") {
+ return true;
+ }
+
+ if (error instanceof DatabaseError || error instanceof Error) {
+ return /Timed out fetching a new connection from the connection pool|connection pool timeout/i.test(
+ error.message
+ );
+ }
+
+ return false;
+};
diff --git a/apps/web/lib/localStorage.ts b/apps/web/lib/localStorage.ts
index 454cdf76f1a3..2581c6e38b12 100644
--- a/apps/web/lib/localStorage.ts
+++ b/apps/web/lib/localStorage.ts
@@ -1,4 +1,5 @@
export const FORMBRICKS_SURVEYS_FILTERS_KEY_LS = "formbricks-surveys-filters";
+export const FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS = "formbricks-workflows-filters";
export const FORMBRICKS_ENVIRONMENT_ID_LS = "formbricks-environment-id";
export const FORMBRICKS_WORKSPACE_ID_LS = "formbricks-workspace-id";
export const FORMBRICKS_LOGGED_IN_WITH_LS = "formbricks-logged-in-with";
diff --git a/apps/web/lib/organization/service.test.ts b/apps/web/lib/organization/service.test.ts
index d92494a4e9bf..2f74dadf9dd4 100644
--- a/apps/web/lib/organization/service.test.ts
+++ b/apps/web/lib/organization/service.test.ts
@@ -12,6 +12,7 @@ import {
createOrganization,
deleteOrganization,
getOrganization,
+ getOrganizationMemberEmails,
getOrganizationsByUserId,
select as organizationSelect,
subscribeOrganizationMembersToSurveyResponses,
@@ -34,6 +35,9 @@ vi.mock("@formbricks/database", () => ({
user: {
findUnique: vi.fn(),
},
+ membership: {
+ findMany: vi.fn(),
+ },
},
}));
@@ -201,6 +205,7 @@ describe("Organization Service", () => {
workspaces: IS_FORMBRICKS_CLOUD ? 1 : 3,
monthly: {
responses: IS_FORMBRICKS_CLOUD ? 250 : 1500,
+ workflowRuns: null,
},
},
stripeCustomerId: null,
@@ -409,4 +414,51 @@ describe("Organization Service", () => {
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
+
+ describe("getOrganizationMemberEmails (send_email recipient allowlist, ENG-2029)", () => {
+ test("queries only active members of the organization", async () => {
+ vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
+
+ await getOrganizationMemberEmails("org_1");
+
+ expect(prisma.membership.findMany).toHaveBeenCalledWith({
+ where: { organizationId: "org_1", user: { isActive: true } },
+ select: { user: { select: { email: true } } },
+ });
+ });
+
+ test("returns a lowercased, whitespace-trimmed set for case-insensitive matching", async () => {
+ vi.mocked(prisma.membership.findMany).mockResolvedValue([
+ { user: { email: " Member@Corp.Example " } },
+ { user: { email: "second@corp.example" } },
+ ] as never);
+
+ const result = await getOrganizationMemberEmails("org_1");
+
+ expect(result).toEqual(new Set(["member@corp.example", "second@corp.example"]));
+ });
+
+ test("drops memberships with a missing user or empty email", async () => {
+ vi.mocked(prisma.membership.findMany).mockResolvedValue([
+ { user: { email: "kept@corp.example" } },
+ { user: null },
+ { user: { email: null } },
+ { user: { email: "" } },
+ ] as never);
+
+ const result = await getOrganizationMemberEmails("org_1");
+
+ expect(result).toEqual(new Set(["kept@corp.example"]));
+ });
+
+ test("wraps a known Prisma error in DatabaseError", async () => {
+ const prismaError = new Prisma.PrismaClientKnownRequestError("db down", {
+ code: "P2002",
+ clientVersion: "1.0.0",
+ });
+ vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError);
+
+ await expect(getOrganizationMemberEmails("org_1")).rejects.toThrow(DatabaseError);
+ });
+ });
});
diff --git a/apps/web/lib/organization/service.ts b/apps/web/lib/organization/service.ts
index e4a41adb5c67..f50a20f2d3fb 100644
--- a/apps/web/lib/organization/service.ts
+++ b/apps/web/lib/organization/service.ts
@@ -17,6 +17,7 @@ import { TUserNotificationSettings } from "@formbricks/types/user";
import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
+import { normalizeEmailForComparison } from "@/lib/utils/email";
import { getWorkspaces } from "@/lib/workspace/service";
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
import { deleteHubTenantData } from "@/modules/hub/service";
@@ -46,6 +47,8 @@ const getDefaultOrganizationBilling = (): TOrganizationBilling => ({
workspaces: IS_FORMBRICKS_CLOUD ? 1 : 3,
monthly: {
responses: IS_FORMBRICKS_CLOUD ? 250 : 1500,
+ // No included workflow runs by default (ENG-1936); the Scale entitlement grants the volume.
+ workflowRuns: null,
},
},
stripeCustomerId: null,
@@ -139,6 +142,42 @@ export const getOrganizationByWorkspaceId = reactCache(
}
);
+/**
+ * Lowercased set of the email addresses of every active member of an organization. Used as the
+ * recipient allowlist for workflow `send_email` actions (ENG-2029): a literal recipient address is
+ * only permitted when it belongs to an active organization member, so a workflow cannot silently
+ * forward response data to an arbitrary external inbox. Emails are lowercased for case-insensitive
+ * matching.
+ */
+export const getOrganizationMemberEmails = reactCache(
+ async (organizationId: string): Promise> => {
+ validateInputs([organizationId, ZString]);
+
+ try {
+ // Only active users: a deactivated (soft-deleted) member has had access revoked and must not
+ // remain on the send_email recipient allowlist (ENG-2029).
+ const memberships = await prisma.membership.findMany({
+ where: { organizationId, user: { isActive: true } },
+ select: { user: { select: { email: true } } },
+ });
+
+ return new Set(
+ memberships
+ .map((membership) =>
+ membership.user?.email ? normalizeEmailForComparison(membership.user.email) : undefined
+ )
+ .filter((email): email is string => Boolean(email))
+ );
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
+ throw new DatabaseError(error.message);
+ }
+
+ throw error;
+ }
+ }
+);
+
export const getOrganization = reactCache(async (organizationId: string): Promise => {
validateInputs([organizationId, ZString]);
diff --git a/apps/web/lib/utils/action-client/types/context.ts b/apps/web/lib/utils/action-client/types/context.ts
index 342ad8b3994c..9704c36e210c 100644
--- a/apps/web/lib/utils/action-client/types/context.ts
+++ b/apps/web/lib/utils/action-client/types/context.ts
@@ -1,6 +1,17 @@
import { TUser } from "@formbricks/types/user";
export type AuditLoggingCtx = {
+ /**
+ * Set by a handler when the action returned successfully but the audited thing did NOT happen, so
+ * `withAuditLogging`'s fixed `action` would be a false record. The wrapper honours this only for a
+ * SUCCESSFUL run — a handler that throws is always audited, so this can never hide a failure.
+ *
+ * The case it exists for: `createUserAction` answers a duplicate sign-up identically to a real one on
+ * purpose (ENG-2099), so the wrapper cannot tell them apart and would log `created` for an account
+ * that was never created (ENG-2091). Reach for it only where the action name itself becomes untrue,
+ * not to quieten noisy events.
+ */
+ suppressEvent?: boolean;
organizationId?: string;
ipAddress: string;
segmentId?: string;
diff --git a/apps/web/lib/utils/email.ts b/apps/web/lib/utils/email.ts
index 0efb5a72f4ae..f4370deae11b 100644
--- a/apps/web/lib/utils/email.ts
+++ b/apps/web/lib/utils/email.ts
@@ -3,3 +3,11 @@ export const isValidEmail = (email: string): boolean => {
const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i;
return regex.test(email);
};
+
+/**
+ * Canonicalizes an email address for case- and whitespace-insensitive comparison. The workflow
+ * `send_email` recipient allowlist (ENG-2029) is built and queried through this single rule so the
+ * member-email set and every lookup against it cannot drift apart and silently weaken the
+ * fail-closed guarantee.
+ */
+export const normalizeEmailForComparison = (email: string): string => email.trim().toLowerCase();
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index 0fe85e639453..ad1a0c04123c 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Wir haben nach einem Konto gesucht, das mit {email} verknüpft ist. Falls keines existierte, haben wir eines für dich erstellt. Falls bereits ein Konto vorhanden war, wurden keine Änderungen vorgenommen. Bitte melde dich unten an, um fortzufahren."
},
"verification-requested": {
+ "email_not_configured_description": "Für diese Formbricks-Instanz ist kein E-Mail-Server eingerichtet, daher konnte kein Bestätigungslink gesendet werden. Bitte wende dich an deinen Administrator.",
+ "email_not_configured_title": "E-Mail ist nicht konfiguriert",
"invalid_email_address": "Ungültige E-Mail-Adresse",
"invalid_token": "Ungültiger Token ☹️",
"new_email_verification_success": "Falls die Adresse gültig ist, wurde eine Bestätigungs-E-Mail versendet.",
@@ -151,6 +155,7 @@
"accepted": "Akzeptiert",
"account": "Konto",
"account_settings": "Kontoeinstellungen",
+ "act": "Handeln",
"action": "Aktion",
"actions": "Aktionen",
"actions_description": "Code- und No-Code-Aktionen werden verwendet, um Intercept-Umfragen in Apps und auf Websites auszulösen.",
@@ -185,6 +190,7 @@
"archive": "Archivieren",
"archived": "Archiviert",
"are_you_sure": "Bist du sicher?",
+ "attempt": "Versuch",
"attributes": "Attribute",
"authorized_apps": "Authorized Apps",
"back": "Zurück",
@@ -193,6 +199,7 @@
"bottom_left": "Unten links",
"bottom_right": "Unten rechts",
"cancel": "Abbrechen",
+ "canceled": "Abgebrochen",
"centered_modal": "Zentriertes Modal",
"chart": "Diagramm",
"charts": "Diagramme",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
+ "editor": "Editor",
"elements": "Elemente",
"email": "E-Mail",
"enable": "Aktivieren",
+ "enabled": "Aktiviert",
"ending_card": "Abschlusskarte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise-Lizenz",
@@ -278,6 +287,7 @@
"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": "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": "Workspaces konnten nicht geladen werden",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Fertig",
+ "finished_at": "Beendet um",
"first_name": "Vorname",
"formbricks_version": "Formbricks-Version",
"full_name": "Vollständiger Name",
@@ -310,6 +321,7 @@
"imprint": "Impressum",
"in_progress": "In Bearbeitung",
"inactive_surveys": "Inaktive Umfragen",
+ "input": "Eingabe",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date_with_value": "Ungültiges Datum: {value}",
@@ -350,6 +362,7 @@
"move_up": "Nach oben bewegen",
"name": "Name",
"new_version_available": "Formbricks {version} ist da. Jetzt upgraden!",
+ "new_workflow": "Neuer Workflow",
"next": "Weiter",
"no": "Nein",
"no_actions_found": "Keine Aktionen gefunden",
@@ -388,10 +401,12 @@
"other": "Sonstiges",
"other_filters": "Weitere Filter",
"other_placeholder": "Sonstiger Platzhalter",
+ "output": "Ausgabe",
"overlay_color": "Overlay-Farbe",
"overview": "Übersicht",
"password": "Passwort",
"paused": "Pausiert",
+ "pending": "Ausstehend",
"pending_downgrade": "Ausstehende Herabstufung",
"people_manager": "Mitarbeitererlebnis",
"person": "Person",
@@ -412,6 +427,7 @@
"question": "Frage",
"question_id": "Fragen-ID",
"questions": "Fragen",
+ "queued": "In Warteschlange",
"quota": "Kontingent",
"quotas": "Kontingente",
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmenden erhältst, die bestimmte Kriterien erfüllen.",
@@ -424,15 +440,20 @@
"replace": "Ersetzen",
"report_survey": "Umfrage melden",
"request_trial_license": "Testlizenz anfordern",
+ "required": "Erforderlich",
"reset_to_default": "Auf Standard zurücksetzen",
"resize": "Größe ändern",
"response": "Antwort",
+ "response_completed": "Antwort abgeschlossen",
"response_id": "Antwort-ID",
"responses": "Antworten",
"restart": "Neu starten",
"retry": "Erneut versuchen",
"role": "Rolle",
"row_n": "Zeile {n}",
+ "run_data": "Ausführungsdaten",
+ "running": "Läuft",
+ "runs": "Ausführungen",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
@@ -468,12 +489,16 @@
"something_went_wrong": "Etwas ist schiefgelaufen",
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"sort_by": "Sortieren nach",
+ "sort_by_value": "Sortieren nach: {label}",
+ "started_at": "Gestartet um",
"status": "Status",
+ "steps": "Schritte",
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
"string": "Text",
"styling": "Styling",
"subheader": "Unterüberschrift",
"submit": "Abschicken",
+ "succeeded": "Erfolgreich",
"summary": "Zusammenfassung",
"survey": "Umfrage",
"survey_completed": "Umfrage abgeschlossen.",
@@ -506,8 +531,11 @@
"trial_expired": "Deine Testphase ist abgelaufen",
"trial_one_day_remaining": "1 Tag verbleibend in deiner Testphase",
"trial_plan_badge": "{plan}-Testversion",
+ "trigger": "Auslöser",
+ "trigger_payload": "Trigger-Payload",
"try_again": "Erneut versuchen",
"type": "Typ",
+ "unarchive": "Wiederherstellen",
"undo": "Rückgängig",
"unlock_more_workspaces_with_a_higher_plan": "Schalte mehr Workspaces mit einem höheren Plan frei.",
"update": "Aktualisieren",
@@ -527,6 +555,7 @@
"verified_email": "Verifizierte E-Mail",
"video": "Video",
"view": "Ansehen",
+ "view_workflow": "Workflow ansehen",
"warning": "Warnung",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Wir konnten deine Lizenz nicht verifizieren, da der Lizenzserver nicht erreichbar ist.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"whats_new": "Was ist neu",
+ "workflow_name": "Workflow-Name",
+ "workflow_runs": "Workflow-Ausführungen",
+ "workflows": "Workflows",
"workspace": "Arbeitsbereich",
"workspace_created_successfully": "Workspace erfolgreich erstellt",
"workspace_creation_description": "Organisiere Umfragen in Workspaces für eine bessere Zugriffskontrolle.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
"response_data": "Antwortdaten",
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde ausgefüllt ✅",
- "response_finished_email_subject_with_email": "{personEmail} hat gerade deine {surveyName}-Umfrage ausgefüllt ✅",
"schedule_your_meeting": "Plane dein Meeting",
"select_a_date": "Wähle ein Datum",
"survey_response_finished_email_congrats": "Glückwunsch, du hast eine neue Antwort auf deine Umfrage erhalten! Jemand hat gerade deine Umfrage ausgefüllt: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Zwei-Faktor-Authentifizierung",
"comparison_row_unify_feedback": "Feedback aus allen Quellen vereinen",
"comparison_row_unlimited_seats": "Unbegrenzte Plätze",
+ "comparison_row_workflows": "Workflows",
"comparison_row_workspaces": "Arbeitsbereiche",
"comparison_section_all_plans": "Alle Pläne",
"comparison_section_basic_usage": "Kernnutzung",
"comparison_section_pro_unlocks": "Pro freischalten",
"comparison_section_scale_unlocks": "Scale freischalten",
+ "confirm_hobby_downgrade_body": "Deine kostenlose {plan}-Testphase endet jetzt und du wechselst sofort zum Hobby-Plan.",
+ "confirm_hobby_downgrade_description": "Du kannst jederzeit wieder upgraden.",
+ "confirm_hobby_downgrade_title": "Jetzt zum Hobby-Plan wechseln?",
+ "confirm_trial_continue_body": "Follow-ups, benutzerdefinierte Links und alles andere in {plan} – sofort freigeschaltet. {chargeNow} heute, dann {fullPrice} {period} inkl. Steuern. Die Abrechnung beginnt heute.",
+ "confirm_trial_continue_body_fallback": "Follow-ups, benutzerdefinierte Links und alles andere in {plan} – sofort freigeschaltet. {fullPrice} {period} zzgl. Steuern. Die Abrechnung beginnt heute.",
+ "confirm_trial_continue_description": "Du kannst deinen Plan jederzeit wieder ändern.",
+ "confirm_trial_continue_pay_now": "Jetzt {chargeNow} zahlen",
+ "confirm_trial_continue_pay_now_generic": "Jetzt zahlen & freischalten",
+ "confirm_trial_continue_title": "{plan} jetzt starten?",
"confirm_upgrade_body": "Du bist dabei, auf den {plan}-Tarif für {amount} {period} upzugraden. Eine anteilige Gebühr für den Rest deiner aktuellen Abrechnungsperiode wird sofort berechnet, und alle anfallenden Steuern werden bei der Zahlung berechnet.",
"confirm_upgrade_body_with_charge": "Du bist dabei, auf den {plan}-Tarif ({period}) upzugraden. Dir werden jetzt {chargeNow} für den Rest deiner aktuellen Abrechnungsperiode berechnet, und alle anfallenden Steuern werden bei der Zahlung ermittelt.",
"confirm_upgrade_button": "Upgrade bestätigen",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Sprich mit uns",
"contact_sales_description": "Erfahre mehr über Formbricks für Unternehmen und wie wir unsere Lösungen für dich anpassen können.",
"contact_sales_title": "Vertrieb kontaktieren",
- "continue_with_plan_after_trial": "Nach der Testphase mit Pro fortfahren",
"current_plan_badge": "Aktuell",
"current_plan_cta": "Aktueller Plan",
"custom_plan_description": "Deine Organisation nutzt ein individuelles Abrechnungsmodell. Du kannst trotzdem zu einem der Standardpläne unten wechseln.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5.000 Antworten / Monat mit dynamischer Preisgestaltung",
"plan_scale_feature_security": "2FA & Spam-Schutz",
"plan_scale_feature_semantic_analysis": "Semantische Analyse (KI)",
+ "plan_scale_feature_workflows": "Workflows",
"plan_scale_feature_workspaces": "5 Workspaces",
"plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle deinen Plan direkt in Formbricks.",
"plan_selection_title": "Wähle deinen Plan",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Am Ende der Periode wechseln",
"switch_plan_now": "Jetzt Tarif wechseln",
"this_includes": "Das beinhaltet",
- "trial_alert_description": "Füge eine Zahlungsmethode hinzu, um weiterhin Zugriff auf alle Funktionen zu haben.",
+ "trial_alert_description": "Einige Features wie Follow-ups und benutzerdefinierte Links bleiben während der Testphase gesperrt. Upgrade jetzt, um alles freizuschalten.",
"trial_already_used": "Für diese E-Mail-Adresse wurde bereits eine kostenlose Testphase genutzt. Bitte wechsle stattdessen zu einem kostenpflichtigen Tarif.",
"trial_cancels_automatically": "Deine Testversion endet automatisch am {date}.",
"trial_ending_add_payment_method": "Zahlungsmethode hinzufügen",
"trial_ending_description": "Wenn die Testphase endet, verlierst du den Zugriff auf alles, was du mit Pro eingerichtet hast:",
"trial_ending_title": "{count, plural, one {Nur noch # Tag in deiner Testphase} other {Nur noch # Tage in deiner Testphase}}",
- "trial_payment_method_added_description": "Alles bereit! Dein Pro-Plan läuft nach Ende der Testphase automatisch weiter.",
"trial_warning_200_description": "Du hast 200 Antworten gesammelt. Sobald du 250 erreichst, akzeptieren deine Umfragen keine neuen Antworten mehr bis zum Ende der 30-Tage-Periode.",
"trial_warning_200_title": "Du hast 80 % deines Antwortlimits erreicht",
"trial_warning_250_description": "Du hast 250 Antworten gesammelt. Ab jetzt akzeptieren deine Umfragen keine neuen Antworten mehr, bis die 30-Tage-Periode endet.",
"trial_warning_250_title": "Du hast dein Limit erreicht",
- "trial_warning_add_payment_method": "Zahlungsmethode hinzufügen",
+ "trial_warning_add_payment_method": "Alle Funktionen freischalten",
"trial_warning_remind_me_later": "Später erinnern",
"unlimited_responses": "Unbegrenzte Antworten",
"unlimited_workspaces": "Unbegrenzte Workspaces",
+ "unlock_all_plan_features": "Alle {plan}-Features freischalten",
"upgrade": "Upgrade",
"upgrade_checkout_pending": "Richte deinen Plan ein…",
"upgrade_checkout_success": "Du hast jetzt den {plan}-Plan.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen",
"follow_ups_modal_action_body_label": "Text",
"follow_ups_modal_action_body_placeholder": "Text der E-Mail",
+ "follow_ups_modal_action_email_already_added": "Diese E-Mail wurde bereits hinzugefügt",
"follow_ups_modal_action_email_content": "E-Mail-Inhalt",
+ "follow_ups_modal_action_email_input_placeholder": "Schreibe eine E-Mail & drücke die Leertaste",
+ "follow_ups_modal_action_email_invalid": "Bitte gib eine gültige E-Mail-Adresse ein",
"follow_ups_modal_action_email_settings": "E-Mail-Einstellungen",
"follow_ups_modal_action_from_description": "E-Mail-Adresse, von der die E-Mail gesendet wird",
"follow_ups_modal_action_from_label": "Von",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
"follow_ups_modal_updated_successfull_toast": "Follow-up aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_new": "Neues Follow-up",
+ "follow_ups_workflows_alert_title": "Brauchst du mehr Flexibilität? Automatisiere Follow-ups und vieles mehr mit Workflows.",
"formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden",
"four_points": "4 Punkte",
"heading": "Überschrift",
@@ -4134,6 +4179,119 @@
"value_number": "Wert (Anzahl)",
"value_text": "Wert (Text)"
},
+ "workflows": {
+ "add_action": "Aktion hinzufügen",
+ "add_trigger": "Trigger hinzufügen",
+ "add_trigger_description": "Wähle aus, was diesen Workflow startet.",
+ "all_changes_saved": "Alle Änderungen gespeichert",
+ "alphabetical": "Alphabetisch",
+ "archive_confirm_body": "Durch das Archivieren wird dieser Workflow deaktiviert und die Ausführung gestoppt. Du kannst ihn später wieder aus dem Archiv holen.",
+ "archive_confirm_title": "Workflow archivieren?",
+ "archive_failed": "Workflow konnte nicht archiviert werden. Bitte versuche es erneut.",
+ "archive_success": "Workflow archiviert.",
+ "archive_workflow": "Workflow archivieren",
+ "archive_workflow_confirmation": "Bist du sicher, dass du \"{name}\" archivieren möchtest? Du kannst ihn später wiederherstellen.",
+ "archive_workflow_description": "Beim Archivieren wird der Workflow aus der Liste ausgeblendet. Du kannst ihn später wiederherstellen.",
+ "auto_layout": "Auto-Layout",
+ "autosave_failed": "Speichern fehlgeschlagen",
+ "autosave_failed_tooltip": "Deine neuesten Änderungen konnten nicht gespeichert werden. Überprüfe deine Verbindung und versuche es erneut.",
+ "autosave_failed_tooltip_rejected": "Deine letzten Änderungen konnten nicht gespeichert werden: {detail}",
+ "collapse_inspector": "Inspector einklappen",
+ "create_failed": "Workflow konnte nicht erstellt werden. Bitte versuche es erneut.",
+ "delete_failed": "Workflow konnte nicht gelöscht werden. Bitte versuche es erneut.",
+ "delete_success": "Workflow gelöscht.",
+ "delete_workflow_confirmation": "Hiermit wird \"{name}\" und sein Ausführungsverlauf dauerhaft gelöscht.",
+ "disable_failed": "Workflow konnte nicht deaktiviert werden.",
+ "disable_success": "Workflow deaktiviert.",
+ "duplicate_failed": "Workflow konnte nicht dupliziert werden. Bitte versuche es erneut.",
+ "duplicate_success": "Workflow dupliziert.",
+ "edit_blocked_active": "Deaktiviere den Workflow, um hier Änderungen vorzunehmen.",
+ "email_attach_response_data_description": "Füge die auslösende Umfrageantwort zur E-Mail-Payload hinzu.",
+ "email_attach_response_data_label": "Antwortdaten anhängen",
+ "email_body_label": "Nachricht",
+ "email_body_placeholder": "Schreibe die Nachricht, die du senden möchtest…",
+ "email_body_required": "Füge die zu sendende Nachricht hinzu.",
+ "email_from_label": "Von",
+ "email_include_hidden_fields_label": "Versteckte Felder einbeziehen",
+ "email_include_variables_label": "Variablen einbeziehen",
+ "email_needs_survey": "Verknüpfe zuerst eine Umfrage im Trigger-Schritt. Die Optionen für Empfänger und Nachricht stammen aus den Antworten der Umfrage.",
+ "email_reply_to_label": "Antworten an",
+ "email_set_up_trigger": "Trigger einrichten",
+ "email_subject_label": "Betreff",
+ "email_subject_placeholder": "Danke für die Teilnahme an der Umfrage",
+ "email_subject_required": "Füge eine Betreffzeile hinzu.",
+ "email_to_label": "Senden an",
+ "email_to_placeholder": "team@beispiel.de",
+ "email_to_required": "Wähle aus, wer diese E-Mail erhalten soll.",
+ "enable_blocked_unsaved_changes": "Deine letzten Änderungen konnten nicht gespeichert werden, daher wurde der Workflow nicht aktiviert.",
+ "enable_failed": "Der Workflow konnte nicht aktiviert werden.",
+ "enable_success": "Workflow aktiviert.",
+ "expand_inspector": "Inspector ausklappen",
+ "if_else": "Wenn / Sonst",
+ "if_else_summary": "Verzweige den Workflow basierend auf einer Bedingung.",
+ "inspector_unsupported_node": "Dieser Knotentyp hat noch kein Konfigurationsformular.",
+ "load_failed": "Workflow konnte nicht geladen werden.",
+ "name_required": "Bitte einen Namen eingeben.",
+ "no_results_description": "Versuche, deine Suche oder Filter anzupassen.",
+ "no_results_title": "Keine Workflows gefunden",
+ "no_workflows_description": "Erstelle deinen ersten Workflow, um Aktionen zu automatisieren, wenn Antworten eingehen.",
+ "no_workflows_title": "Noch keine Workflows",
+ "node_actions": "Knotenaktionen",
+ "node_needs_email_content": "Empfänger & Inhalte festlegen",
+ "node_needs_survey": "Wähle eine Umfrage aus, um loszulegen",
+ "pan_mode": "Verschiebemodus",
+ "pointer_mode": "Zeigermodus",
+ "read_only": "Schreibgeschützt",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {vor # Tag} other {vor # Tagen}}, {time}",
+ "relative_today": "Heute, {time}",
+ "relative_yesterday": "Gestern, {time}",
+ "response_completed": "Antwort abgeschlossen",
+ "response_completed_description": "Wird ausgeführt, wenn jemand eine Umfrageantwort abschließt.",
+ "save_failed": "Workflow konnte nicht gespeichert werden.",
+ "save_success": "Workflow gespeichert.",
+ "saving_changes": "Wird gespeichert…",
+ "search_by_workflow_name": "Nach Workflow-Namen suchen",
+ "send_email": "E-Mail senden",
+ "send_email_description": "Versende eine E-Mail, wenn dieser Workflow ausgeführt wird.",
+ "send_email_summary": "Sende eine E-Mail an {to}.",
+ "send_email_unconfigured": "Konfiguriere den E-Mail-Empfänger.",
+ "trigger_ending_cards_label": "Abschlusskarten",
+ "trigger_ending_cards_none": "Diese Umfrage hat keine konfigurierten Abschlüsse.",
+ "trigger_ending_cards_pick_survey": "Wähle eine Umfrage aus, um ihre Abschlüsse zu sehen.",
+ "trigger_ending_cards_scope_all": "Alle Abschlüsse",
+ "trigger_ending_cards_scope_specific": "Bestimmte Abschlüsse",
+ "trigger_ending_cards_select_at_least_one": "Wähle mindestens einen Abschluss aus. Ohne Auswahl löst jeder Abschluss diesen Workflow aus.",
+ "trigger_summary_all_endings": "Bei jeder Umfrageantwort auslösen.",
+ "trigger_summary_ending_cards": "Bei {count, plural, one {# Endkarte} other {# Endkarten}} auslösen.",
+ "trigger_survey_description": "Wähle die Umfrage aus, deren abgeschlossene Antworten diesen Workflow auslösen.",
+ "trigger_survey_empty": "In diesem Workspace gibt es noch keine Umfragen.",
+ "trigger_survey_label": "Umfrage",
+ "trigger_survey_placeholder": "Wähle eine Umfrage aus",
+ "triggers": "Auslöser",
+ "unarchive": "Aus Archiv wiederherstellen",
+ "unarchive_failed": "Workflow konnte nicht wiederhergestellt werden. Bitte versuche es erneut.",
+ "unarchive_success": "Workflow wiederhergestellt.",
+ "upgrade_prompt_description": "Automatisiere antwortgesteuerte Aufgaben mit Triggern, Filtern und Aktionen.",
+ "upgrade_prompt_title": "Upgrade durchführen, um Workflows freizuschalten",
+ "validation_failed": "Workflow-Validierung fehlgeschlagen.",
+ "validation_problem_fix_label": "Beheben: {problem}",
+ "validation_problem_flow_invalid": "Die Workflow-Schritte sind nicht zu einem ausführbaren Ablauf verbunden.",
+ "validation_problem_generic": "Dieser Teil des Workflows hat ein Konfigurationsproblem.",
+ "validation_problem_name_missing": "Gib dem Workflow einen Namen.",
+ "validation_problem_step_incomplete": "Füll den Empfänger, Betreff und Text des E-Mail-Schritts aus.",
+ "validation_problem_step_not_executable": "Dieser Schritttyp kann noch nicht ausgeführt werden. Entferne ihn, bevor Du den Workflow aktivierst.",
+ "validation_problem_trigger_ending_not_found": "Ein ausgewähltes Ende existiert nicht mehr in der verbundenen Umfrage.",
+ "validation_problem_trigger_missing": "Füge einen Trigger hinzu, um den Workflow zu starten.",
+ "validation_problem_trigger_not_connected": "Verbinde einen Schritt nach dem Trigger.",
+ "validation_problem_trigger_survey_unbound": "Verbinde den Trigger mit einer Umfrage in diesem Workspace.",
+ "validation_problems_count": "{count, plural, one {# Problem} other {# Probleme}}",
+ "validation_problems_description": "Behebe diese Probleme, bevor der Workflow ausgeführt werden kann:",
+ "validation_problems_title": "Validierungsprobleme",
+ "validation_status_valid": "Gültig",
+ "zoom_in": "Vergrößern",
+ "zoom_out": "Verkleinern"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Customer Effort Score",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index 3b161e266931..32e0de60adcb 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "We have checked for an account associated with {email}. If none existed, we have created one for you. If an account already existed, no changes were made. Please log in below to continue."
},
"verification-requested": {
+ "email_not_configured_description": "This Formbricks instance has no email server set up, so no verification link could be sent. Please contact your administrator.",
+ "email_not_configured_title": "Email is not configured",
"invalid_email_address": "Invalid email address",
"invalid_token": "Invalid token ☹️",
"new_email_verification_success": "If the address is valid, a verification email has been sent.",
@@ -151,6 +155,7 @@
"accepted": "Accepted",
"account": "Account",
"account_settings": "Account settings",
+ "act": "Act",
"action": "Action",
"actions": "Actions",
"actions_description": "Code and No-Code Actions are used to trigger intercept surveys within apps & on websites.",
@@ -185,6 +190,7 @@
"archive": "Archive",
"archived": "Archived",
"are_you_sure": "Are you sure?",
+ "attempt": "Attempt",
"attributes": "Attributes",
"authorized_apps": "Authorized Apps",
"back": "Back",
@@ -193,6 +199,7 @@
"bottom_left": "Bottom Left",
"bottom_right": "Bottom Right",
"cancel": "Cancel",
+ "canceled": "Canceled",
"centered_modal": "Centered Modal",
"chart": "Chart",
"charts": "Charts",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
+ "editor": "Editor",
"elements": "Elements",
"email": "Email",
"enable": "Enable",
+ "enabled": "Enabled",
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows",
+ "failed": "Failed",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Finish",
+ "finished_at": "Finished At",
"first_name": "First Name",
"formbricks_version": "Formbricks Version",
"full_name": "Full name",
@@ -310,6 +321,7 @@
"imprint": "Imprint",
"in_progress": "In Progress",
"inactive_surveys": "Inactive surveys",
+ "input": "Input",
"integration": "integration",
"integrations": "Integrations",
"invalid_date_with_value": "Invalid date: {value}",
@@ -350,6 +362,7 @@
"move_up": "Move up",
"name": "Name",
"new_version_available": "Formbricks {version} is here. Upgrade now!",
+ "new_workflow": "New workflow",
"next": "Next",
"no": "No",
"no_actions_found": "No actions found",
@@ -388,10 +401,12 @@
"other": "Other",
"other_filters": "Other Filters",
"other_placeholder": "Other Placeholder",
+ "output": "Output",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
"paused": "Paused",
+ "pending": "Pending",
"pending_downgrade": "Pending Downgrade",
"people_manager": "Employee Experience",
"person": "Person",
@@ -412,6 +427,7 @@
"question": "question",
"question_id": "Question ID",
"questions": "Questions",
+ "queued": "Queued",
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
@@ -424,15 +440,20 @@
"replace": "Replace",
"report_survey": "Report Survey",
"request_trial_license": "Request trial license",
+ "required": "Required",
"reset_to_default": "Reset to default",
"resize": "Resize",
"response": "Response",
+ "response_completed": "Response completed",
"response_id": "Response ID",
"responses": "Responses",
"restart": "Restart",
"retry": "Retry",
"role": "Role",
"row_n": "Row {n}",
+ "run_data": "Run data",
+ "running": "Running",
+ "runs": "Runs",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
@@ -468,12 +489,16 @@
"something_went_wrong": "Something went wrong",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"sort_by": "Sort by",
+ "sort_by_value": "Sort by: {label}",
+ "started_at": "Started At",
"status": "Status",
+ "steps": "Steps",
"storage_not_configured": "File storage not set up, uploads will likely fail",
"string": "Text",
"styling": "Styling",
"subheader": "Subheader",
"submit": "Submit",
+ "succeeded": "Succeeded",
"summary": "Summary",
"survey": "Survey",
"survey_completed": "Survey completed.",
@@ -506,8 +531,11 @@
"trial_expired": "Your trial has expired",
"trial_one_day_remaining": "1 day left in your trial",
"trial_plan_badge": "{plan} Trial",
+ "trigger": "Trigger",
+ "trigger_payload": "Trigger payload",
"try_again": "Try again",
"type": "Type",
+ "unarchive": "Unarchive",
"undo": "Undo",
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
"update": "Update",
@@ -527,6 +555,7 @@
"verified_email": "Verified Email",
"video": "Video",
"view": "View",
+ "view_workflow": "View workflow",
"warning": "Warning",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "weeks",
"welcome_card": "Welcome card",
"whats_new": "What's New",
+ "workflow_name": "Workflow Name",
+ "workflow_runs": "Workflow Runs",
+ "workflows": "Workflows",
"workspace": "Workspace",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
"response_data": "Response data",
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
- "response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
"schedule_your_meeting": "Schedule your meeting",
"select_a_date": "Select a date",
"survey_response_finished_email_congrats": "Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}",
@@ -2455,13 +2486,23 @@
"comparison_row_two_factor_auth": "Two-factor authentication",
"comparison_row_unify_feedback": "Unify feedback from all sources",
"comparison_row_unlimited_seats": "Unlimited seats",
+ "comparison_row_workflows": "Workflows",
"comparison_row_workspaces": "Workspaces",
"comparison_section_all_plans": "All plans",
"comparison_section_basic_usage": "Core usage",
"comparison_section_pro_unlocks": "Pro unlocks",
"comparison_section_scale_unlocks": "Scale unlocks",
- "confirm_upgrade_body": "You're about to upgrade to the {plan} plan at {amount} {period}. A prorated charge for the rest of your current billing period is applied immediately, and any applicable taxes are calculated at payment.",
- "confirm_upgrade_body_with_charge": "You're about to upgrade to the {plan} plan ({period}). You'll be charged {chargeNow} now for the rest of your current billing period, with any applicable taxes calculated at payment.",
+ "confirm_hobby_downgrade_body": "Your free {plan} trial will end now and you'll switch to the Hobby plan immediately.",
+ "confirm_hobby_downgrade_description": "You can upgrade again at any time.",
+ "confirm_hobby_downgrade_title": "Switch to the Hobby plan now?",
+ "confirm_trial_continue_body": "Follow-ups, custom links, and everything else in {plan} — unlocked instantly. {chargeNow} today, then {fullPrice} {period} incl. taxes. Billing starts today.",
+ "confirm_trial_continue_body_fallback": "Follow-ups, custom links, and everything else in {plan} — unlocked instantly. {fullPrice} {period} plus taxes. Billing starts today.",
+ "confirm_trial_continue_description": "You can change your plan again at any time.",
+ "confirm_trial_continue_pay_now": "Pay {chargeNow} now",
+ "confirm_trial_continue_pay_now_generic": "Pay now & unlock",
+ "confirm_trial_continue_title": "Start {plan} now?",
+ "confirm_upgrade_body": "You're about to switch to the {plan} plan at {amount} {period}. The charge is applied immediately, and any applicable taxes are calculated at payment.",
+ "confirm_upgrade_body_with_charge": "You're about to switch to the {plan} plan ({period}). You'll be charged {chargeNow} now, and any applicable taxes are calculated at payment.",
"confirm_upgrade_button": "Confirm upgrade",
"confirm_upgrade_calculating": "Calculating your prorated charge…",
"confirm_upgrade_description": "You can change your plan again at any time.",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Talk to us",
"contact_sales_description": "Learn more about Formbricks for enterprises and how we can tailor our solutions for you.",
"contact_sales_title": "Contact Sales",
- "continue_with_plan_after_trial": "Continue with Pro after trial",
"current_plan_badge": "Current",
"current_plan_cta": "Current plan",
"custom_plan_description": "Your organization is on a custom billing setup. You can still switch to one of the standard plans below.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5,000 responses / month with dynamic pricing",
"plan_scale_feature_security": "2FA & spam protection",
"plan_scale_feature_semantic_analysis": "Semantic Analysis (AI)",
+ "plan_scale_feature_workflows": "Workflows",
"plan_scale_feature_workspaces": "5 workspaces",
"plan_selection_description": "Compare Hobby, Pro, and Scale, then switch plans directly from Formbricks.",
"plan_selection_title": "Choose your plan",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Switch at period end",
"switch_plan_now": "Switch plan now",
"this_includes": "This includes",
- "trial_alert_description": "Add a payment method to continue with your plan after the trial ends.",
+ "trial_alert_description": "Some features like follow-ups and custom links stay locked during the trial. Upgrade now to unlock everything.",
"trial_already_used": "A free trial has already been used for this email address. Please upgrade to a paid plan instead.",
"trial_cancels_automatically": "Your trial cancels automatically on {date}.",
"trial_ending_add_payment_method": "Add payment method",
"trial_ending_description": "When it ends, you'll lose access to everything you've set up on Pro:",
"trial_ending_title": "{count, plural, one {Only # day left in your trial} other {Only # days left in your trial}}",
- "trial_payment_method_added_description": "You're all set! Your plan will continue automatically after the trial ends.",
"trial_warning_200_description": "You've collected 200 responses. Once you reach 250, your surveys will stop accepting new responses until the end of the 30-day period.",
"trial_warning_200_title": "You've collected 80% of your response limit",
"trial_warning_250_description": "You've collected 250 responses. From now on, your surveys will not accept new responses until the end of the 30-day period.",
"trial_warning_250_title": "You've reached your limit",
- "trial_warning_add_payment_method": "Add payment method",
+ "trial_warning_add_payment_method": "Unlock all features",
"trial_warning_remind_me_later": "Remind me later",
"unlimited_responses": "Unlimited Responses",
"unlimited_workspaces": "Unlimited Workspaces",
+ "unlock_all_plan_features": "Unlock all {plan} features",
"upgrade": "Upgrade",
"upgrade_checkout_pending": "Setting up your plan…",
"upgrade_checkout_success": "You're now on the {plan} plan.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Attach response data",
"follow_ups_modal_action_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
+ "follow_ups_modal_action_email_already_added": "This email has already been added",
"follow_ups_modal_action_email_content": "Email content",
+ "follow_ups_modal_action_email_input_placeholder": "Write an email & press space bar",
+ "follow_ups_modal_action_email_invalid": "Please enter a valid email address",
"follow_ups_modal_action_email_settings": "Email settings",
"follow_ups_modal_action_from_description": "Email address to send the email from",
"follow_ups_modal_action_from_label": "From",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
"follow_ups_new": "New follow-up",
+ "follow_ups_workflows_alert_title": "Need more flexibility? Automate follow-ups and much more with Workflows.",
"formbricks_sdk_is_not_connected": "Formbricks SDK is not connected",
"four_points": "4 points",
"heading": "Heading",
@@ -4134,6 +4179,119 @@
"value_number": "Value (Number)",
"value_text": "Value (Text)"
},
+ "workflows": {
+ "add_action": "Add action",
+ "add_trigger": "Add trigger",
+ "add_trigger_description": "Choose what starts this workflow.",
+ "all_changes_saved": "All changes saved",
+ "alphabetical": "Alphabetical",
+ "archive_confirm_body": "Archiving disables this workflow and stops it from running. You can unarchive it again later.",
+ "archive_confirm_title": "Archive workflow?",
+ "archive_failed": "Failed to archive the workflow. Please try again.",
+ "archive_success": "Workflow archived.",
+ "archive_workflow": "Archive workflow",
+ "archive_workflow_confirmation": "Are you sure you want to archive \"{name}\"? You can restore it later.",
+ "archive_workflow_description": "Archiving hides the workflow from the list. You can restore it later.",
+ "auto_layout": "Auto layout",
+ "autosave_failed": "Save failed",
+ "autosave_failed_tooltip": "Your latest changes couldn't be saved. Check your connection and try again.",
+ "autosave_failed_tooltip_rejected": "Your latest changes couldn't be saved: {detail}",
+ "collapse_inspector": "Collapse inspector",
+ "create_failed": "Failed to create the workflow. Please try again.",
+ "delete_failed": "Failed to delete the workflow. Please try again.",
+ "delete_success": "Workflow deleted.",
+ "delete_workflow_confirmation": "This permanently deletes \"{name}\" and its run history.",
+ "disable_failed": "Could not disable the workflow.",
+ "disable_success": "Workflow disabled.",
+ "duplicate_failed": "Failed to duplicate the workflow. Please try again.",
+ "duplicate_success": "Workflow duplicated.",
+ "edit_blocked_active": "Disable the workflow to make changes here.",
+ "email_attach_response_data_description": "Include the triggering survey response with the email payload.",
+ "email_attach_response_data_label": "Attach response data",
+ "email_body_label": "Body",
+ "email_body_placeholder": "Write the message you want to send…",
+ "email_body_required": "Add the message to send.",
+ "email_from_label": "From",
+ "email_include_hidden_fields_label": "Include hidden fields",
+ "email_include_variables_label": "Include variables",
+ "email_needs_survey": "Connect a survey in the trigger step first. The recipient and message options come from the survey's answers.",
+ "email_reply_to_label": "Reply to",
+ "email_set_up_trigger": "Set up trigger",
+ "email_subject_label": "Subject",
+ "email_subject_placeholder": "Thanks for completing the survey",
+ "email_subject_required": "Add a subject line.",
+ "email_to_label": "Send to",
+ "email_to_placeholder": "team@example.com",
+ "email_to_required": "Pick who should receive this email.",
+ "enable_blocked_unsaved_changes": "Your latest changes couldn't be saved, so the workflow wasn't enabled.",
+ "enable_failed": "Could not enable the workflow.",
+ "enable_success": "Workflow enabled.",
+ "expand_inspector": "Expand inspector",
+ "if_else": "If / Else",
+ "if_else_summary": "Branch the workflow based on a condition.",
+ "inspector_unsupported_node": "This node type doesn't have a configuration form yet.",
+ "load_failed": "Could not load the workflow.",
+ "name_required": "Please enter a name.",
+ "no_results_description": "Try adjusting your search or filters.",
+ "no_results_title": "No workflows found",
+ "no_workflows_description": "Create your first workflow to automate actions when responses come in.",
+ "no_workflows_title": "No workflows yet",
+ "node_actions": "Node actions",
+ "node_needs_email_content": "Set recipient & contents",
+ "node_needs_survey": "Pick a survey to get started",
+ "pan_mode": "Pan mode",
+ "pointer_mode": "Pointer mode",
+ "read_only": "Read-only",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {# day ago} other {# days ago}}, {time}",
+ "relative_today": "Today, {time}",
+ "relative_yesterday": "Yesterday, {time}",
+ "response_completed": "Response completed",
+ "response_completed_description": "Runs when someone completes a survey response.",
+ "save_failed": "Could not save the workflow.",
+ "save_success": "Workflow saved.",
+ "saving_changes": "Saving…",
+ "search_by_workflow_name": "Search by workflow name",
+ "send_email": "Send email",
+ "send_email_description": "Send an email when this workflow runs.",
+ "send_email_summary": "Send an email to {to}.",
+ "send_email_unconfigured": "Configure the email recipient.",
+ "trigger_ending_cards_label": "Ending cards",
+ "trigger_ending_cards_none": "This survey has no endings configured.",
+ "trigger_ending_cards_pick_survey": "Pick a survey to see its endings.",
+ "trigger_ending_cards_scope_all": "All endings",
+ "trigger_ending_cards_scope_specific": "Specific endings",
+ "trigger_ending_cards_select_at_least_one": "Select at least one ending. With none selected, every ending fires this workflow.",
+ "trigger_summary_all_endings": "Trigger on any survey response.",
+ "trigger_summary_ending_cards": "Trigger on {count, plural, one {# ending card} other {# ending cards}}.",
+ "trigger_survey_description": "Pick the survey whose completed responses fire this workflow.",
+ "trigger_survey_empty": "No surveys in this workspace yet.",
+ "trigger_survey_label": "Survey",
+ "trigger_survey_placeholder": "Select a survey",
+ "triggers": "Triggers",
+ "unarchive": "Unarchive",
+ "unarchive_failed": "Failed to unarchive the workflow. Please try again.",
+ "unarchive_success": "Workflow unarchived.",
+ "upgrade_prompt_description": "Automate response-driven tasks with triggers, filters, and actions.",
+ "upgrade_prompt_title": "Upgrade to unlock Workflows",
+ "validation_failed": "Workflow validation failed.",
+ "validation_problem_fix_label": "Fix: {problem}",
+ "validation_problem_flow_invalid": "The workflow steps aren't connected into a single runnable flow.",
+ "validation_problem_generic": "This part of the workflow has a configuration problem.",
+ "validation_problem_name_missing": "Give the workflow a name.",
+ "validation_problem_step_incomplete": "Fill in the email step's recipient, subject, and body.",
+ "validation_problem_step_not_executable": "This step type can't run yet. Remove it before enabling the workflow.",
+ "validation_problem_trigger_ending_not_found": "A selected ending no longer exists on the connected survey.",
+ "validation_problem_trigger_missing": "Add a trigger to start the workflow.",
+ "validation_problem_trigger_not_connected": "Connect a step after the trigger.",
+ "validation_problem_trigger_survey_unbound": "Connect the trigger to a survey in this workspace.",
+ "validation_problems_count": "{count, plural, one {# problem} other {# problems}}",
+ "validation_problems_description": "Fix these problems before the workflow can run:",
+ "validation_problems_title": "Validation problems",
+ "validation_status_valid": "Valid",
+ "zoom_in": "Zoom in",
+ "zoom_out": "Zoom out"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Customer Effort Score",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index 5c67956fac7d..1ba7c8bb5fc7 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Hemos comprobado si existe una cuenta asociada con {email}. Si no existía ninguna, hemos creado una para ti. Si ya existía una cuenta, no se realizaron cambios. Por favor, inicia sesión a continuación para continuar."
},
"verification-requested": {
+ "email_not_configured_description": "Esta instancia de Formbricks no tiene un servidor de correo configurado, por lo que no se pudo enviar ningún enlace de verificación. Ponte en contacto con tu administrador.",
+ "email_not_configured_title": "El correo electrónico no está configurado",
"invalid_email_address": "Dirección de correo electrónico no válida",
"invalid_token": "Token no válido ☹️",
"new_email_verification_success": "Si la dirección es válida, se ha enviado un correo electrónico de verificación.",
@@ -151,6 +155,7 @@
"accepted": "Aceptado",
"account": "Cuenta",
"account_settings": "Ajustes de cuenta",
+ "act": "Actuar",
"action": "Acción",
"actions": "Acciones",
"actions_description": "Las acciones de código y sin código se utilizan para activar encuestas de intercepción en aplicaciones y sitios web.",
@@ -185,6 +190,7 @@
"archive": "Archivar",
"archived": "Archivado",
"are_you_sure": "¿Estás seguro?",
+ "attempt": "Intento",
"attributes": "Atributos",
"authorized_apps": "Authorized Apps",
"back": "Atrás",
@@ -193,6 +199,7 @@
"bottom_left": "Inferior izquierda",
"bottom_right": "Inferior derecha",
"cancel": "Cancelar",
+ "canceled": "Cancelado",
"centered_modal": "Modal centrado",
"chart": "Gráfico",
"charts": "Gráficos",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
+ "editor": "Editor",
"elements": "Elementos",
"email": "Email",
"enable": "Activar",
+ "enabled": "Activado",
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Número máximo de solicitudes alcanzado. Por favor, inténtalo de nuevo más tarde.",
"error_rate_limit_title": "Límite de frecuencia excedido",
"expand_rows": "Expandir filas",
+ "failed": "Fallido",
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los espacios de trabajo",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtro",
"finish": "Finalizar",
+ "finished_at": "Finalizado el",
"first_name": "Nombre",
"formbricks_version": "Versión de Formbricks",
"full_name": "Nombre completo",
@@ -310,6 +321,7 @@
"imprint": "Aviso legal",
"in_progress": "En progreso",
"inactive_surveys": "Encuestas inactivas",
+ "input": "Entrada",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date_with_value": "Fecha no válida: {value}",
@@ -350,6 +362,7 @@
"move_up": "Mover hacia arriba",
"name": "Nombre",
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
+ "new_workflow": "Nuevo flujo de trabajo",
"next": "Siguiente",
"no": "No",
"no_actions_found": "No se encontraron acciones",
@@ -388,10 +401,12 @@
"other": "Otro",
"other_filters": "Otros Filtros",
"other_placeholder": "Otro marcador de posición",
+ "output": "Salida",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
"paused": "Pausado",
+ "pending": "Pendiente",
"pending_downgrade": "Degradación pendiente",
"people_manager": "Experiencia del empleado",
"person": "Persona",
@@ -412,6 +427,7 @@
"question": "pregunta",
"question_id": "ID de pregunta",
"questions": "Preguntas",
+ "queued": "En cola",
"quota": "Cuota",
"quotas": "Cuotas",
"quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.",
@@ -424,15 +440,20 @@
"replace": "Reemplazar",
"report_survey": "Reportar encuesta",
"request_trial_license": "Solicitar licencia de prueba",
+ "required": "Obligatorio",
"reset_to_default": "Restablecer a valores predeterminados",
"resize": "Cambiar tamaño",
"response": "Respuesta",
+ "response_completed": "Respuesta completada",
"response_id": "ID de respuesta",
"responses": "Respuestas",
"restart": "Reiniciar",
"retry": "Reintentar",
"role": "Rol",
"row_n": "Fila {n}",
+ "run_data": "Datos de ejecución",
+ "running": "En ejecución",
+ "runs": "Ejecuciones",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
@@ -468,12 +489,16 @@
"something_went_wrong": "Algo ha salido mal",
"something_went_wrong_please_try_again": "Algo ha salido mal. Por favor, inténtalo de nuevo.",
"sort_by": "Ordenar por",
+ "sort_by_value": "Ordenar por: {label}",
+ "started_at": "Iniciado el",
"status": "Estado",
+ "steps": "Pasos",
"storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas",
"string": "Texto",
"styling": "Estilo",
"subheader": "Subtítulo",
"submit": "Enviar",
+ "succeeded": "Exitoso",
"summary": "Resumen",
"survey": "Encuesta",
"survey_completed": "Encuesta completada.",
@@ -506,8 +531,11 @@
"trial_expired": "Tu prueba ha expirado",
"trial_one_day_remaining": "1 día restante en tu prueba",
"trial_plan_badge": "Prueba de {plan}",
+ "trigger": "Activador",
+ "trigger_payload": "Carga útil del disparador",
"try_again": "Intentar de nuevo",
"type": "Tipo",
+ "unarchive": "Desarchivar",
"undo": "Deshacer",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más espacios de trabajo con un plan superior.",
"update": "Actualizar",
@@ -527,6 +555,7 @@
"verified_email": "Correo electrónico verificado",
"video": "Vídeo",
"view": "Ver",
+ "view_workflow": "Ver flujo de trabajo",
"warning": "Advertencia",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "No pudimos verificar tu licencia porque el servidor de licencias no está accesible.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"whats_new": "Novedades",
+ "workflow_name": "Nombre del flujo de trabajo",
+ "workflow_runs": "Ejecuciones de workflows",
+ "workflows": "Flujos de trabajo",
"workspace": "Espacio de trabajo",
"workspace_created_successfully": "Espacio de trabajo creado correctamente",
"workspace_creation_description": "Organiza las encuestas en espacios de trabajo para un mejor control de acceso.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "El enlace al archivo subido no está incluido por razones de privacidad de datos",
"response_data": "Datos de respuesta",
"response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅",
- "response_finished_email_subject_with_email": "{personEmail} acaba de completar tu encuesta {surveyName} ✅",
"schedule_your_meeting": "Programa tu reunión",
"select_a_date": "Selecciona una fecha",
"survey_response_finished_email_congrats": "Enhorabuena, has recibido una nueva respuesta a tu encuesta. Alguien acaba de completar tu encuesta: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Autenticación de dos factores",
"comparison_row_unify_feedback": "Unifica comentarios de todas las fuentes",
"comparison_row_unlimited_seats": "Asientos ilimitados",
+ "comparison_row_workflows": "Workflows",
"comparison_row_workspaces": "Espacios de trabajo",
"comparison_section_all_plans": "Todos los planes",
"comparison_section_basic_usage": "Uso básico",
"comparison_section_pro_unlocks": "Desbloqueos Pro",
"comparison_section_scale_unlocks": "Desbloqueos Scale",
+ "confirm_hobby_downgrade_body": "Tu prueba gratuita del plan {plan} terminará ahora y cambiarás al plan Hobby inmediatamente.",
+ "confirm_hobby_downgrade_description": "Puedes actualizar de nuevo en cualquier momento.",
+ "confirm_hobby_downgrade_title": "¿Cambiar al plan Hobby ahora?",
+ "confirm_trial_continue_body": "Seguimientos, enlaces personalizados y todo lo demás en {plan} — desbloqueado al instante. {chargeNow} hoy, después {fullPrice} {period} impuestos incluidos. La facturación comienza hoy.",
+ "confirm_trial_continue_body_fallback": "Seguimientos, enlaces personalizados y todo lo demás en {plan} — desbloqueado al instante. {fullPrice} {period} más impuestos. La facturación comienza hoy.",
+ "confirm_trial_continue_description": "Puedes cambiar tu plan de nuevo en cualquier momento.",
+ "confirm_trial_continue_pay_now": "Pagar {chargeNow} ahora",
+ "confirm_trial_continue_pay_now_generic": "Pagar ahora y desbloquear",
+ "confirm_trial_continue_title": "¿Iniciar {plan} ahora?",
"confirm_upgrade_body": "Estás a punto de mejorar al plan {plan} por {amount} {period}. Se aplica de inmediato un cargo prorrateado por el resto de tu período de facturación actual, y los impuestos aplicables se calculan al realizar el pago.",
"confirm_upgrade_body_with_charge": "Estás a punto de mejorar al plan {plan} ({period}). Se te cobrará {chargeNow} ahora por el resto de tu período de facturación actual, y los impuestos aplicables se calculan al realizar el pago.",
"confirm_upgrade_button": "Confirmar actualización",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Habla con nosotros",
"contact_sales_description": "Descubre más sobre Formbricks para empresas y cómo podemos adaptar nuestras soluciones para ti.",
"contact_sales_title": "Contactar con Ventas",
- "continue_with_plan_after_trial": "Continuar con Pro después de la prueba",
"current_plan_badge": "Actual",
"current_plan_cta": "Plan actual",
"custom_plan_description": "Tu organización tiene una configuración de facturación personalizada. Aún puedes cambiar a uno de los planes estándar a continuación.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5.000 respuestas al mes con precios dinámicos",
"plan_scale_feature_security": "2FA y protección antispam",
"plan_scale_feature_semantic_analysis": "Análisis semántico (IA)",
+ "plan_scale_feature_workflows": "Workflows",
"plan_scale_feature_workspaces": "5 espacios de trabajo",
"plan_selection_description": "Compara Hobby, Pro y Scale, y cambia de plan directamente desde Formbricks.",
"plan_selection_title": "Elige tu plan",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Cambiar al final del período",
"switch_plan_now": "Cambiar de plan ahora",
"this_includes": "Esto incluye",
- "trial_alert_description": "Añade un método de pago para mantener el acceso a todas las funciones.",
+ "trial_alert_description": "Algunas funciones como seguimientos y enlaces personalizados permanecen bloqueadas durante la prueba. Actualiza ahora para desbloquear todo.",
"trial_already_used": "Ya se ha utilizado una prueba gratuita para esta dirección de correo electrónico. Por favor, actualiza a un plan de pago.",
"trial_cancels_automatically": "Tu prueba se cancela automáticamente el {date}.",
"trial_ending_add_payment_method": "Añadir método de pago",
"trial_ending_description": "Cuando finalice, perderás el acceso a todo lo que has configurado en Pro:",
"trial_ending_title": "{count, plural, one {Solo queda # día en tu prueba} other {Solo quedan # días en tu prueba}}",
- "trial_payment_method_added_description": "¡Todo listo! Tu plan Pro continuará automáticamente cuando termine el periodo de prueba.",
"trial_warning_200_description": "Has recopilado 200 respuestas. Una vez que llegues a 250, tus encuestas dejarán de aceptar nuevas respuestas hasta que finalice el período de 30 días.",
"trial_warning_200_title": "Has recopilado el 80% de tu límite de respuestas",
"trial_warning_250_description": "Has recopilado 250 respuestas. A partir de ahora, tus encuestas no aceptarán nuevas respuestas hasta que finalice el período de 30 días.",
"trial_warning_250_title": "Has alcanzado tu límite",
- "trial_warning_add_payment_method": "Añadir método de pago",
+ "trial_warning_add_payment_method": "Desbloquear todas las funciones",
"trial_warning_remind_me_later": "Recuérdamelo más tarde",
"unlimited_responses": "Respuestas ilimitadas",
"unlimited_workspaces": "Espacios de trabajo ilimitados",
+ "unlock_all_plan_features": "Desbloquea todas las funciones de {plan}",
"upgrade": "Actualizar",
"upgrade_checkout_pending": "Configurando tu plan…",
"upgrade_checkout_success": "Ahora estás en el plan {plan}.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Adjuntar datos de respuesta",
"follow_ups_modal_action_body_label": "Cuerpo",
"follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico",
+ "follow_ups_modal_action_email_already_added": "Este correo electrónico ya ha sido añadido",
"follow_ups_modal_action_email_content": "Contenido del correo electrónico",
+ "follow_ups_modal_action_email_input_placeholder": "Escribe un correo y pulsa la barra espaciadora",
+ "follow_ups_modal_action_email_invalid": "Por favor, introduce una dirección de correo electrónico válida",
"follow_ups_modal_action_email_settings": "Configuración del correo electrónico",
"follow_ups_modal_action_from_description": "Dirección de correo electrónico desde la que enviar el correo",
"follow_ups_modal_action_from_label": "De",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "El encuestado completa la encuesta",
"follow_ups_modal_updated_successfull_toast": "Seguimiento actualizado y se guardará cuando guardes la encuesta.",
"follow_ups_new": "Nuevo seguimiento",
+ "follow_ups_workflows_alert_title": "¿Necesitas más flexibilidad? Automatiza seguimientos y mucho más con Workflows.",
"formbricks_sdk_is_not_connected": "El SDK de Formbricks no está conectado",
"four_points": "4 puntos",
"heading": "Encabezado",
@@ -4134,6 +4179,119 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
+ "workflows": {
+ "add_action": "Añadir acción",
+ "add_trigger": "Añadir activador",
+ "add_trigger_description": "Elige qué inicia este flujo de trabajo.",
+ "all_changes_saved": "Todos los cambios guardados",
+ "alphabetical": "Alfabético",
+ "archive_confirm_body": "Archivar desactiva este flujo de trabajo y evita que se ejecute. Puedes desarchivarlo más adelante.",
+ "archive_confirm_title": "¿Archivar flujo de trabajo?",
+ "archive_failed": "No se pudo archivar el flujo de trabajo. Por favor, inténtalo de nuevo.",
+ "archive_success": "Flujo de trabajo archivado.",
+ "archive_workflow": "Archivar flujo de trabajo",
+ "archive_workflow_confirmation": "¿Estás seguro de que quieres archivar \"{name}\"? Puedes restaurarlo más tarde.",
+ "archive_workflow_description": "Archivar oculta el flujo de trabajo de la lista. Puedes restaurarlo más tarde.",
+ "auto_layout": "Diseño automático",
+ "autosave_failed": "Error al guardar",
+ "autosave_failed_tooltip": "No se pudieron guardar tus últimos cambios. Comprueba tu conexión e inténtalo de nuevo.",
+ "autosave_failed_tooltip_rejected": "No se pudieron guardar tus últimos cambios: {detail}",
+ "collapse_inspector": "Contraer inspector",
+ "create_failed": "No se pudo crear el flujo de trabajo. Por favor, inténtalo de nuevo.",
+ "delete_failed": "No se pudo eliminar el flujo de trabajo. Por favor, inténtalo de nuevo.",
+ "delete_success": "Flujo de trabajo eliminado.",
+ "delete_workflow_confirmation": "Esto eliminará permanentemente \"{name}\" y su historial de ejecuciones.",
+ "disable_failed": "No se pudo desactivar el flujo de trabajo.",
+ "disable_success": "Flujo de trabajo desactivado.",
+ "duplicate_failed": "No se pudo duplicar el flujo de trabajo. Por favor, inténtalo de nuevo.",
+ "duplicate_success": "Flujo de trabajo duplicado.",
+ "edit_blocked_active": "Desactiva el flujo de trabajo para hacer cambios aquí.",
+ "email_attach_response_data_description": "Incluye la respuesta de la encuesta que activó el flujo con la carga del correo electrónico.",
+ "email_attach_response_data_label": "Adjuntar datos de respuesta",
+ "email_body_label": "Cuerpo",
+ "email_body_placeholder": "Escribe el mensaje que quieres enviar…",
+ "email_body_required": "Añade el mensaje que quieres enviar.",
+ "email_from_label": "De",
+ "email_include_hidden_fields_label": "Incluir campos ocultos",
+ "email_include_variables_label": "Incluir variables",
+ "email_needs_survey": "Primero conecta una encuesta en el paso del activador. Las opciones de destinatario y mensaje provienen de las respuestas de la encuesta.",
+ "email_reply_to_label": "Responder a",
+ "email_set_up_trigger": "Configurar activador",
+ "email_subject_label": "Asunto",
+ "email_subject_placeholder": "Gracias por completar la encuesta",
+ "email_subject_required": "Añade un asunto.",
+ "email_to_label": "Enviar a",
+ "email_to_placeholder": "equipo@ejemplo.com",
+ "email_to_required": "Elige quién debe recibir este correo.",
+ "enable_blocked_unsaved_changes": "No se pudieron guardar tus últimos cambios, así que el flujo de trabajo no se habilitó.",
+ "enable_failed": "No se pudo activar el flujo de trabajo.",
+ "enable_success": "Flujo de trabajo activado.",
+ "expand_inspector": "Expandir inspector",
+ "if_else": "Si / Sino",
+ "if_else_summary": "Ramifica el flujo de trabajo según una condición.",
+ "inspector_unsupported_node": "Este tipo de nodo aún no tiene un formulario de configuración.",
+ "load_failed": "No se pudo cargar el flujo de trabajo.",
+ "name_required": "Introduce un nombre.",
+ "no_results_description": "Prueba a ajustar tu búsqueda o filtros.",
+ "no_results_title": "No se encontraron flujos de trabajo",
+ "no_workflows_description": "Crea tu primer flujo de trabajo para automatizar acciones cuando lleguen respuestas.",
+ "no_workflows_title": "Aún no hay flujos de trabajo",
+ "node_actions": "Acciones del nodo",
+ "node_needs_email_content": "Configurar destinatario y contenido",
+ "node_needs_survey": "Elige una encuesta para empezar",
+ "pan_mode": "Modo de desplazamiento",
+ "pointer_mode": "Modo puntero",
+ "read_only": "Solo lectura",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {hace # día} other {hace # días}}, {time}",
+ "relative_today": "Hoy, {time}",
+ "relative_yesterday": "Ayer, {time}",
+ "response_completed": "Respuesta completada",
+ "response_completed_description": "Se ejecuta cuando alguien completa una respuesta de encuesta.",
+ "save_failed": "No se pudo guardar el flujo de trabajo.",
+ "save_success": "Flujo de trabajo guardado.",
+ "saving_changes": "Guardando…",
+ "search_by_workflow_name": "Buscar por nombre de flujo de trabajo",
+ "send_email": "Enviar correo",
+ "send_email_description": "Envía un correo electrónico cuando se ejecute este flujo de trabajo.",
+ "send_email_summary": "Enviar un correo a {to}.",
+ "send_email_unconfigured": "Configura el destinatario del correo.",
+ "trigger_ending_cards_label": "Pantallas de finalización",
+ "trigger_ending_cards_none": "Esta encuesta no tiene finales configurados.",
+ "trigger_ending_cards_pick_survey": "Elige una encuesta para ver sus finales.",
+ "trigger_ending_cards_scope_all": "Todos los finales",
+ "trigger_ending_cards_scope_specific": "Finales específicos",
+ "trigger_ending_cards_select_at_least_one": "Selecciona al menos un final. Si no seleccionas ninguno, cada final activará este flujo de trabajo.",
+ "trigger_summary_all_endings": "Activar con cualquier respuesta de encuesta.",
+ "trigger_summary_ending_cards": "Activar con {count, plural, one {# tarjeta de finalización} other {# tarjetas de finalización}}.",
+ "trigger_survey_description": "Elige la encuesta cuyas respuestas completadas activarán este flujo de trabajo.",
+ "trigger_survey_empty": "Aún no hay encuestas en este espacio de trabajo.",
+ "trigger_survey_label": "Encuesta",
+ "trigger_survey_placeholder": "Selecciona una encuesta",
+ "triggers": "Activadores",
+ "unarchive": "Desarchivar",
+ "unarchive_failed": "No se pudo desarchivar el flujo de trabajo. Por favor, inténtalo de nuevo.",
+ "unarchive_success": "Flujo de trabajo desarchivado.",
+ "upgrade_prompt_description": "Automatiza tareas impulsadas por respuestas con activadores, filtros y acciones.",
+ "upgrade_prompt_title": "Mejora tu plan para desbloquear Workflows",
+ "validation_failed": "Falló la validación del flujo de trabajo.",
+ "validation_problem_fix_label": "Solución: {problem}",
+ "validation_problem_flow_invalid": "Los pasos del flujo de trabajo no están conectados en un flujo ejecutable único.",
+ "validation_problem_generic": "Esta parte del flujo de trabajo tiene un problema de configuración.",
+ "validation_problem_name_missing": "Dale un nombre al flujo de trabajo.",
+ "validation_problem_step_incomplete": "Rellena el destinatario, asunto y cuerpo del paso de correo electrónico.",
+ "validation_problem_step_not_executable": "Este tipo de paso aún no puede ejecutarse. Elimínalo antes de activar el flujo de trabajo.",
+ "validation_problem_trigger_ending_not_found": "Un final seleccionado ya no existe en la encuesta conectada.",
+ "validation_problem_trigger_missing": "Añade un desencadenador para iniciar el flujo de trabajo.",
+ "validation_problem_trigger_not_connected": "Conecta un paso después del desencadenador.",
+ "validation_problem_trigger_survey_unbound": "Conecta el desencadenador a una encuesta en este espacio de trabajo.",
+ "validation_problems_count": "{count, plural, one {# problema} other {# problemas}}",
+ "validation_problems_description": "Soluciona estos problemas antes de que el flujo de trabajo pueda ejecutarse:",
+ "validation_problems_title": "Problemas de validación",
+ "validation_status_valid": "Válido",
+ "zoom_in": "Acercar",
+ "zoom_out": "Alejar"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Puntuación de Esfuerzo del Cliente",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index 0336cdaa0c1e..31aa9c09f1e8 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Nous avons vérifié s'il existait un compte associé à {email}. Si aucun n'existait, nous en avons créé un pour vous. Si un compte existait déjà, aucune modification n'a été apportée. Veuillez vous connecter ci-dessous pour continuer."
},
"verification-requested": {
+ "email_not_configured_description": "Cette instance Formbricks n'a pas de serveur email configuré, aucun lien de vérification n'a donc pu être envoyé. Veuillez contacter votre administrateur.",
+ "email_not_configured_title": "L'email n'est pas configuré",
"invalid_email_address": "Adresse e-mail invalide",
"invalid_token": "Jeton non valide ☹️",
"new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.",
@@ -151,6 +155,7 @@
"accepted": "Accepté",
"account": "Compte",
"account_settings": "Paramètres du compte",
+ "act": "Agir",
"action": "Action",
"actions": "Actions",
"actions_description": "Les actions avec et sans code permettent de déclencher des enquêtes dans des applications et sur des sites Web.",
@@ -185,6 +190,7 @@
"archive": "Archiver",
"archived": "Archivé",
"are_you_sure": "Es-tu sûr ?",
+ "attempt": "Tentative",
"attributes": "Attributs",
"authorized_apps": "Authorized Apps",
"back": "Retour",
@@ -193,6 +199,7 @@
"bottom_left": "En bas à gauche",
"bottom_right": "En bas à droite",
"cancel": "Annuler",
+ "canceled": "Annulé",
"centered_modal": "Au centre",
"chart": "Graphique",
"charts": "Graphiques",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
+ "editor": "Éditeur",
"elements": "Éléments",
"email": "Email",
"enable": "Activer",
+ "enabled": "Activé",
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
+ "failed": "Échoué",
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des espaces de travail",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtre",
"finish": "Terminer",
+ "finished_at": "Terminé le",
"first_name": "Prénom",
"formbricks_version": "Version de Formbricks",
"full_name": "Nom complet",
@@ -310,6 +321,7 @@
"imprint": "Empreinte",
"in_progress": "En cours",
"inactive_surveys": "Sondages inactifs",
+ "input": "Entrée",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date_with_value": "Date invalide: {value}",
@@ -350,6 +362,7 @@
"move_up": "Déplacer vers le haut",
"name": "Nom",
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
+ "new_workflow": "Nouveau workflow",
"next": "Suivant",
"no": "Non",
"no_actions_found": "Aucune action trouvée",
@@ -388,10 +401,12 @@
"other": "Autre",
"other_filters": "Autres filtres",
"other_placeholder": "Autre espace réservé",
+ "output": "Sortie",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
"paused": "En pause",
+ "pending": "En attente",
"pending_downgrade": "Downgrade en attente",
"people_manager": "Expérience employé",
"person": "Personne",
@@ -412,6 +427,7 @@
"question": "question",
"question_id": "ID de la question",
"questions": "Questions",
+ "queued": "En file d'attente",
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
@@ -424,15 +440,20 @@
"replace": "Remplacer",
"report_survey": "Rapport d'enquête",
"request_trial_license": "Demander une licence d'essai",
+ "required": "Obligatoire",
"reset_to_default": "Réinitialiser par défaut",
"resize": "Redimensionner",
"response": "Réponse",
+ "response_completed": "Réponse terminée",
"response_id": "ID de réponse",
"responses": "Réponses",
"restart": "Recommencer",
"retry": "Réessayer",
"role": "Rôle",
"row_n": "Ligne {n}",
+ "run_data": "Données d'exécution",
+ "running": "En cours d'exécution",
+ "runs": "Exécutions",
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
@@ -468,12 +489,16 @@
"something_went_wrong": "Quelque chose s'est mal passé.",
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
"sort_by": "Trier par",
+ "sort_by_value": "Trier par: {label}",
+ "started_at": "Commencé le",
"status": "Statut",
+ "steps": "Étapes",
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
"string": "Texte",
"styling": "Style",
"subheader": "Sous-titre",
"submit": "Soumettre",
+ "succeeded": "Réussi",
"summary": "Résumé",
"survey": "Enquête",
"survey_completed": "Enquête terminée.",
@@ -506,8 +531,11 @@
"trial_expired": "Votre période d'essai a expiré",
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
"trial_plan_badge": "Essai {plan}",
+ "trigger": "Déclencheur",
+ "trigger_payload": "Charge utile de déclenchement",
"try_again": "Réessayer",
"type": "Type",
+ "unarchive": "Désarchiver",
"undo": "Annuler",
"unlock_more_workspaces_with_a_higher_plan": "Débloque plus d'espaces de travail avec un forfait supérieur.",
"update": "Mise à jour",
@@ -527,6 +555,7 @@
"verified_email": "Email vérifié",
"video": "Vidéo",
"view": "Afficher",
+ "view_workflow": "Voir le workflow",
"warning": "Avertissement",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"whats_new": "Quoi de neuf",
+ "workflow_name": "Nom du flux de travail",
+ "workflow_runs": "Exécutions de workflows",
+ "workflows": "Workflows",
"workspace": "Espace de travail",
"workspace_created_successfully": "Espace de travail créé avec succès",
"workspace_creation_description": "Organise tes enquêtes dans des espaces de travail pour un meilleur contrôle d'accès.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
"response_data": "Données de réponse",
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
- "response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
"schedule_your_meeting": "Planifier votre rendez-vous",
"select_a_date": "Sélectionner une date",
"survey_response_finished_email_congrats": "Félicitations, vous avez reçu une nouvelle réponse à votre enquête ! Quelqu'un vient de compléter votre enquête : {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Authentification à deux facteurs",
"comparison_row_unify_feedback": "Unifier les retours de toutes les sources",
"comparison_row_unlimited_seats": "Sièges illimités",
+ "comparison_row_workflows": "Workflows",
"comparison_row_workspaces": "Espaces de travail",
"comparison_section_all_plans": "Tous les forfaits",
"comparison_section_basic_usage": "Utilisation principale",
"comparison_section_pro_unlocks": "Fonctionnalités Pro",
"comparison_section_scale_unlocks": "Fonctionnalités Scale",
+ "confirm_hobby_downgrade_body": "Votre essai gratuit {plan} se terminera maintenant et vous passerez immédiatement au forfait Hobby.",
+ "confirm_hobby_downgrade_description": "Tu peux repasser à un forfait supérieur à tout moment.",
+ "confirm_hobby_downgrade_title": "Passer au forfait Hobby maintenant ?",
+ "confirm_trial_continue_body": "Relances, liens personnalisés et tout le reste dans {plan} — débloqués instantanément. {chargeNow} aujourd'hui, puis {fullPrice} {period} taxes comprises. La facturation commence aujourd'hui.",
+ "confirm_trial_continue_body_fallback": "Relances, liens personnalisés et tout le reste dans {plan} — débloqués instantanément. {fullPrice} {period} plus taxes. La facturation commence aujourd'hui.",
+ "confirm_trial_continue_description": "Tu peux changer de forfait à tout moment.",
+ "confirm_trial_continue_pay_now": "Payer {chargeNow} maintenant",
+ "confirm_trial_continue_pay_now_generic": "Payer maintenant et déverrouiller",
+ "confirm_trial_continue_title": "Commencer {plan} maintenant ?",
"confirm_upgrade_body": "Tu es sur le point de passer au forfait {plan} à {amount} {period}. Un montant au prorata pour le reste de ta période de facturation actuelle est appliqué immédiatement, et les taxes applicables sont calculées lors du paiement.",
"confirm_upgrade_body_with_charge": "Tu es sur le point de passer au forfait {plan} ({period}). Tu seras débité de {chargeNow} maintenant pour le reste de ta période de facturation actuelle, avec les taxes applicables calculées lors du paiement.",
"confirm_upgrade_button": "Confirmer la mise à niveau",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Discutons ensemble",
"contact_sales_description": "Découvre Formbricks pour les entreprises et comment nous pouvons adapter nos solutions à tes besoins.",
"contact_sales_title": "Contacter les ventes",
- "continue_with_plan_after_trial": "Continuer avec Pro après l'essai",
"current_plan_badge": "Actuel",
"current_plan_cta": "Formule actuelle",
"custom_plan_description": "Votre organisation dispose d'une configuration de facturation personnalisée. Tu peux toujours basculer vers l'une des formules standard ci-dessous.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5 000 réponses / mois avec tarification dynamique",
"plan_scale_feature_security": "2FA et protection anti-spam",
"plan_scale_feature_semantic_analysis": "Analyse sémantique (IA)",
+ "plan_scale_feature_workflows": "Workflows",
"plan_scale_feature_workspaces": "5 espaces de travail",
"plan_selection_description": "Compare les formules Hobby, Pro et Scale, puis change de formule directement depuis Formbricks.",
"plan_selection_title": "Choisis ta formule",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Changer à la fin de la période",
"switch_plan_now": "Changer de formule maintenant",
"this_includes": "Cela inclut",
- "trial_alert_description": "Ajoute un moyen de paiement pour conserver l'accès à toutes les fonctionnalités.",
+ "trial_alert_description": "Certaines fonctionnalités comme les relances et les liens personnalisés restent verrouillées pendant l'essai. Passe à la version supérieure maintenant pour tout débloquer.",
"trial_already_used": "Un essai gratuit a déjà été utilisé pour cette adresse e-mail. Passe plutôt à un plan payant.",
"trial_cancels_automatically": "Votre essai se termine automatiquement le {date}.",
"trial_ending_add_payment_method": "Ajouter un moyen de paiement",
"trial_ending_description": "À la fin de la période d'essai, tu perdras l'accès à tout ce que tu as configuré avec Pro :",
"trial_ending_title": "{count, plural, one {Plus qu'# jour d'essai restant} other {Plus que # jours d'essai restants}}",
- "trial_payment_method_added_description": "Tout est prêt ! Votre abonnement Pro se poursuivra automatiquement après la fin de la période d'essai.",
"trial_warning_200_description": "Tu as collecté 200 réponses. Une fois que tu atteindras 250, tes sondages ne pourront plus accepter de nouvelles réponses jusqu'à la fin de la période de 30 jours.",
"trial_warning_200_title": "Tu as collecté 80 % de ta limite de réponses",
"trial_warning_250_description": "Tu as collecté 250 réponses. À partir de maintenant, tes sondages n'accepteront plus de nouvelles réponses jusqu'à la fin de la période de 30 jours.",
"trial_warning_250_title": "Tu as atteint ta limite",
- "trial_warning_add_payment_method": "Ajouter un moyen de paiement",
+ "trial_warning_add_payment_method": "Débloquer toutes les fonctionnalités",
"trial_warning_remind_me_later": "Me le rappeler plus tard",
"unlimited_responses": "Réponses illimitées",
"unlimited_workspaces": "Espaces de travail illimités",
+ "unlock_all_plan_features": "Débloquer toutes les fonctionnalités {plan}",
"upgrade": "Mise à niveau",
"upgrade_checkout_pending": "Configuration de ton forfait en cours…",
"upgrade_checkout_success": "Tu es maintenant sur le forfait {plan}.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse",
"follow_ups_modal_action_body_label": "Corps",
"follow_ups_modal_action_body_placeholder": "Corps de l'email",
+ "follow_ups_modal_action_email_already_added": "Cet email a déjà été ajouté",
"follow_ups_modal_action_email_content": "Contenu de l'email",
+ "follow_ups_modal_action_email_input_placeholder": "Écris un email et appuie sur espace",
+ "follow_ups_modal_action_email_invalid": "Merci de saisir une adresse email valide",
"follow_ups_modal_action_email_settings": "Paramètres de messagerie",
"follow_ups_modal_action_from_description": "Adresse e-mail à partir de laquelle envoyer l'e-mail",
"follow_ups_modal_action_from_label": "De",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Le répondant complète l'enquête",
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_new": "Nouveau suivi",
+ "follow_ups_workflows_alert_title": "Besoin de plus de flexibilité ? Automatise les relances et bien plus encore avec Workflows.",
"formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté",
"four_points": "4 points",
"heading": "En-tête",
@@ -4134,6 +4179,119 @@
"value_number": "Valeur (Nombre)",
"value_text": "Valeur (texte)"
},
+ "workflows": {
+ "add_action": "Ajouter une action",
+ "add_trigger": "Ajouter un déclencheur",
+ "add_trigger_description": "Choisis ce qui démarre ce workflow.",
+ "all_changes_saved": "Toutes les modifications enregistrées",
+ "alphabetical": "Alphabétique",
+ "archive_confirm_body": "L'archivage désactive ce workflow et l'empêche de s'exécuter. Tu pourras le restaurer plus tard.",
+ "archive_confirm_title": "Archiver le workflow ?",
+ "archive_failed": "Échec de l'archivage du workflow. Réessaye.",
+ "archive_success": "Workflow archivé.",
+ "archive_workflow": "Archiver le workflow",
+ "archive_workflow_confirmation": "Es-tu sûr de vouloir archiver « {name} » ? Tu pourras le restaurer plus tard.",
+ "archive_workflow_description": "L'archivage masque le workflow de la liste. Tu pourras le restaurer plus tard.",
+ "auto_layout": "Disposition automatique",
+ "autosave_failed": "Échec de l'enregistrement",
+ "autosave_failed_tooltip": "Tes dernières modifications n'ont pas pu être enregistrées. Vérifie ta connexion et réessaie.",
+ "autosave_failed_tooltip_rejected": "Tes dernières modifications n'ont pas pu être enregistrées : {detail}",
+ "collapse_inspector": "Réduire l'inspecteur",
+ "create_failed": "Échec de la création du workflow. Réessaye.",
+ "delete_failed": "Échec de la suppression du workflow. Réessaye.",
+ "delete_success": "Workflow supprimé.",
+ "delete_workflow_confirmation": "Cette action supprime définitivement « {name} » et son historique d'exécution.",
+ "disable_failed": "Impossible de désactiver le workflow.",
+ "disable_success": "Workflow désactivé.",
+ "duplicate_failed": "Échec de la duplication du workflow. Réessaye.",
+ "duplicate_success": "Workflow dupliqué.",
+ "edit_blocked_active": "Désactive le workflow pour apporter des modifications ici.",
+ "email_attach_response_data_description": "Inclure la réponse au sondage déclencheur avec la charge utile de l'e-mail.",
+ "email_attach_response_data_label": "Joindre les données de réponse",
+ "email_body_label": "Corps",
+ "email_body_placeholder": "Écris le message que tu souhaites envoyer…",
+ "email_body_required": "Ajoute le message à envoyer.",
+ "email_from_label": "De",
+ "email_include_hidden_fields_label": "Inclure les champs masqués",
+ "email_include_variables_label": "Inclure les variables",
+ "email_needs_survey": "Connecte d'abord un questionnaire dans l'étape de déclenchement. Les options de destinataire et de message proviennent des réponses du questionnaire.",
+ "email_reply_to_label": "Répondre à",
+ "email_set_up_trigger": "Configurer le déclencheur",
+ "email_subject_label": "Objet",
+ "email_subject_placeholder": "Merci d'avoir répondu au sondage",
+ "email_subject_required": "Ajoute une ligne d'objet.",
+ "email_to_label": "Envoyer à",
+ "email_to_placeholder": "equipe@exemple.com",
+ "email_to_required": "Choisis qui doit recevoir cet e-mail.",
+ "enable_blocked_unsaved_changes": "Tes dernières modifications n'ont pas pu être enregistrées, donc le workflow n'a pas été activé.",
+ "enable_failed": "Impossible d'activer le workflow.",
+ "enable_success": "Workflow activé.",
+ "expand_inspector": "Développer l'inspecteur",
+ "if_else": "Si / Sinon",
+ "if_else_summary": "Brancher le workflow selon une condition.",
+ "inspector_unsupported_node": "Ce type de nœud n'a pas encore de formulaire de configuration.",
+ "load_failed": "Impossible de charger le workflow.",
+ "name_required": "Veuillez saisir un nom.",
+ "no_results_description": "Essaie d'ajuster ta recherche ou tes filtres.",
+ "no_results_title": "Aucun workflow trouvé",
+ "no_workflows_description": "Crée ton premier workflow pour automatiser des actions lorsque des réponses arrivent.",
+ "no_workflows_title": "Aucun workflow pour le moment",
+ "node_actions": "Actions du nœud",
+ "node_needs_email_content": "Définir le destinataire et le contenu",
+ "node_needs_survey": "Choisis un questionnaire pour commencer",
+ "pan_mode": "Mode panoramique",
+ "pointer_mode": "Mode pointeur",
+ "read_only": "Lecture seule",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {il y a # jour} other {il y a # jours}}, {time}",
+ "relative_today": "Aujourd'hui, {time}",
+ "relative_yesterday": "Hier, {time}",
+ "response_completed": "Réponse terminée",
+ "response_completed_description": "S'exécute quand quelqu'un complète une réponse au questionnaire.",
+ "save_failed": "Impossible d'enregistrer le workflow.",
+ "save_success": "Workflow enregistré.",
+ "saving_changes": "Enregistrement…",
+ "search_by_workflow_name": "Rechercher par nom de workflow",
+ "send_email": "Envoyer un e-mail",
+ "send_email_description": "Envoie un email quand ce workflow s'exécute.",
+ "send_email_summary": "Envoyer un e-mail à {to}.",
+ "send_email_unconfigured": "Configure le destinataire de l'e-mail.",
+ "trigger_ending_cards_label": "Écrans de fin",
+ "trigger_ending_cards_none": "Ce questionnaire n'a aucune fin configurée.",
+ "trigger_ending_cards_pick_survey": "Choisis un questionnaire pour voir ses fins.",
+ "trigger_ending_cards_scope_all": "Toutes les fins",
+ "trigger_ending_cards_scope_specific": "Fins spécifiques",
+ "trigger_ending_cards_select_at_least_one": "Sélectionne au moins une fin. Sans sélection, chaque fin déclenche ce workflow.",
+ "trigger_summary_all_endings": "Se déclenche à chaque réponse à l'enquête.",
+ "trigger_summary_ending_cards": "Se déclenche sur {count, plural, one {# carte de fin} other {# cartes de fin}}.",
+ "trigger_survey_description": "Choisis le questionnaire dont les réponses complétées déclenchent ce workflow.",
+ "trigger_survey_empty": "Aucun questionnaire dans cet espace de travail pour le moment.",
+ "trigger_survey_label": "Questionnaire",
+ "trigger_survey_placeholder": "Sélectionne un questionnaire",
+ "triggers": "Déclencheurs",
+ "unarchive": "Désarchiver",
+ "unarchive_failed": "Échec de la désarchivage du workflow. Réessaye.",
+ "unarchive_success": "Workflow désarchivé.",
+ "upgrade_prompt_description": "Automatise les tâches pilotées par les réponses avec des déclencheurs, des filtres et des actions.",
+ "upgrade_prompt_title": "Passe à la version supérieure pour débloquer les Workflows",
+ "validation_failed": "La validation du workflow a échoué.",
+ "validation_problem_fix_label": "Corriger : {problem}",
+ "validation_problem_flow_invalid": "Les étapes du workflow ne sont pas connectées en un flux exécutable unique.",
+ "validation_problem_generic": "Cette partie du workflow a un problème de configuration.",
+ "validation_problem_name_missing": "Donne un nom au workflow.",
+ "validation_problem_step_incomplete": "Remplis le destinataire, l'objet et le corps de l'étape d'e-mail.",
+ "validation_problem_step_not_executable": "Ce type d'étape ne peut pas encore s'exécuter. Supprime-le avant d'activer le workflow.",
+ "validation_problem_trigger_ending_not_found": "Une fin sélectionnée n'existe plus dans le questionnaire connecté.",
+ "validation_problem_trigger_missing": "Ajoute un déclencheur pour lancer le workflow.",
+ "validation_problem_trigger_not_connected": "Connecte une étape après le déclencheur.",
+ "validation_problem_trigger_survey_unbound": "Connecte le déclencheur à une enquête dans cet espace de travail.",
+ "validation_problems_count": "{count, plural, one {# problème} other {# problèmes}}",
+ "validation_problems_description": "Corrige ces problèmes avant que le workflow puisse s'exécuter :",
+ "validation_problems_title": "Problèmes de validation",
+ "validation_status_valid": "Valide",
+ "zoom_in": "Zoomer",
+ "zoom_out": "Dézoomer"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Score d'Effort Client",
diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json
index ca2c36a2ecbf..994eb22d65be 100644
--- a/apps/web/locales/hu-HU.json
+++ b/apps/web/locales/hu-HU.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Ellenőriztük, hogy létezik-e a(z) {email} címhez rendelt fiók. Ha nem létezett, akkor létrehoztunk egyet Önnek. Ha a fiók már létezett, akkor nem történt változtatás. Jelentkezzen be lent a folytatáshoz."
},
"verification-requested": {
+ "email_not_configured_description": "Ehhez a Formbricks-példányhoz nincs beállítva e-mail-kiszolgáló, ezért nem sikerült ellenőrző hivatkozást küldeni. Kérjük, vegye fel a kapcsolatot a rendszergazdával.",
+ "email_not_configured_title": "Az e-mail nincs beállítva",
"invalid_email_address": "Érvénytelen e-mail-cím",
"invalid_token": "Érvénytelen token ☹️",
"new_email_verification_success": "Ha a cím érvényes, akkor egy ellenőrző e-mail került elküldésre.",
@@ -151,6 +155,7 @@
"accepted": "Elfogadva",
"account": "Fiók",
"account_settings": "Fiókbeállítások",
+ "act": "Felvonás",
"action": "Művelet",
"actions": "Műveletek",
"actions_description": "A kód vagy kód nélküli műveleteket arra használják, hogy aktiválják a kérdőívek alkalmazásokon és webhelyeken belüli elfogását.",
@@ -185,6 +190,7 @@
"archive": "Archiválás",
"archived": "Archiválva",
"are_you_sure": "Biztos benne?",
+ "attempt": "Kísérlet",
"attributes": "Attribútumok",
"authorized_apps": "Authorized Apps",
"back": "Vissza",
@@ -193,6 +199,7 @@
"bottom_left": "Balra lent",
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
+ "canceled": "Megszakítva",
"centered_modal": "Középre helyezett kizárólagos",
"chart": "Diagram",
"charts": "Diagramok",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
+ "editor": "Szerkesztő",
"elements": "Elemek",
"email": "E-mail",
"enable": "Engedélyezés",
+ "enabled": "Engedélyezve",
"ending_card": "Befejező kártya",
"enter_url": "URL megadása",
"enterprise_license": "Vállalati licenc",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "A kérések legnagyobb száma elérve. Próbálja meg később újra.",
"error_rate_limit_title": "A sebességkorlát elérve",
"expand_rows": "Sorok kinyitása",
+ "failed": "Sikertelen",
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült betölteni a munkaterületeket",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "A fájlfeltöltési szolgáltatás nem érhető el",
"filter": "Szűrő",
"finish": "Befejezés",
+ "finished_at": "Befejezve",
"first_name": "Keresztnév",
"formbricks_version": "Formbricks verziója",
"full_name": "Teljes név",
@@ -310,6 +321,7 @@
"imprint": "Impresszum",
"in_progress": "Folyamatban",
"inactive_surveys": "Inaktív kérdőívek",
+ "input": "Bemenet",
"integration": "integráció",
"integrations": "Integrációk",
"invalid_date_with_value": "Érvénytelen dátum: {value}",
@@ -350,6 +362,7 @@
"move_up": "Mozgatás fel",
"name": "Név",
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
+ "new_workflow": "Új munkafolyamat",
"next": "Következő",
"no": "Nem",
"no_actions_found": "Nem találhatók műveletek",
@@ -388,10 +401,12 @@
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"other_placeholder": "Egyéb helykitöltő",
+ "output": "Kimenet",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
"paused": "Szüneteltetve",
+ "pending": "Folyamatban",
"pending_downgrade": "Régebbi verzió telepítésére várakozik",
"people_manager": "Munkavállalói Élmény",
"person": "Személy",
@@ -412,6 +427,7 @@
"question": "kérdés",
"question_id": "Kérdésazonosító",
"questions": "Kérdések",
+ "queued": "Várakozik",
"quota": "Kvóta",
"quotas": "Kvóták",
"quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.",
@@ -424,15 +440,20 @@
"replace": "Csere",
"report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbaidőszaki licenc kérése",
+ "required": "Kötelező",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"resize": "Átméretezés",
"response": "Válasz",
+ "response_completed": "Válasz befejezve",
"response_id": "Válaszazonosító",
"responses": "Válaszok",
"restart": "Újraindítás",
"retry": "Újrapróbálás",
"role": "Szerep",
"row_n": "{n}. sor",
+ "run_data": "Futtatási adatok",
+ "running": "Fut",
+ "runs": "Futtatások",
"saas": "SaaS",
"sales": "Értékesítés",
"save": "Mentés",
@@ -468,12 +489,16 @@
"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.",
"sort_by": "Rendezési sorrend",
+ "sort_by_value": "Rendezési sorrend: {label}",
+ "started_at": "Elindítva",
"status": "Állapot",
+ "steps": "Lépések",
"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",
+ "succeeded": "Sikeres",
"summary": "Összegzés",
"survey": "Kérdőív",
"survey_completed": "A kérdőív kitöltve.",
@@ -506,8 +531,11 @@
"trial_expired": "A próbaidőszaka lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
"trial_plan_badge": "{plan} próbaidőszaka",
+ "trigger": "Eseményindító",
+ "trigger_payload": "Trigger adatcsomag",
"try_again": "Próbálja újra",
"type": "Típus",
+ "unarchive": "Archiválás visszavonása",
"undo": "Visszavonás",
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
"update": "Frissítés",
@@ -527,6 +555,7 @@
"verified_email": "Ellenőrzött e-mail-cím",
"video": "Videó",
"view": "Megtekintés",
+ "view_workflow": "Munkafolyamat megtekintése",
"warning": "Figyelmeztetés",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nem tudtuk ellenőrizni a licencét, mert a licenckiszolgáló nem érhető el.",
"webhook": "Webhorog",
@@ -536,6 +565,9 @@
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"whats_new": "Újdonságok",
+ "workflow_name": "Munkafolyamat neve",
+ "workflow_runs": "Munkafolyamat-futtatások",
+ "workflows": "Munkafolyamatok",
"workspace": "Munkaterület",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Adatvédelmi okokból a feltöltött fájlra mutató hivatkozást nem tartalmazza",
"response_data": "Válasz adatai",
"response_finished_email_subject": "A(z) {surveyName} kérdőívre adott válasz befejeződött ✅",
- "response_finished_email_subject_with_email": "{personEmail} épp most töltötte ki a(z) {surveyName} kérdőívet ✅",
"schedule_your_meeting": "Megbeszélés ütemezése",
"select_a_date": "Dátum kiválasztása",
"survey_response_finished_email_congrats": "Gratulálunk, új válasz érkezett a kérdőívére! Valaki épp most töltötte ki ezt a kérdőívet: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Kétfaktoros hitelesítés",
"comparison_row_unify_feedback": "Visszajelzések egyesítése minden forrásból",
"comparison_row_unlimited_seats": "Korlátlan számú felhasználó",
+ "comparison_row_workflows": "Munkafolyamatok",
"comparison_row_workspaces": "Munkaterületek",
"comparison_section_all_plans": "Minden csomag",
"comparison_section_basic_usage": "Alapvető használat",
"comparison_section_pro_unlocks": "Pro feloldások",
"comparison_section_scale_unlocks": "Scale feloldások",
+ "confirm_hobby_downgrade_body": "Az ingyenes {plan} próbaidőszak most véget ér, és azonnal a Hobby csomagra vált.",
+ "confirm_hobby_downgrade_description": "Bármikor újra frissíthet magasabb csomagra.",
+ "confirm_hobby_downgrade_title": "Vált most a Hobby csomagra?",
+ "confirm_trial_continue_body": "Követések, egyéni hivatkozások és minden egyéb a {plan} keretében — azonnal feloldva. {chargeNow} ma, majd {fullPrice} {period} adóval együtt. A számlázás ma kezdődik.",
+ "confirm_trial_continue_body_fallback": "Követések, egyéni hivatkozások és minden egyéb a {plan} keretében — azonnal feloldva. {fullPrice} {period} plusz adók. A számlázás ma kezdődik.",
+ "confirm_trial_continue_description": "Bármikor módosíthatja a csomagját.",
+ "confirm_trial_continue_pay_now": "Fizessen {chargeNow} összeget most",
+ "confirm_trial_continue_pay_now_generic": "Fizetés most és feloldás",
+ "confirm_trial_continue_title": "Elindítja most a {plan} csomagot?",
"confirm_upgrade_body": "Ön a {plan} csomag {amount} {period} díjszabású verzióját kívánja aktiválni. A jelenlegi számlázási időszak hátralévő részére vonatkozó arányosított díj azonnal felszámításra kerül, valamint a fizetéskor a vonatkozó adók is kiszámításra kerülnek.",
"confirm_upgrade_body_with_charge": "Ön a {plan} csomag ({period}) verzióját kívánja aktiválni. A jelenlegi számlázási időszak hátralévő részére vonatkozóan {chargeNow} összeg kerül azonnal felszámításra, a vonatkozó adók pedig a fizetéskor kerülnek kiszámításra.",
"confirm_upgrade_button": "Frissítés megerősítése",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Beszéljünk",
"contact_sales_description": "Tudjon meg többet a Formbricks vállalati megoldásairól, és arról, hogyan szabhatjuk testre szolgáltatásainkat az Ön számára.",
"contact_sales_title": "Kapcsolatfelvétel az értékesítéssel",
- "continue_with_plan_after_trial": "Folytatás Pro csomaggal a próbaidőszak után",
"current_plan_badge": "Jelenlegi",
"current_plan_cta": "Jelenlegi csomag",
"custom_plan_description": "A szervezete egyéni számlázási beállítással rendelkezik. Ugyanakkor áttérhet az alábbi szabványos csomagok egyikére.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5000 válasz / hónap dinamikus árképzéssel",
"plan_scale_feature_security": "2FA és spam védelem",
"plan_scale_feature_semantic_analysis": "Szemantikai elemzés (AI)",
+ "plan_scale_feature_workflows": "Munkafolyamatok",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hobby, Pro és Scale csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Csomag kiválasztása",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Váltás az időszak végén",
"switch_plan_now": "Csomag váltása most",
"this_includes": "Ezeket tartalmazza",
- "trial_alert_description": "Fizetési mód hozzáadása az összes funkcióhoz való hozzáférés megtartásához.",
+ "trial_alert_description": "Egyes funkciók, mint például a követések és az egyéni hivatkozások, a próbaidőszak alatt zárolva maradnak. Frissítsen most, hogy mindent feloldjon.",
"trial_already_used": "Ehhez az e-mail-címhez már használatban van egy ingyenes próbaidőszak. Váltson inkább fizetős csomagra.",
"trial_cancels_automatically": "Az Ön próbaidőszaka automatikusan megszűnik {date} napon.",
"trial_ending_add_payment_method": "Fizetési mód hozzáadása",
"trial_ending_description": "A lejárat után elveszíti a hozzáférést mindahhoz, amit a Pro verzióban beállított:",
"trial_ending_title": "{count, plural, one {Már csak # nap van hátra a próbaidőszakból} other {Már csak # nap van hátra a próbaidőszakból}}",
- "trial_payment_method_added_description": "Mindent beállított! A Pro csomagja a próbaidőszak vége után automatikusan folytatódik.",
"trial_warning_200_description": "Ön 200 választ gyűjtött össze. Amint eléri a 250-et, a felmérései nem fogadnak új válaszokat a 30 napos időszak végéig.",
"trial_warning_200_title": "Ön elérte a válaszlimit 80%-át",
"trial_warning_250_description": "Ön 250 választ gyűjtött össze. Mostantól a felmérései nem fogadnak új válaszokat a 30 napos időszak végéig.",
"trial_warning_250_title": "Ön elérte a limitjét",
- "trial_warning_add_payment_method": "Fizetési mód hozzáadása",
+ "trial_warning_add_payment_method": "Minden funkció feloldása",
"trial_warning_remind_me_later": "Emlékeztessen később",
"unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület",
+ "unlock_all_plan_features": "Az összes {plan} funkció feloldása",
"upgrade": "Frissítés",
"upgrade_checkout_pending": "Csomagja beállítása folyamatban…",
"upgrade_checkout_success": "Mostantól Ön a(z) {plan} csomagot használja.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Válasz adatainak csatolása",
"follow_ups_modal_action_body_label": "Törzs",
"follow_ups_modal_action_body_placeholder": "Az e-mail törzse",
+ "follow_ups_modal_action_email_already_added": "Ez az e-mail cím már hozzá lett adva",
"follow_ups_modal_action_email_content": "E-mail tartalma",
+ "follow_ups_modal_action_email_input_placeholder": "Írjon be egy e-mail címet és nyomja meg a szóköz billentyűt",
+ "follow_ups_modal_action_email_invalid": "Kérem, adjon meg egy érvényes e-mail címet",
"follow_ups_modal_action_email_settings": "E-mail beállításai",
"follow_ups_modal_action_from_description": "Az az e-mail-cím, ahonnan az e-mail elküldésre kerül",
"follow_ups_modal_action_from_label": "Feladó",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
"follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új utókövetés",
+ "follow_ups_workflows_alert_title": "Nagyobb rugalmasságra van szüksége? Automatizálja az utókövetéseket és még sok mást a Workflows segítségével.",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont",
"heading": "Címsor",
@@ -4134,6 +4179,119 @@
"value_number": "Érték (szám)",
"value_text": "Érték (szöveg)"
},
+ "workflows": {
+ "add_action": "Művelet hozzáadása",
+ "add_trigger": "Trigger hozzáadása",
+ "add_trigger_description": "Válassza ki, mi indítsa el ezt a munkafolyamatot.",
+ "all_changes_saved": "Minden módosítás mentésre került",
+ "alphabetical": "Ábécé szerinti",
+ "archive_confirm_body": "Az archiválás letiltja ezt a munkafolyamatot és leállítja annak futtatását. Később bármikor visszaállíthatja az archívumból.",
+ "archive_confirm_title": "Archiválja a munkafolyamatot?",
+ "archive_failed": "A munkafolyamat archiválása sikertelen volt. Kérjük, próbálja meg újra.",
+ "archive_success": "A munkafolyamat archiválva lett.",
+ "archive_workflow": "Munkafolyamat archiválása",
+ "archive_workflow_confirmation": "Biztos benne, hogy archiválni kívánja a következőt: \"{name}\"? Később visszaállíthatja.",
+ "archive_workflow_description": "Az archiválás elrejti a munkafolyamatot a listából. Később visszaállíthatja.",
+ "auto_layout": "Automatikus elrendezés",
+ "autosave_failed": "A mentés sikertelen volt",
+ "autosave_failed_tooltip": "A legutóbbi módosításait nem sikerült elmenteni. Kérem, ellenőrizze a kapcsolatot, és próbálja újra.",
+ "autosave_failed_tooltip_rejected": "A legutóbbi módosításokat nem sikerült menteni: {detail}",
+ "collapse_inspector": "Ellenőrző összecsukása",
+ "create_failed": "A munkafolyamat létrehozása sikertelen volt. Kérjük, próbálja meg újra.",
+ "delete_failed": "A munkafolyamat törlése sikertelen volt. Kérjük, próbálja meg újra.",
+ "delete_success": "A munkafolyamat törölve lett.",
+ "delete_workflow_confirmation": "Ez véglegesen törli a következőt: \"{name}\" és annak futtatási előzményeit.",
+ "disable_failed": "A munkafolyamat letiltása sikertelen volt.",
+ "disable_success": "Munkafolyamat letiltva.",
+ "duplicate_failed": "A munkafolyamat másolása sikertelen volt. Kérjük, próbálja meg újra.",
+ "duplicate_success": "A munkafolyamat lemásolva.",
+ "edit_blocked_active": "A módosítások elvégzéséhez kérem, tiltsa le a munkafolyamatot.",
+ "email_attach_response_data_description": "A kiváltó felmérési válasz csatolása az e-mail adatcsomaghoz.",
+ "email_attach_response_data_label": "Válaszadatok csatolása",
+ "email_body_label": "Törzs",
+ "email_body_placeholder": "Írja meg az elküldeni kívánt üzenetet…",
+ "email_body_required": "Adja meg az elküldendő üzenetet.",
+ "email_from_label": "Feladó",
+ "email_include_hidden_fields_label": "Rejtett mezők hozzáadása",
+ "email_include_variables_label": "Változók hozzáadása",
+ "email_needs_survey": "Először kapcsoljon össze egy felmérést a trigger lépésben. A címzett és az üzenet opciók a felmérés válaszaiból származnak.",
+ "email_reply_to_label": "Válaszcím",
+ "email_set_up_trigger": "Trigger beállítása",
+ "email_subject_label": "Tárgy",
+ "email_subject_placeholder": "Köszönjük, hogy kitöltötted a felmérést",
+ "email_subject_required": "Adjon meg egy tárgyat.",
+ "email_to_label": "Címzett",
+ "email_to_placeholder": "csapat@pelda.hu",
+ "email_to_required": "Válassza ki, hogy ki kapja meg ezt az e-mailt.",
+ "enable_blocked_unsaved_changes": "A legutóbbi módosításokat nem sikerült menteni, ezért a munkafolyamat nem lett engedélyezve.",
+ "enable_failed": "A munkafolyamat nem engedélyezhető.",
+ "enable_success": "Munkafolyamat engedélyezve.",
+ "expand_inspector": "Ellenőrző kibontása",
+ "if_else": "Ha / Egyébként",
+ "if_else_summary": "A munkafolyamat elágaztatása egy feltétel alapján.",
+ "inspector_unsupported_node": "Ez a csomóponttípus még nem rendelkezik konfigurációs űrlappal.",
+ "load_failed": "A munkafolyamat betöltése sikertelen volt.",
+ "name_required": "Adj meg egy nevet.",
+ "no_results_description": "Kérem, módosítsa a keresési feltételeket vagy a szűrőket.",
+ "no_results_title": "Nem találhatók munkafolyamatok",
+ "no_workflows_description": "Hozza létre első munkafolyamatát, hogy automatizálja a műveleteket, amikor válaszok érkeznek.",
+ "no_workflows_title": "Még nincsenek munkafolyamatok",
+ "node_actions": "Csomópont műveletek",
+ "node_needs_email_content": "Állítsa be a címzettet és a tartalmat",
+ "node_needs_survey": "Válasszon egy felmérést a kezdéshez",
+ "pan_mode": "Eltolás mód",
+ "pointer_mode": "Mutató mód",
+ "read_only": "Csak olvasható",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {# napja} other {# napja}}, {time}",
+ "relative_today": "Ma, {time}",
+ "relative_yesterday": "Tegnap, {time}",
+ "response_completed": "Válasz befejezve",
+ "response_completed_description": "Fut, amikor valaki befejez egy felmérési választ.",
+ "save_failed": "A munkafolyamat mentése sikertelen volt.",
+ "save_success": "Munkafolyamat elmentve.",
+ "saving_changes": "Mentés folyamatban…",
+ "search_by_workflow_name": "Keresés munkafolyamat neve alapján",
+ "send_email": "E-mail küldése",
+ "send_email_description": "E-mail küldése, amikor ez a munkafolyamat lefut.",
+ "send_email_summary": "E-mail küldése a következő címre: {to}.",
+ "send_email_unconfigured": "Konfigurálja az e-mail címzettet.",
+ "trigger_ending_cards_label": "Záró kártyák",
+ "trigger_ending_cards_none": "Ehhez a felméréshez nincsenek beállított lezárások.",
+ "trigger_ending_cards_pick_survey": "Válasszon ki egy felmérést a lezárásainak megtekintéséhez.",
+ "trigger_ending_cards_scope_all": "Összes lezárás",
+ "trigger_ending_cards_scope_specific": "Meghatározott lezárások",
+ "trigger_ending_cards_select_at_least_one": "Válasszon ki legalább egy lezárást. Ha egyik sincs kiválasztva, minden lezárás elindítja ezt a munkafolyamatot.",
+ "trigger_summary_all_endings": "Aktiválás bármilyen felmérési válasz esetén.",
+ "trigger_summary_ending_cards": "Aktiválás {count, plural, one {# befejező kártya} other {# befejező kártya}} esetén.",
+ "trigger_survey_description": "Válassza ki azt a felmérést, amelynek befejezett válaszai elindítják ezt a munkafolyamatot.",
+ "trigger_survey_empty": "Még nincsenek felmérések ebben a munkaterületen.",
+ "trigger_survey_label": "Felmérés",
+ "trigger_survey_placeholder": "Válasszon ki egy felmérést",
+ "triggers": "Indítók",
+ "unarchive": "Archiválás visszavonása",
+ "unarchive_failed": "A munkafolyamat archiválásának visszavonása sikertelen volt. Kérjük, próbálja meg újra.",
+ "unarchive_success": "A munkafolyamat archiválása visszavonva.",
+ "upgrade_prompt_description": "Automatizáljon válaszalapú feladatokat triggerekkel, szűrőkkel és műveletekkel.",
+ "upgrade_prompt_title": "Frissítsen a Munkafolyamatok feloldásához",
+ "validation_failed": "A munkafolyamat érvényesítése sikertelen volt.",
+ "validation_problem_fix_label": "Javítás: {problem}",
+ "validation_problem_flow_invalid": "A munkafolyamat lépései nincsenek egyetlen futtatható folyamattá összekapcsolva.",
+ "validation_problem_generic": "Ennek a munkafolyamat-résznek konfigurációs problémája van.",
+ "validation_problem_name_missing": "Adjon nevet a munkafolyamatnak.",
+ "validation_problem_step_incomplete": "Töltse ki az e-mail lépés címzettjét, tárgyát és törzsét.",
+ "validation_problem_step_not_executable": "Ez a lépéstípus még nem futtatható. Távolítsa el, mielőtt engedélyezi a munkafolyamatot.",
+ "validation_problem_trigger_ending_not_found": "A kiválasztott végpont már nem létezik a csatlakoztatott kérdőíven.",
+ "validation_problem_trigger_missing": "Adjon hozzá egy triggert a munkafolyamat indításához.",
+ "validation_problem_trigger_not_connected": "Kapcsoljon egy lépést a trigger után.",
+ "validation_problem_trigger_survey_unbound": "Kapcsolja a triggert egy felméréshez ebben a munkaterületen.",
+ "validation_problems_count": "{count, plural, one {# probléma} other {# probléma}}",
+ "validation_problems_description": "Javítsa ki ezeket a problémákat, mielőtt a munkafolyamat futhat:",
+ "validation_problems_title": "Érvényesítési problémák",
+ "validation_status_valid": "Érvényes",
+ "zoom_in": "Nagyítás",
+ "zoom_out": "Kicsinyítés"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Ügyfélmegterhelési pontszám",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index d39950956bd6..2d4eba2270cc 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "入力されたメールアドレス ({email}) に関連するアカウントをチェックしました。アカウントが存在しない場合、作成されました。すでに存在する場合、変更はありません。続行するには、以下のログインフォームからログインしてください。"
},
"verification-requested": {
+ "email_not_configured_description": "この Formbricks インスタンスにはメールサーバーが設定されていないため、確認リンクを送信できませんでした。管理者にお問い合わせください。",
+ "email_not_configured_title": "メールが設定されていません",
"invalid_email_address": "無効なメールアドレスです",
"invalid_token": "無効なトークンです ☹️",
"new_email_verification_success": "アドレスが有効であれば、確認メールが送信されました。",
@@ -151,6 +155,7 @@
"accepted": "承認済み",
"account": "アカウント",
"account_settings": "アカウント設定",
+ "act": "行動",
"action": "アクション",
"actions": "アクション",
"actions_description": "コードとノーコードアクションは、アプリ内やウェブサイト上で調査を発動するために使用されます。",
@@ -185,6 +190,7 @@
"archive": "アーカイブ",
"archived": "アーカイブ済み",
"are_you_sure": "よろしいですか?",
+ "attempt": "試行",
"attributes": "属性",
"authorized_apps": "Authorized Apps",
"back": "戻る",
@@ -193,6 +199,7 @@
"bottom_left": "左下",
"bottom_right": "右下",
"cancel": "キャンセル",
+ "canceled": "キャンセル済み",
"centered_modal": "中央モーダル",
"chart": "チャート",
"charts": "チャート",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
+ "editor": "エディター",
"elements": "要素",
"email": "メールアドレス",
"enable": "有効化",
+ "enabled": "有効",
"ending_card": "終了カード",
"enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
"error_rate_limit_title": "レート制限を超えました",
"expand_rows": "行を展開",
+ "failed": "失敗",
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "フィルター",
"finish": "完了",
+ "finished_at": "完了日時",
"first_name": "名",
"formbricks_version": "Formbricksバージョン",
"full_name": "氏名",
@@ -310,6 +321,7 @@
"imprint": "企業情報",
"in_progress": "進行中",
"inactive_surveys": "非アクティブなフォーム",
+ "input": "入力",
"integration": "連携",
"integrations": "連携",
"invalid_date_with_value": "無効な日付です: {value}",
@@ -350,6 +362,7 @@
"move_up": "上に移動",
"name": "名前",
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
+ "new_workflow": "新しいワークフロー",
"next": "次へ",
"no": "いいえ",
"no_actions_found": "アクションが見つかりません",
@@ -388,10 +401,12 @@
"other": "その他",
"other_filters": "その他のフィルター",
"other_placeholder": "その他のプレースホルダー",
+ "output": "出力",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
"paused": "一時停止",
+ "pending": "保留中",
"pending_downgrade": "ダウングレード保留中",
"people_manager": "従業員エクスペリエンス",
"person": "人",
@@ -412,6 +427,7 @@
"question": "質問",
"question_id": "質問ID",
"questions": "質問",
+ "queued": "待機中",
"quota": "クォータ",
"quotas": "クォータ",
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
@@ -424,15 +440,20 @@
"replace": "置き換え",
"report_survey": "フォームを報告",
"request_trial_license": "トライアルライセンスをリクエスト",
+ "required": "必須",
"reset_to_default": "デフォルトにリセット",
"resize": "サイズ変更",
"response": "回答",
+ "response_completed": "応答完了",
"response_id": "回答ID",
"responses": "回答",
"restart": "再開",
"retry": "再試行",
"role": "役割",
"row_n": "行 {n}",
+ "run_data": "実行データ",
+ "running": "実行中",
+ "runs": "実行",
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
@@ -468,12 +489,16 @@
"something_went_wrong": "問題が発生しました",
"something_went_wrong_please_try_again": "問題が発生しました。もう一度お試しください。",
"sort_by": "並び替え",
+ "sort_by_value": "並び替え: {label}",
+ "started_at": "開始日時",
"status": "ステータス",
+ "steps": "ステップ",
"storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります",
"string": "テキスト",
"styling": "スタイル",
"subheader": "小見出し",
"submit": "送信",
+ "succeeded": "成功",
"summary": "概要",
"survey": "フォーム",
"survey_completed": "フォームが完了しました。",
@@ -506,8 +531,11 @@
"trial_expired": "トライアル期間が終了しました",
"trial_one_day_remaining": "トライアル期間の残り1日",
"trial_plan_badge": "{plan}トライアル",
+ "trigger": "トリガー",
+ "trigger_payload": "トリガーペイロード",
"try_again": "もう一度お試しください",
"type": "種類",
+ "unarchive": "アーカイブ解除",
"undo": "元に戻す",
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
"update": "更新",
@@ -527,6 +555,7 @@
"verified_email": "認証済みメールアドレス",
"video": "動画",
"view": "表示",
+ "view_workflow": "ワークフローを表示",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "ライセンスサーバーにアクセスできないため、ライセンスを認証できませんでした。",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"whats_new": "新機能",
+ "workflow_name": "ワークフロー名",
+ "workflow_runs": "ワークフロー実行",
+ "workflows": "ワークフロー",
"workspace": "ワークスペース",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "データプライバシーのため、アップロードされたファイルへのリンクは含まれていません",
"response_data": "回答データ",
"response_finished_email_subject": "{surveyName} の回答が完了しました ✅",
- "response_finished_email_subject_with_email": "{personEmail} が {surveyName} フォームを完了しました ✅",
"schedule_your_meeting": "ミーティングを予約",
"select_a_date": "日付を選択",
"survey_response_finished_email_congrats": "おめでとうございます、新しい回答が届きました!{surveyName} フォームへの回答が完了しました。",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "二段階認証",
"comparison_row_unify_feedback": "すべてのソースからのフィードバックを統合",
"comparison_row_unlimited_seats": "無制限のシート数",
+ "comparison_row_workflows": "ワークフロー",
"comparison_row_workspaces": "ワークスペース",
"comparison_section_all_plans": "すべてのプラン",
"comparison_section_basic_usage": "基本利用",
"comparison_section_pro_unlocks": "Proで解除される機能",
"comparison_section_scale_unlocks": "Scaleで解除される機能",
+ "confirm_hobby_downgrade_body": "無料の{plan}トライアルは今すぐ終了し、すぐにHobbyプランに切り替わります。",
+ "confirm_hobby_downgrade_description": "いつでも再度アップグレードできます。",
+ "confirm_hobby_downgrade_title": "今すぐHobbyプランに切り替えますか?",
+ "confirm_trial_continue_body": "{plan}のフォローアップ、カスタムリンク、その他すべての機能がすぐに利用可能になります。 本日{chargeNow}、その後は{period}ごとに{fullPrice}(税込)。本日より課金が開始されます。",
+ "confirm_trial_continue_body_fallback": "{plan}のフォローアップ、カスタムリンク、その他すべての機能がすぐに利用可能になります。 {period}ごとに{fullPrice}プラス税金。本日より課金が開始されます。",
+ "confirm_trial_continue_description": "プランはいつでも変更できます。",
+ "confirm_trial_continue_pay_now": "今すぐ{chargeNow}を支払う",
+ "confirm_trial_continue_pay_now_generic": "今すぐ支払ってロック解除",
+ "confirm_trial_continue_title": "今すぐ{plan}を開始しますか?",
"confirm_upgrade_body": "{plan}プラン({amount} {period})へのアップグレードを行います。現在の請求期間の残り分について日割り計算された料金が即座に請求され、該当する税金は支払い時に計算されます。",
"confirm_upgrade_body_with_charge": "{plan}プラン({period})へのアップグレードを行います。現在の請求期間の残り分として{chargeNow}が今すぐ請求され、該当する税金は支払い時に計算されます。",
"confirm_upgrade_button": "アップグレードを確認",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "お問い合わせ",
"contact_sales_description": "エンタープライズ向けのFormbricksについて、お客様に最適なソリューションをご提案いたします。",
"contact_sales_title": "営業へのお問い合わせ",
- "continue_with_plan_after_trial": "トライアル後もProプランを継続",
"current_plan_badge": "現在のプラン",
"current_plan_cta": "現在のプラン",
"custom_plan_description": "あなたの組織はカスタム請求設定を利用しています。以下の標準プランに切り替えることもできます。",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "月間5,000件のレスポンス(動的価格設定)",
"plan_scale_feature_security": "2FA&スパム保護",
"plan_scale_feature_semantic_analysis": "セマンティック分析(AI)",
+ "plan_scale_feature_workflows": "ワークフロー",
"plan_scale_feature_workspaces": "5つのワークスペース",
"plan_selection_description": "Hobby、Pro、Scaleプランを比較して、Formbricksから直接プランを切り替えられます。",
"plan_selection_title": "プランを選択",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "期間終了時に切り替え",
"switch_plan_now": "今すぐプランを切り替え",
"this_includes": "これには以下が含まれます",
- "trial_alert_description": "すべての機能へのアクセスを維持するには、支払い方法を追加してください。",
+ "trial_alert_description": "フォローアップやカスタムリンクなどの一部機能は、トライアル期間中はロックされたままです。今すぐアップグレードしてすべての機能をご利用ください。",
"trial_already_used": "このメールアドレスでは既に無料トライアルが使用されています。代わりに有料プランにアップグレードしてください。",
"trial_cancels_automatically": "トライアルは{date}に自動的にキャンセルされます。",
"trial_ending_add_payment_method": "お支払い方法を追加",
"trial_ending_description": "トライアル期間が終了すると、Proで設定したすべての機能にアクセスできなくなります:",
"trial_ending_title": "{count, plural, other {トライアル期間終了まであと#日}}",
- "trial_payment_method_added_description": "準備完了です!トライアル終了後、Proプランが自動的に継続されます。",
"trial_warning_200_description": "200件の回答を収集しました。250件に達すると、30日間の期間が終了するまで、アンケートは新しい回答を受け付けなくなります。",
"trial_warning_200_title": "回答制限の80%に達しました",
"trial_warning_250_description": "250件の回答を収集しました。30日間の期間が終了するまで、アンケートは新しい回答を受け付けなくなります。",
"trial_warning_250_title": "制限に達しました",
- "trial_warning_add_payment_method": "支払い方法を追加",
+ "trial_warning_add_payment_method": "すべての機能をアンロック",
"trial_warning_remind_me_later": "後で通知",
"unlimited_responses": "無制限の回答",
"unlimited_workspaces": "無制限ワークスペース",
+ "unlock_all_plan_features": "すべての{plan}機能をアンロック",
"upgrade": "アップグレード",
"upgrade_checkout_pending": "プランを設定中…",
"upgrade_checkout_success": "{plan}プランになりました。",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "回答データを添付",
"follow_ups_modal_action_body_label": "本文",
"follow_ups_modal_action_body_placeholder": "メールの本文",
+ "follow_ups_modal_action_email_already_added": "このメールアドレスは既に追加されています",
"follow_ups_modal_action_email_content": "メールの内容",
+ "follow_ups_modal_action_email_input_placeholder": "メールアドレスを入力してスペースキーを押してください",
+ "follow_ups_modal_action_email_invalid": "有効なメールアドレスを入力してください",
"follow_ups_modal_action_email_settings": "メール設定",
"follow_ups_modal_action_from_description": "メールを送信するメールアドレス",
"follow_ups_modal_action_from_label": "送信元",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "回答者がフォームを完了したとき",
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
"follow_ups_new": "新しいフォローアップ",
+ "follow_ups_workflows_alert_title": "より柔軟性が必要ですか?ワークフローでフォローアップなどを自動化しましょう。",
"formbricks_sdk_is_not_connected": "Formbricks SDKが接続されていません",
"four_points": "4点",
"heading": "見出し",
@@ -4134,6 +4179,119 @@
"value_number": "値(数値)",
"value_text": "値 (テキスト)"
},
+ "workflows": {
+ "add_action": "アクションを追加",
+ "add_trigger": "トリガーを追加",
+ "add_trigger_description": "このワークフローを開始する条件を選択してください。",
+ "all_changes_saved": "すべての変更を保存しました",
+ "alphabetical": "アルファベット順",
+ "archive_confirm_body": "アーカイブすると、このワークフローが無効化され、実行されなくなります。後で再度アーカイブ解除できます。",
+ "archive_confirm_title": "ワークフローをアーカイブしますか?",
+ "archive_failed": "ワークフローのアーカイブに失敗しました。もう一度お試しください。",
+ "archive_success": "ワークフローをアーカイブしました。",
+ "archive_workflow": "ワークフローをアーカイブ",
+ "archive_workflow_confirmation": "「{name}」をアーカイブしてもよろしいですか?後で復元できます。",
+ "archive_workflow_description": "アーカイブすると、ワークフローがリストに表示されなくなります。後で復元できます。",
+ "auto_layout": "自動レイアウト",
+ "autosave_failed": "保存に失敗しました",
+ "autosave_failed_tooltip": "最新の変更を保存できませんでした。接続を確認して、もう一度お試しください。",
+ "autosave_failed_tooltip_rejected": "最新の変更を保存できませんでした: {detail}",
+ "collapse_inspector": "インスペクターを折りたたむ",
+ "create_failed": "ワークフローの作成に失敗しました。もう一度お試しください。",
+ "delete_failed": "ワークフローの削除に失敗しました。もう一度お試しください。",
+ "delete_success": "ワークフローを削除しました。",
+ "delete_workflow_confirmation": "「{name}」と実行履歴が完全に削除されます。",
+ "disable_failed": "ワークフローを無効化できませんでした。",
+ "disable_success": "ワークフローを無効化しました。",
+ "duplicate_failed": "ワークフローの複製に失敗しました。もう一度お試しください。",
+ "duplicate_success": "ワークフローを複製しました。",
+ "edit_blocked_active": "ここで変更を行うには、ワークフローを無効にしてください。",
+ "email_attach_response_data_description": "トリガーとなったアンケート回答をメールペイロードに含めます。",
+ "email_attach_response_data_label": "回答データを添付",
+ "email_body_label": "本文",
+ "email_body_placeholder": "送信したいメッセージを入力してください…",
+ "email_body_required": "送信するメッセージを追加してください。",
+ "email_from_label": "送信者",
+ "email_include_hidden_fields_label": "非表示フィールドを含める",
+ "email_include_variables_label": "変数を含める",
+ "email_needs_survey": "まずトリガーステップでアンケートを接続してください。受信者とメッセージのオプションは、アンケートの回答から取得されます。",
+ "email_reply_to_label": "返信先",
+ "email_set_up_trigger": "トリガーを設定",
+ "email_subject_label": "件名",
+ "email_subject_placeholder": "アンケートへのご協力ありがとうございます",
+ "email_subject_required": "件名を追加してください。",
+ "email_to_label": "送信先",
+ "email_to_placeholder": "team@example.com",
+ "email_to_required": "このメールを受信する宛先を選択してください。",
+ "enable_blocked_unsaved_changes": "最新の変更を保存できなかったため、ワークフローは有効化されませんでした。",
+ "enable_failed": "ワークフローを有効化できませんでした。",
+ "enable_success": "ワークフローを有効化しました。",
+ "expand_inspector": "インスペクターを展開",
+ "if_else": "条件分岐",
+ "if_else_summary": "条件に基づいてワークフローを分岐します。",
+ "inspector_unsupported_node": "このノードタイプにはまだ設定フォームがありません。",
+ "load_failed": "ワークフローを読み込めませんでした。",
+ "name_required": "名前を入力してください。",
+ "no_results_description": "検索条件やフィルターを調整してみてください。",
+ "no_results_title": "ワークフローが見つかりません",
+ "no_workflows_description": "最初のワークフローを作成して、回答が届いたときのアクションを自動化しましょう。",
+ "no_workflows_title": "ワークフローはまだありません",
+ "node_actions": "ノードアクション",
+ "node_needs_email_content": "受信者と内容を設定",
+ "node_needs_survey": "開始するにはアンケートを選択してください",
+ "pan_mode": "パンモード",
+ "pointer_mode": "ポインターモード",
+ "read_only": "読み取り専用",
+ "relative_date": "{date} {time}",
+ "relative_days_ago": "{count, plural, other {#日前}} {time}",
+ "relative_today": "今日 {time}",
+ "relative_yesterday": "昨日 {time}",
+ "response_completed": "応答完了",
+ "response_completed_description": "誰かがアンケートの回答を完了したときに実行されます。",
+ "save_failed": "ワークフローを保存できませんでした。",
+ "save_success": "ワークフローを保存しました。",
+ "saving_changes": "保存中…",
+ "search_by_workflow_name": "ワークフロー名で検索",
+ "send_email": "メール送信",
+ "send_email_description": "このワークフローが実行されるとメールを送信します。",
+ "send_email_summary": "{to}にメールを送信します。",
+ "send_email_unconfigured": "メールの送信先を設定してください。",
+ "trigger_ending_cards_label": "エンディングカード",
+ "trigger_ending_cards_none": "このアンケートにはエンディングが設定されていません。",
+ "trigger_ending_cards_pick_survey": "エンディングを表示するアンケートを選択してください。",
+ "trigger_ending_cards_scope_all": "すべてのエンディング",
+ "trigger_ending_cards_scope_specific": "特定のエンディング",
+ "trigger_ending_cards_select_at_least_one": "少なくとも1つのエンディングを選択してください。何も選択しない場合、すべてのエンディングでこのワークフローが実行されます。",
+ "trigger_summary_all_endings": "すべてのアンケート回答でトリガーします。",
+ "trigger_summary_ending_cards": "{count, plural, other {# 個のエンディングカード}}でトリガーします。",
+ "trigger_survey_description": "このワークフローを実行する、完了した回答のアンケートを選択してください。",
+ "trigger_survey_empty": "このワークスペースにはまだアンケートがありません。",
+ "trigger_survey_label": "アンケート",
+ "trigger_survey_placeholder": "アンケートを選択",
+ "triggers": "トリガー",
+ "unarchive": "アーカイブを解除",
+ "unarchive_failed": "ワークフローのアーカイブ解除に失敗しました。もう一度お試しください。",
+ "unarchive_success": "ワークフローのアーカイブを解除しました。",
+ "upgrade_prompt_description": "トリガー、フィルター、アクションを使用して、レスポンス駆動型のタスクを自動化します。",
+ "upgrade_prompt_title": "アップグレードしてワークフローを利用する",
+ "validation_failed": "ワークフローの検証に失敗しました。",
+ "validation_problem_fix_label": "修正: {problem}",
+ "validation_problem_flow_invalid": "ワークフローのステップが実行可能な単一のフローに接続されていません。",
+ "validation_problem_generic": "ワークフローのこの部分に設定の問題があります。",
+ "validation_problem_name_missing": "ワークフローに名前を付けてください。",
+ "validation_problem_step_incomplete": "メールステップの宛先、件名、本文を入力してください。",
+ "validation_problem_step_not_executable": "このステップタイプはまだ実行できません。ワークフローを有効にする前に削除してください。",
+ "validation_problem_trigger_ending_not_found": "選択された終了条件が、接続されたアンケートに存在しなくなりました。",
+ "validation_problem_trigger_missing": "ワークフローを開始するトリガーを追加してください。",
+ "validation_problem_trigger_not_connected": "トリガーの後にステップを接続してください。",
+ "validation_problem_trigger_survey_unbound": "トリガーをこのワークスペース内のアンケートに接続してください。",
+ "validation_problems_count": "{count, plural, other {#件の問題}}",
+ "validation_problems_description": "ワークフローを実行する前に、これらの問題を修正してください:",
+ "validation_problems_title": "検証の問題",
+ "validation_status_valid": "有効",
+ "zoom_in": "拡大",
+ "zoom_out": "縮小"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "顧客努力指標",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index 9c12fd8e0dc6..3c7330b1f6ec 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "We hebben gecontroleerd of er een account is gekoppeld aan {email}. Als er geen bestond, hebben we er een voor u gemaakt. Als er al een account bestond, zijn er geen wijzigingen aangebracht. Log hieronder in om verder te gaan."
},
"verification-requested": {
+ "email_not_configured_description": "Voor deze Formbricks-instantie is geen e-mailserver ingesteld, waardoor er geen verificatielink kon worden verzonden. Neem contact op met uw beheerder.",
+ "email_not_configured_title": "E-mail is niet geconfigureerd",
"invalid_email_address": "Ongeldig e-mailadres",
"invalid_token": "Ongeldig token ☹️",
"new_email_verification_success": "Als het adres geldig is, is er een verificatie-e-mail verzonden.",
@@ -151,6 +155,7 @@
"accepted": "Geaccepteerd",
"account": "Rekening",
"account_settings": "Accountinstellingen",
+ "act": "Handelen",
"action": "Actie",
"actions": "Acties",
"actions_description": "Code- en no-code-acties worden gebruikt om onderscheppingsenquêtes in apps en op websites te activeren.",
@@ -185,6 +190,7 @@
"archive": "Archiveren",
"archived": "Gearchiveerd",
"are_you_sure": "Weet je het zeker?",
+ "attempt": "Poging",
"attributes": "Kenmerken",
"authorized_apps": "Authorized Apps",
"back": "Rug",
@@ -193,6 +199,7 @@
"bottom_left": "Linksonder",
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
+ "canceled": "Geannuleerd",
"centered_modal": "Gecentreerd modaal",
"chart": "Grafiek",
"charts": "Grafieken",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
+ "editor": "Editor",
"elements": "Elementen",
"email": "E-mail",
"enable": "Inschakelen",
+ "enabled": "Ingeschakeld",
"ending_card": "Einde kaart",
"enter_url": "URL invoeren",
"enterprise_license": "Enterprise-licentie",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.",
"error_rate_limit_title": "Tarieflimiet overschreden",
"expand_rows": "Vouw rijen uit",
+ "failed": "Mislukt",
"failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt",
"failed_to_load_organizations": "Laden van organisaties mislukt",
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Finish",
+ "finished_at": "Voltooid op",
"first_name": "Voornaam",
"formbricks_version": "Formbricks-versie",
"full_name": "Volledige naam",
@@ -310,6 +321,7 @@
"imprint": "Wettelijke vermeldingen",
"in_progress": "In uitvoering",
"inactive_surveys": "Inactieve enquêtes",
+ "input": "Invoer",
"integration": "integratie",
"integrations": "Integraties",
"invalid_date_with_value": "Ongeldige datum: {value}",
@@ -350,6 +362,7 @@
"move_up": "Ga omhoog",
"name": "Naam",
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
+ "new_workflow": "Nieuwe workflow",
"next": "Volgende",
"no": "Nee",
"no_actions_found": "Geen acties gevonden",
@@ -388,10 +401,12 @@
"other": "Ander",
"other_filters": "Overige filters",
"other_placeholder": "Andere tijdelijke aanduiding",
+ "output": "Uitvoer",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
"paused": "Gepauzeerd",
+ "pending": "In behandeling",
"pending_downgrade": "In afwachting van downgrade",
"people_manager": "Medewerkersevaring",
"person": "Persoon",
@@ -412,6 +427,7 @@
"question": "vraag",
"question_id": "Vraag-ID",
"questions": "Vragen",
+ "queued": "In wachtrij",
"quota": "Quotum",
"quotas": "Quota",
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
@@ -424,15 +440,20 @@
"replace": "Vervangen",
"report_survey": "Enquête melden",
"request_trial_license": "Proeflicentie aanvragen",
+ "required": "Verplicht",
"reset_to_default": "Resetten naar standaard",
"resize": "Formaat wijzigen",
"response": "Antwoord",
+ "response_completed": "Reactie voltooid",
"response_id": "Antwoord-ID",
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"retry": "Opnieuw proberen",
"role": "Rol",
"row_n": "Rij {n}",
+ "run_data": "Uitvoeringsgegevens",
+ "running": "Actief",
+ "runs": "Uitvoeringen",
"saas": "SaaS",
"sales": "Verkoop",
"save": "Redden",
@@ -468,12 +489,16 @@
"something_went_wrong": "Er is iets misgegaan",
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
"sort_by": "Sorteer op",
+ "sort_by_value": "Sorteer op: {label}",
+ "started_at": "Gestart op",
"status": "Status",
+ "steps": "Stappen",
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
"string": "Tekst",
"styling": "Styling",
"subheader": "Subkop",
"submit": "Indienen",
+ "succeeded": "Geslaagd",
"summary": "Samenvatting",
"survey": "Vragenlijst",
"survey_completed": "Enquête voltooid.",
@@ -506,8 +531,11 @@
"trial_expired": "Je proefperiode is verlopen",
"trial_one_day_remaining": "1 dag over in je proefperiode",
"trial_plan_badge": "{plan} Proefperiode",
+ "trigger": "Trigger",
+ "trigger_payload": "Trigger-payload",
"try_again": "Probeer het opnieuw",
"type": "Type",
+ "unarchive": "Dearchiveren",
"undo": "Ongedaan maken",
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
"update": "Update",
@@ -527,6 +555,7 @@
"verified_email": "Geverifieerde e-mail",
"video": "Video",
"view": "Bekijken",
+ "view_workflow": "Workflow bekijken",
"warning": "Waarschuwing",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We kunnen uw licentie niet verifiëren omdat de licentieserver niet bereikbaar is.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"whats_new": "Wat is er nieuw",
+ "workflow_name": "Workflownaam",
+ "workflow_runs": "Workflow-uitvoeringen",
+ "workflows": "Workflows",
"workspace": "Werkruimte",
"workspace_created_successfully": "Werkruimte succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "De link naar het geüploade bestand is om redenen van gegevensprivacy niet opgenomen",
"response_data": "Responsgegevens",
"response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅",
- "response_finished_email_subject_with_email": "{personEmail} heeft zojuist uw {surveyName} enquête voltooid ✅",
"schedule_your_meeting": "Plan uw vergadering",
"select_a_date": "Selecteer een datum",
"survey_response_finished_email_congrats": "Gefeliciteerd, u heeft een nieuwe reactie op uw enquête ontvangen! Iemand heeft zojuist uw enquête ingevuld: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Tweefactorauthenticatie",
"comparison_row_unify_feedback": "Feedback uit alle bronnen bundelen",
"comparison_row_unlimited_seats": "Onbeperkt aantal seats",
+ "comparison_row_workflows": "Workflows",
"comparison_row_workspaces": "Workspaces",
"comparison_section_all_plans": "Alle abonnementen",
"comparison_section_basic_usage": "Kerngebruik",
"comparison_section_pro_unlocks": "Pro-mogelijkheden",
"comparison_section_scale_unlocks": "Scale-mogelijkheden",
+ "confirm_hobby_downgrade_body": "Je gratis proefperiode van {plan} eindigt nu en je schakelt direct over naar het Hobby-abonnement.",
+ "confirm_hobby_downgrade_description": "Je kunt op elk moment weer upgraden.",
+ "confirm_hobby_downgrade_title": "Nu overschakelen naar het Hobby-abonnement?",
+ "confirm_trial_continue_body": "Opvolgingen, aangepaste links en alles in {plan} — direct ontgrendeld. {chargeNow} vandaag, daarna {fullPrice} {period} incl. btw. Facturering begint vandaag.",
+ "confirm_trial_continue_body_fallback": "Opvolgingen, aangepaste links en alles in {plan} — direct ontgrendeld. {fullPrice} {period} plus btw. Facturering begint vandaag.",
+ "confirm_trial_continue_description": "Je kunt je abonnement op elk moment weer aanpassen.",
+ "confirm_trial_continue_pay_now": "Betaal nu {chargeNow}",
+ "confirm_trial_continue_pay_now_generic": "Betaal nu en ontgrendel",
+ "confirm_trial_continue_title": "{plan} nu starten?",
"confirm_upgrade_body": "Je staat op het punt om te upgraden naar het {plan}-abonnement voor {amount} {period}. Er wordt direct een evenredige betaling voor de rest van je huidige facturatieperiode verrekend, en eventuele belastingen worden berekend bij de betaling.",
"confirm_upgrade_body_with_charge": "Je staat op het punt om te upgraden naar het {plan}-abonnement ({period}). Je betaalt nu {chargeNow} voor de rest van je huidige facturatieperiode, waarbij eventuele belastingen worden berekend bij de betaling.",
"confirm_upgrade_button": "Upgrade bevestigen",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Praat met ons",
"contact_sales_description": "Ontdek meer over Formbricks voor ondernemingen en hoe we onze oplossingen op maat kunnen maken voor jou.",
"contact_sales_title": "Neem contact op met Sales",
- "continue_with_plan_after_trial": "Ga door met Pro na de proefperiode",
"current_plan_badge": "Huidig",
"current_plan_cta": "Huidig abonnement",
"custom_plan_description": "Je organisatie heeft een aangepaste factureringsopzet. Je kunt nog steeds overstappen naar een van de standaard abonnementen hieronder.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5.000 reacties / maand met dynamische prijzen",
"plan_scale_feature_security": "2FA & spambescherming",
"plan_scale_feature_semantic_analysis": "Semantische analyse (AI)",
+ "plan_scale_feature_workflows": "Workflows",
"plan_scale_feature_workspaces": "5 werkruimtes",
"plan_selection_description": "Vergelijk Hobby, Pro en Scale, en schakel direct vanuit Formbricks tussen abonnementen.",
"plan_selection_title": "Kies je abonnement",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Schakel aan het einde van de periode",
"switch_plan_now": "Schakel nu van abonnement",
"this_includes": "Dit omvat",
- "trial_alert_description": "Voeg een betaalmethode toe om toegang te houden tot alle functies.",
+ "trial_alert_description": "Sommige functies zoals opvolgingen en aangepaste links blijven vergrendeld tijdens de proefperiode. Upgrade nu om alles te ontgrendelen.",
"trial_already_used": "Er is al een gratis proefperiode gebruikt voor dit e-mailadres. Upgrade in plaats daarvan naar een betaald abonnement.",
"trial_cancels_automatically": "Je proefperiode eindigt automatisch op {date}.",
"trial_ending_add_payment_method": "Betaalmethode toevoegen",
"trial_ending_description": "Wanneer deze afloopt, verlies je toegang tot alles wat je hebt ingesteld op Pro:",
"trial_ending_title": "{count, plural, one {Nog maar # dag over in je proefperiode} other {Nog maar # dagen over in je proefperiode}}",
- "trial_payment_method_added_description": "Je bent helemaal klaar! Je Pro-abonnement wordt automatisch voortgezet na afloop van de proefperiode.",
"trial_warning_200_description": "Je hebt 200 reacties verzameld. Zodra je 250 bereikt, accepteren je enquêtes geen nieuwe reacties meer tot het einde van de periode van 30 dagen.",
"trial_warning_200_title": "Je hebt 80% van je reactielimiet bereikt",
"trial_warning_250_description": "Je hebt 250 reacties verzameld. Vanaf nu accepteren je enquêtes geen nieuwe reacties meer tot het einde van de periode van 30 dagen.",
"trial_warning_250_title": "Je hebt je limiet bereikt",
- "trial_warning_add_payment_method": "Betaalmethode toevoegen",
+ "trial_warning_add_payment_method": "Ontgrendel alle functies",
"trial_warning_remind_me_later": "Herinner me later",
"unlimited_responses": "Onbeperkte reacties",
"unlimited_workspaces": "Onbeperkt werkruimtes",
+ "unlock_all_plan_features": "Ontgrendel alle {plan}-functies",
"upgrade": "Upgraden",
"upgrade_checkout_pending": "Je abonnement wordt ingesteld…",
"upgrade_checkout_success": "Je hebt nu het {plan}-abonnement.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Reactiegegevens bijvoegen",
"follow_ups_modal_action_body_label": "Lichaam",
"follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail",
+ "follow_ups_modal_action_email_already_added": "Dit e-mailadres is al toegevoegd",
"follow_ups_modal_action_email_content": "E-mailinhoud",
+ "follow_ups_modal_action_email_input_placeholder": "Typ een e-mailadres en druk op de spatiebalk",
+ "follow_ups_modal_action_email_invalid": "Voer een geldig e-mailadres in",
"follow_ups_modal_action_email_settings": "E-mailinstellingen",
"follow_ups_modal_action_from_description": "E-mailadres waar vandaan de e-mail moet worden verzonden",
"follow_ups_modal_action_from_label": "Van",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Respondent vult enquête in",
"follow_ups_modal_updated_successfull_toast": "Follow-up bijgewerkt en wordt opgeslagen zodra u de enquête opslaat.",
"follow_ups_new": "Nieuw vervolg",
+ "follow_ups_workflows_alert_title": "Meer flexibiliteit nodig? Automatiseer follow-ups en nog veel meer met Workflows.",
"formbricks_sdk_is_not_connected": "Formbricks SDK is niet verbonden",
"four_points": "4 punten",
"heading": "Rubriek",
@@ -4134,6 +4179,119 @@
"value_number": "Waarde (getal)",
"value_text": "Waarde (tekst)"
},
+ "workflows": {
+ "add_action": "Actie toevoegen",
+ "add_trigger": "Trigger toevoegen",
+ "add_trigger_description": "Kies wat deze workflow start.",
+ "all_changes_saved": "Alle wijzigingen opgeslagen",
+ "alphabetical": "Alfabetisch",
+ "archive_confirm_body": "Archiveren schakelt deze workflow uit en stopt de uitvoering ervan. Je kunt het later weer uit het archief halen.",
+ "archive_confirm_title": "Workflow archiveren?",
+ "archive_failed": "Archiveren van de workflow is mislukt. Probeer het opnieuw.",
+ "archive_success": "Workflow gearchiveerd.",
+ "archive_workflow": "Workflow archiveren",
+ "archive_workflow_confirmation": "Weet je zeker dat je \"{name}\" wilt archiveren? Je kunt deze later herstellen.",
+ "archive_workflow_description": "Archiveren verbergt de workflow uit de lijst. Je kunt deze later herstellen.",
+ "auto_layout": "Automatische indeling",
+ "autosave_failed": "Opslaan mislukt",
+ "autosave_failed_tooltip": "Je laatste wijzigingen konden niet worden opgeslagen. Controleer je verbinding en probeer het opnieuw.",
+ "autosave_failed_tooltip_rejected": "Je laatste wijzigingen konden niet worden opgeslagen: {detail}",
+ "collapse_inspector": "Inspector inklappen",
+ "create_failed": "Aanmaken van de workflow is mislukt. Probeer het opnieuw.",
+ "delete_failed": "Verwijderen van de workflow is mislukt. Probeer het opnieuw.",
+ "delete_success": "Workflow verwijderd.",
+ "delete_workflow_confirmation": "Dit verwijdert \"{name}\" en de uitvoeringsgeschiedenis permanent.",
+ "disable_failed": "Kon de workflow niet uitschakelen.",
+ "disable_success": "Workflow uitgeschakeld.",
+ "duplicate_failed": "Dupliceren van de workflow is mislukt. Probeer het opnieuw.",
+ "duplicate_success": "Workflow gedupliceerd.",
+ "edit_blocked_active": "Schakel de workflow uit om hier wijzigingen aan te brengen.",
+ "email_attach_response_data_description": "Voeg de antwoordgegevens van de enquête toe aan de e-mailpayload.",
+ "email_attach_response_data_label": "Antwoordgegevens toevoegen",
+ "email_body_label": "Bericht",
+ "email_body_placeholder": "Schrijf het bericht dat je wilt versturen…",
+ "email_body_required": "Voeg het bericht toe om te verzenden.",
+ "email_from_label": "Van",
+ "email_include_hidden_fields_label": "Verborgen velden opnemen",
+ "email_include_variables_label": "Variabelen opnemen",
+ "email_needs_survey": "Koppel eerst een enquête in de triggerstap. De ontvanger- en berichtopties komen uit de antwoorden van de enquête.",
+ "email_reply_to_label": "Beantwoorden aan",
+ "email_set_up_trigger": "Trigger instellen",
+ "email_subject_label": "Onderwerp",
+ "email_subject_placeholder": "Bedankt voor het invullen van de enquête",
+ "email_subject_required": "Voeg een onderwerpregeltje toe.",
+ "email_to_label": "Verzenden naar",
+ "email_to_placeholder": "team@voorbeeld.nl",
+ "email_to_required": "Kies wie deze e-mail moet ontvangen.",
+ "enable_blocked_unsaved_changes": "Je laatste wijzigingen konden niet worden opgeslagen, dus de workflow is niet ingeschakeld.",
+ "enable_failed": "Workflow kon niet worden ingeschakeld.",
+ "enable_success": "Workflow ingeschakeld.",
+ "expand_inspector": "Inspector uitklappen",
+ "if_else": "Als / Anders",
+ "if_else_summary": "Splits de workflow op basis van een voorwaarde.",
+ "inspector_unsupported_node": "Dit type node heeft nog geen configuratieformulier.",
+ "load_failed": "Kon de workflow niet laden.",
+ "name_required": "Voer een naam in.",
+ "no_results_description": "Probeer je zoekopdracht of filters aan te passen.",
+ "no_results_title": "Geen workflows gevonden",
+ "no_workflows_description": "Maak je eerste workflow aan om acties te automatiseren wanneer er reacties binnenkomen.",
+ "no_workflows_title": "Nog geen workflows",
+ "node_actions": "Nodeacties",
+ "node_needs_email_content": "Stel ontvanger en inhoud in",
+ "node_needs_survey": "Kies een enquête om te beginnen",
+ "pan_mode": "Versleepstand",
+ "pointer_mode": "Aanwijzerstand",
+ "read_only": "Alleen-lezen",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {# dag geleden} other {# dagen geleden}}, {time}",
+ "relative_today": "Vandaag, {time}",
+ "relative_yesterday": "Gisteren, {time}",
+ "response_completed": "Reactie voltooid",
+ "response_completed_description": "Wordt uitgevoerd wanneer iemand een enquête volledig heeft ingevuld.",
+ "save_failed": "Kon de workflow niet opslaan.",
+ "save_success": "Workflow opgeslagen.",
+ "saving_changes": "Opslaan…",
+ "search_by_workflow_name": "Zoeken op workflownaam",
+ "send_email": "E-mail versturen",
+ "send_email_description": "Verstuur een e-mail wanneer deze workflow wordt uitgevoerd.",
+ "send_email_summary": "Verstuur een e-mail naar {to}.",
+ "send_email_unconfigured": "Configureer de e-mailontvanger.",
+ "trigger_ending_cards_label": "Eindschermen",
+ "trigger_ending_cards_none": "Deze enquête heeft geen eindschermen geconfigureerd.",
+ "trigger_ending_cards_pick_survey": "Kies een enquête om de eindschermen te zien.",
+ "trigger_ending_cards_scope_all": "Alle eindschermen",
+ "trigger_ending_cards_scope_specific": "Specifieke eindschermen",
+ "trigger_ending_cards_select_at_least_one": "Selecteer minimaal één eindscherm. Als je er geen selecteert, activeert elk eindscherm deze workflow.",
+ "trigger_summary_all_endings": "Activeren bij elke enquêterespons.",
+ "trigger_summary_ending_cards": "Activeren bij {count, plural, one {# eindkaart} other {# eindkaarten}}.",
+ "trigger_survey_description": "Kies de enquête waarvan de voltooide antwoorden deze workflow activeren.",
+ "trigger_survey_empty": "Nog geen enquêtes in deze workspace.",
+ "trigger_survey_label": "Enquête",
+ "trigger_survey_placeholder": "Selecteer een enquête",
+ "triggers": "Triggers",
+ "unarchive": "Dearchiveren",
+ "unarchive_failed": "Dearchiveren van de workflow is mislukt. Probeer het opnieuw.",
+ "unarchive_success": "Workflow gedearchiveerd.",
+ "upgrade_prompt_description": "Automatiseer taken op basis van reacties met triggers, filters en acties.",
+ "upgrade_prompt_title": "Upgrade om Workflows te ontgrendelen",
+ "validation_failed": "Workflowvalidatie mislukt.",
+ "validation_problem_fix_label": "Oplossen: {problem}",
+ "validation_problem_flow_invalid": "De workflowstappen zijn niet verbonden tot een enkele uitvoerbare flow.",
+ "validation_problem_generic": "Dit deel van de workflow heeft een configuratieprobleem.",
+ "validation_problem_name_missing": "Geef de workflow een naam.",
+ "validation_problem_step_incomplete": "Vul de ontvanger, onderwerp en tekst van de e-mailstap in.",
+ "validation_problem_step_not_executable": "Dit staptype kan nog niet worden uitgevoerd. Verwijder het voordat je de workflow activeert.",
+ "validation_problem_trigger_ending_not_found": "Een geselecteerd einde bestaat niet meer in de gekoppelde enquête.",
+ "validation_problem_trigger_missing": "Voeg een trigger toe om de workflow te starten.",
+ "validation_problem_trigger_not_connected": "Verbind een stap na de trigger.",
+ "validation_problem_trigger_survey_unbound": "Verbind de trigger met een enquête in deze workspace.",
+ "validation_problems_count": "{count, plural, one {# probleem} other {# problemen}}",
+ "validation_problems_description": "Los deze problemen op voordat de workflow kan draaien:",
+ "validation_problems_title": "Validatieproblemen",
+ "validation_status_valid": "Geldig",
+ "zoom_in": "Inzoomen",
+ "zoom_out": "Uitzoomen"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Customer Effort Score",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 0864297e313d..9866647b2b25 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Verificamos se há uma conta associada a {email}. Se não existia, criamos uma para você. Se uma conta já existia, nenhuma alteração foi feita. Por favor, faça login abaixo para continuar."
},
"verification-requested": {
+ "email_not_configured_description": "Esta instância do Formbricks não tem um servidor de e-mail configurado, por isso nenhum link de verificação pôde ser enviado. Entre em contato com seu administrador.",
+ "email_not_configured_title": "O e-mail não está configurado",
"invalid_email_address": "Endereço de email inválido",
"invalid_token": "Token inválido ☹️",
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
@@ -151,6 +155,7 @@
"accepted": "Aceito",
"account": "conta",
"account_settings": "Configurações da conta",
+ "act": "Agir",
"action": "Ação",
"actions": "Ações",
"actions_description": "Ações de Código e Sem Código são usadas para acionar interceptar pesquisas dentro de apps & em sites.",
@@ -185,6 +190,7 @@
"archive": "Arquivar",
"archived": "Arquivado",
"are_you_sure": "Certeza?",
+ "attempt": "Tentativa",
"attributes": "atributos",
"authorized_apps": "Authorized Apps",
"back": "Voltar",
@@ -193,6 +199,7 @@
"bottom_left": "canto inferior esquerdo",
"bottom_right": "Canto Inferior Direito",
"cancel": "Cancelar",
+ "canceled": "Cancelado",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
+ "editor": "Editor",
"elements": "Elementos",
"email": "Email",
"enable": "Ativar",
+ "enabled": "Ativado",
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
+ "failed": "Falhou",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar workspaces",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtro",
"finish": "Terminar",
+ "finished_at": "Finalizado em",
"first_name": "Primeiro nome",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -310,6 +321,7 @@
"imprint": "impressão",
"in_progress": "Em andamento",
"inactive_surveys": "Pesquisas inativas",
+ "input": "Entrada",
"integration": "integração",
"integrations": "Integrações",
"invalid_date_with_value": "Data inválida: {value}",
@@ -350,6 +362,7 @@
"move_up": "Subir",
"name": "Nome",
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
+ "new_workflow": "Novo fluxo de trabalho",
"next": "Próximo",
"no": "Não",
"no_actions_found": "Nenhuma ação encontrada",
@@ -388,10 +401,12 @@
"other": "outro",
"other_filters": "Outros Filtros",
"other_placeholder": "Outro espaço reservado",
+ "output": "Saída",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
"password": "Senha",
"paused": "Pausado",
+ "pending": "Pendente",
"pending_downgrade": "Rebaixamento Pendente",
"people_manager": "Experiência do Colaborador",
"person": "Pessoa",
@@ -412,6 +427,7 @@
"question": "pergunta",
"question_id": "ID da Pergunta",
"questions": "Perguntas",
+ "queued": "Na fila",
"quota": "Cota",
"quotas": "Cotas",
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
@@ -424,15 +440,20 @@
"replace": "Substituir",
"report_survey": "Relatório de Pesquisa",
"request_trial_license": "Pedir licença de teste",
+ "required": "Obrigatório",
"reset_to_default": "Restaurar para o padrão",
"resize": "Redimensionar",
"response": "Resposta",
+ "response_completed": "Resposta concluída",
"response_id": "ID da resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"retry": "Tentar novamente",
"role": "Rolê",
"row_n": "Linha {n}",
+ "run_data": "Dados de execução",
+ "running": "Executando",
+ "runs": "Execuções",
"saas": "SaaS",
"sales": "vendas",
"save": "Salvar",
@@ -468,12 +489,16 @@
"something_went_wrong": "Algo deu errado",
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
"sort_by": "Ordenar por",
+ "sort_by_value": "Ordenar por: {label}",
+ "started_at": "Iniciado em",
"status": "status",
+ "steps": "Etapas",
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
"string": "Texto",
"styling": "Estilização",
"subheader": "Subtítulo",
"submit": "Enviar",
+ "succeeded": "Bem-sucedido",
"summary": "Resumo",
"survey": "Pesquisa",
"survey_completed": "Pesquisa concluída.",
@@ -506,8 +531,11 @@
"trial_expired": "Seu período de teste expirou",
"trial_one_day_remaining": "1 dia restante no seu período de teste",
"trial_plan_badge": "Teste {plan}",
+ "trigger": "Gatilho",
+ "trigger_payload": "Payload do gatilho",
"try_again": "Tenta de novo",
"type": "Tipo",
+ "unarchive": "Desarquivar",
"undo": "Desfazer",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais workspaces com um plano superior.",
"update": "atualizar",
@@ -527,6 +555,7 @@
"verified_email": "Email Verificado",
"video": "vídeo",
"view": "Visualizar",
+ "view_workflow": "Ver fluxo de trabalho",
"warning": "Aviso",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não conseguimos verificar sua licença porque o servidor de licenças está inacessível.",
"webhook": "webhook",
@@ -536,6 +565,9 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"whats_new": "Novidades",
+ "workflow_name": "Nome do Fluxo de Trabalho",
+ "workflow_runs": "Execuções de workflows",
+ "workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_created_successfully": "Workspace criado com sucesso",
"workspace_creation_description": "Organize pesquisas em workspaces para melhor controle de acesso.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
- "response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
"schedule_your_meeting": "Agendar sua reunião",
"select_a_date": "Selecione uma data",
"survey_response_finished_email_congrats": "Parabéns, você recebeu uma nova resposta na sua pesquisa! Alguém acabou de completar sua pesquisa: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Autenticação de dois fatores",
"comparison_row_unify_feedback": "Unifique feedback de todas as fontes",
"comparison_row_unlimited_seats": "Assentos ilimitados",
+ "comparison_row_workflows": "Fluxos de trabalho",
"comparison_row_workspaces": "Workspaces",
"comparison_section_all_plans": "Todos os planos",
"comparison_section_basic_usage": "Uso principal",
"comparison_section_pro_unlocks": "Desbloqueios Pro",
"comparison_section_scale_unlocks": "Desbloqueios Scale",
+ "confirm_hobby_downgrade_body": "Seu período de teste gratuito do plano {plan} será encerrado agora e você mudará para o plano Hobby imediatamente.",
+ "confirm_hobby_downgrade_description": "Você pode fazer upgrade novamente a qualquer momento.",
+ "confirm_hobby_downgrade_title": "Mudar para o plano Hobby agora?",
+ "confirm_trial_continue_body": "Follow-ups, links personalizados e tudo mais no plano {plan} — desbloqueados instantaneamente. {chargeNow} hoje, depois {fullPrice} {period} com impostos inclusos. A cobrança começa hoje.",
+ "confirm_trial_continue_body_fallback": "Follow-ups, links personalizados e tudo mais no plano {plan} — desbloqueados instantaneamente. {fullPrice} {period} mais impostos. A cobrança começa hoje.",
+ "confirm_trial_continue_description": "Você pode mudar seu plano novamente a qualquer momento.",
+ "confirm_trial_continue_pay_now": "Pagar {chargeNow} agora",
+ "confirm_trial_continue_pay_now_generic": "Pagar agora e desbloquear",
+ "confirm_trial_continue_title": "Começar o {plan} agora?",
"confirm_upgrade_body": "Você está prestes a fazer upgrade para o plano {plan} por {amount} {period}. Uma cobrança proporcional pelo restante do seu período de cobrança atual será aplicada imediatamente, e quaisquer impostos aplicáveis serão calculados no momento do pagamento.",
"confirm_upgrade_body_with_charge": "Você está prestes a fazer upgrade para o plano {plan} ({period}). Você será cobrado {chargeNow} agora pelo restante do seu período de cobrança atual, com quaisquer impostos aplicáveis calculados no momento do pagamento.",
"confirm_upgrade_button": "Confirmar upgrade",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Fale conosco",
"contact_sales_description": "Saiba mais sobre o Formbricks para empresas e como podemos adaptar nossas soluções para você.",
"contact_sales_title": "Contato com Vendas",
- "continue_with_plan_after_trial": "Continuar com o plano Pro após o período de teste",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "Sua organização está em uma configuração de cobrança personalizada. Você ainda pode mudar para um dos planos padrão abaixo.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos",
"plan_scale_feature_security": "Autenticação 2FA e proteção contra spam",
"plan_scale_feature_semantic_analysis": "Análise Semântica (IA)",
+ "plan_scale_feature_workflows": "Fluxos de trabalho",
"plan_scale_feature_workspaces": "5 espaços de trabalho",
"plan_selection_description": "Compare os planos Hobby, Pro e Scale e mude de plano diretamente no Formbricks.",
"plan_selection_title": "Escolha seu plano",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Mudar no final do período",
"switch_plan_now": "Mudar de plano agora",
"this_includes": "Isso inclui",
- "trial_alert_description": "Adicione uma forma de pagamento para manter o acesso a todos os recursos.",
+ "trial_alert_description": "Alguns recursos como follow-ups e links personalizados permanecem bloqueados durante o período de teste. Faça upgrade agora para desbloquear tudo.",
"trial_already_used": "Um período de teste gratuito já foi usado para este endereço de e-mail. Por favor, faça upgrade para um plano pago.",
"trial_cancels_automatically": "Seu teste cancela automaticamente em {date}.",
"trial_ending_add_payment_method": "Adicionar forma de pagamento",
"trial_ending_description": "Quando terminar, você perderá acesso a tudo que configurou no Pro:",
"trial_ending_title": "{count, plural, one {Resta apenas # dia no seu período de testes} other {Restam apenas # dias no seu período de testes}}",
- "trial_payment_method_added_description": "Tudo pronto! Seu plano Pro continuará automaticamente após o término do período de teste.",
"trial_warning_200_description": "Você coletou 200 respostas. Assim que atingir 250, suas pesquisas vão parar de aceitar novas respostas até o fim do período de 30 dias.",
"trial_warning_200_title": "Você coletou 80% do seu limite de respostas",
"trial_warning_250_description": "Você coletou 250 respostas. A partir de agora, suas pesquisas não aceitarão novas respostas até o final do período de 30 dias.",
"trial_warning_250_title": "Você atingiu seu limite",
- "trial_warning_add_payment_method": "Adicionar forma de pagamento",
+ "trial_warning_add_payment_method": "Desbloquear todos os recursos",
"trial_warning_remind_me_later": "Lembrar mais tarde",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Workspaces Ilimitados",
+ "unlock_all_plan_features": "Desbloquear todos os recursos do {plan}",
"upgrade": "Atualizar",
"upgrade_checkout_pending": "Configurando seu plano…",
"upgrade_checkout_success": "Agora você está no plano {plan}.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
+ "follow_ups_modal_action_email_already_added": "Este e-mail já foi adicionado",
"follow_ups_modal_action_email_content": "Conteúdo do e-mail",
+ "follow_ups_modal_action_email_input_placeholder": "Digite um e-mail e pressione a barra de espaço",
+ "follow_ups_modal_action_email_invalid": "Por favor, insira um endereço de e-mail válido",
"follow_ups_modal_action_email_settings": "Configuração de e-mail",
"follow_ups_modal_action_from_description": "Endereço de e-mail de onde o e-mail será enviado",
"follow_ups_modal_action_from_label": "De",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
"follow_ups_new": "Novo acompanhamento",
+ "follow_ups_workflows_alert_title": "Precisa de mais flexibilidade? Automatize follow-ups e muito mais com Workflows.",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Título",
@@ -4134,6 +4179,119 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
+ "workflows": {
+ "add_action": "Adicionar ação",
+ "add_trigger": "Adicionar gatilho",
+ "add_trigger_description": "Escolha o que inicia este fluxo de trabalho.",
+ "all_changes_saved": "Todas as alterações salvas",
+ "alphabetical": "Alfabética",
+ "archive_confirm_body": "Arquivar desativa este fluxo de trabalho e impede que ele seja executado. Você pode desarquivá-lo novamente mais tarde.",
+ "archive_confirm_title": "Arquivar fluxo de trabalho?",
+ "archive_failed": "Falha ao arquivar o fluxo de trabalho. Tente novamente.",
+ "archive_success": "Fluxo de trabalho arquivado.",
+ "archive_workflow": "Arquivar fluxo de trabalho",
+ "archive_workflow_confirmation": "Tem certeza que deseja arquivar \"{name}\"? Você pode restaurá-lo depois.",
+ "archive_workflow_description": "Arquivar oculta o fluxo de trabalho da lista. Você pode restaurá-lo depois.",
+ "auto_layout": "Layout automático",
+ "autosave_failed": "Falha ao salvar",
+ "autosave_failed_tooltip": "Suas últimas alterações não puderam ser salvas. Verifique sua conexão e tente novamente.",
+ "autosave_failed_tooltip_rejected": "Suas últimas alterações não puderam ser salvas: {detail}",
+ "collapse_inspector": "Recolher inspetor",
+ "create_failed": "Falha ao criar o fluxo de trabalho. Tente novamente.",
+ "delete_failed": "Falha ao excluir o fluxo de trabalho. Tente novamente.",
+ "delete_success": "Fluxo de trabalho excluído.",
+ "delete_workflow_confirmation": "Isso exclui permanentemente \"{name}\" e seu histórico de execuções.",
+ "disable_failed": "Não foi possível desabilitar o fluxo de trabalho.",
+ "disable_success": "Fluxo de trabalho desabilitado.",
+ "duplicate_failed": "Falha ao duplicar o fluxo de trabalho. Tente novamente.",
+ "duplicate_success": "Fluxo de trabalho duplicado.",
+ "edit_blocked_active": "Desative o fluxo de trabalho para fazer alterações aqui.",
+ "email_attach_response_data_description": "Inclui a resposta da pesquisa que disparou o fluxo junto com os dados do e-mail.",
+ "email_attach_response_data_label": "Anexar dados da resposta",
+ "email_body_label": "Corpo",
+ "email_body_placeholder": "Escreva a mensagem que você quer enviar…",
+ "email_body_required": "Adicione a mensagem para enviar.",
+ "email_from_label": "De",
+ "email_include_hidden_fields_label": "Incluir campos ocultos",
+ "email_include_variables_label": "Incluir variáveis",
+ "email_needs_survey": "Conecte uma pesquisa na etapa de gatilho primeiro. As opções de destinatário e mensagem vêm das respostas da pesquisa.",
+ "email_reply_to_label": "Responder para",
+ "email_set_up_trigger": "Configurar gatilho",
+ "email_subject_label": "Assunto",
+ "email_subject_placeholder": "Obrigado por completar a pesquisa",
+ "email_subject_required": "Adicione um assunto.",
+ "email_to_label": "Enviar para",
+ "email_to_placeholder": "equipe@exemplo.com",
+ "email_to_required": "Escolha quem deve receber este e-mail.",
+ "enable_blocked_unsaved_changes": "Suas últimas alterações não puderam ser salvas, então o fluxo de trabalho não foi ativado.",
+ "enable_failed": "Não foi possível ativar o fluxo de trabalho.",
+ "enable_success": "Fluxo de trabalho ativado.",
+ "expand_inspector": "Expandir inspetor",
+ "if_else": "Se / Senão",
+ "if_else_summary": "Ramifica o fluxo de trabalho com base em uma condição.",
+ "inspector_unsupported_node": "Este tipo de nó ainda não tem um formulário de configuração.",
+ "load_failed": "Não foi possível carregar o fluxo de trabalho.",
+ "name_required": "Insira um nome.",
+ "no_results_description": "Tente ajustar sua busca ou filtros.",
+ "no_results_title": "Nenhum workflow encontrado",
+ "no_workflows_description": "Crie seu primeiro fluxo de trabalho para automatizar ações quando as respostas chegarem.",
+ "no_workflows_title": "Ainda não há workflows",
+ "node_actions": "Ações do nó",
+ "node_needs_email_content": "Definir destinatário e conteúdo",
+ "node_needs_survey": "Escolha uma pesquisa para começar",
+ "pan_mode": "Modo panorâmica",
+ "pointer_mode": "Modo ponteiro",
+ "read_only": "Somente leitura",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {# dia atrás} other {# dias atrás}}, {time}",
+ "relative_today": "Hoje, {time}",
+ "relative_yesterday": "Ontem, {time}",
+ "response_completed": "Resposta concluída",
+ "response_completed_description": "Executa quando alguém completa uma resposta de pesquisa.",
+ "save_failed": "Não foi possível salvar o fluxo de trabalho.",
+ "save_success": "Fluxo de trabalho salvo.",
+ "saving_changes": "Salvando…",
+ "search_by_workflow_name": "Buscar por nome do fluxo de trabalho",
+ "send_email": "Enviar e-mail",
+ "send_email_description": "Envia um e-mail quando este fluxo de trabalho é executado.",
+ "send_email_summary": "Enviar um e-mail para {to}.",
+ "send_email_unconfigured": "Configure o destinatário do e-mail.",
+ "trigger_ending_cards_label": "Telas de encerramento",
+ "trigger_ending_cards_none": "Esta pesquisa não tem encerramentos configurados.",
+ "trigger_ending_cards_pick_survey": "Escolha uma pesquisa para ver seus encerramentos.",
+ "trigger_ending_cards_scope_all": "Todos os encerramentos",
+ "trigger_ending_cards_scope_specific": "Encerramentos específicos",
+ "trigger_ending_cards_select_at_least_one": "Selecione pelo menos um encerramento. Com nenhum selecionado, todos os encerramentos acionam este fluxo de trabalho.",
+ "trigger_summary_all_endings": "Acionar em qualquer resposta da pesquisa.",
+ "trigger_summary_ending_cards": "Acionar em {count, plural, one {# cartão de encerramento} other {# cartões de encerramento}}.",
+ "trigger_survey_description": "Escolha a pesquisa cujas respostas concluídas acionam este fluxo de trabalho.",
+ "trigger_survey_empty": "Ainda não há pesquisas neste workspace.",
+ "trigger_survey_label": "Pesquisa",
+ "trigger_survey_placeholder": "Selecione uma pesquisa",
+ "triggers": "Gatilhos",
+ "unarchive": "Desarquivar",
+ "unarchive_failed": "Falha ao desarquivar o fluxo de trabalho. Tente novamente.",
+ "unarchive_success": "Fluxo de trabalho desarquivado.",
+ "upgrade_prompt_description": "Automatize tarefas orientadas por respostas com gatilhos, filtros e ações.",
+ "upgrade_prompt_title": "Faça upgrade para desbloquear Fluxos de trabalho",
+ "validation_failed": "Falha na validação do workflow.",
+ "validation_problem_fix_label": "Corrigir: {problem}",
+ "validation_problem_flow_invalid": "As etapas do fluxo de trabalho não estão conectadas em um fluxo executável único.",
+ "validation_problem_generic": "Esta parte do fluxo de trabalho tem um problema de configuração.",
+ "validation_problem_name_missing": "Dê um nome ao fluxo de trabalho.",
+ "validation_problem_step_incomplete": "Preencha o destinatário, assunto e corpo da etapa de e-mail.",
+ "validation_problem_step_not_executable": "Este tipo de etapa ainda não pode ser executado. Remova-o antes de ativar o fluxo de trabalho.",
+ "validation_problem_trigger_ending_not_found": "Um final selecionado não existe mais na pesquisa conectada.",
+ "validation_problem_trigger_missing": "Adicione um gatilho para iniciar o fluxo de trabalho.",
+ "validation_problem_trigger_not_connected": "Conecte uma etapa após o gatilho.",
+ "validation_problem_trigger_survey_unbound": "Conecte o gatilho a uma pesquisa neste espaço de trabalho.",
+ "validation_problems_count": "{count, plural, one {# problema} other {# problemas}}",
+ "validation_problems_description": "Corrija esses problemas antes que o fluxo de trabalho possa ser executado:",
+ "validation_problems_title": "Problemas de validação",
+ "validation_status_valid": "Válido",
+ "zoom_in": "Aumentar zoom",
+ "zoom_out": "Diminuir zoom"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Customer Effort Score",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index feffc039f973..f0a3e1a9060a 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Verificámos a existência de uma conta associada a {email}. Se não existia, criámos uma para si. Se já existia uma conta, não foram feitas alterações. Por favor, inicie sessão abaixo para continuar."
},
"verification-requested": {
+ "email_not_configured_description": "Esta instância do Formbricks não tem um servidor de email configurado, pelo que não foi possível enviar qualquer link de verificação. Por favor, contacte o seu administrador.",
+ "email_not_configured_title": "O email não está configurado",
"invalid_email_address": "Endereço de email inválido",
"invalid_token": "Token inválido ☹️",
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
@@ -151,6 +155,7 @@
"accepted": "Aceite",
"account": "Conta",
"account_settings": "Configurações da conta",
+ "act": "Agir",
"action": "Ação",
"actions": "Ações",
"actions_description": "As ações com código e sem código são usadas para acionar pesquisas de interceptação em apps e em sites.",
@@ -185,6 +190,7 @@
"archive": "Arquivar",
"archived": "Arquivado",
"are_you_sure": "Tem a certeza?",
+ "attempt": "Tentativa",
"attributes": "Atributos",
"authorized_apps": "Authorized Apps",
"back": "Voltar",
@@ -193,6 +199,7 @@
"bottom_left": "Inferior Esquerdo",
"bottom_right": "Inferior Direito",
"cancel": "Cancelar",
+ "canceled": "Cancelado",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
+ "editor": "Editor",
"elements": "Elementos",
"email": "Email",
"enable": "Ativar",
+ "enabled": "Ativado",
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
+ "failed": "Falhou",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar espaços de trabalho",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtro",
"finish": "Concluir",
+ "finished_at": "Concluído Em",
"first_name": "Primeiro nome",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -310,6 +321,7 @@
"imprint": "Impressão",
"in_progress": "Em Progresso",
"inactive_surveys": "Inquéritos inativos",
+ "input": "Entrada",
"integration": "integração",
"integrations": "Integrações",
"invalid_date_with_value": "Data inválida: {value}",
@@ -350,6 +362,7 @@
"move_up": "Mover para cima",
"name": "Nome",
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
+ "new_workflow": "Novo fluxo de trabalho",
"next": "Seguinte",
"no": "Não",
"no_actions_found": "Nenhuma ação encontrada",
@@ -388,10 +401,12 @@
"other": "Outro",
"other_filters": "Outros Filtros",
"other_placeholder": "Outro espaço reservado",
+ "output": "Saída",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
"password": "Palavra-passe",
"paused": "Em pausa",
+ "pending": "Pendente",
"pending_downgrade": "Rebaixamento Pendente",
"people_manager": "Experiência do Colaborador",
"person": "Pessoa",
@@ -412,6 +427,7 @@
"question": "pergunta",
"question_id": "ID da pergunta",
"questions": "Perguntas",
+ "queued": "Na fila",
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
@@ -424,15 +440,20 @@
"replace": "Substituir",
"report_survey": "Relatório de Inquérito",
"request_trial_license": "Solicitar licença de teste",
+ "required": "Obrigatório",
"reset_to_default": "Repor para o padrão",
"resize": "Redimensionar",
"response": "Resposta",
+ "response_completed": "Resposta concluída",
"response_id": "ID de resposta",
"responses": "Respostas",
"restart": "Reiniciar",
"retry": "Tentar novamente",
"role": "Função",
"row_n": "Linha {n}",
+ "run_data": "Dados de execução",
+ "running": "Em execução",
+ "runs": "Execuções",
"saas": "SaaS",
"sales": "Vendas",
"save": "Guardar",
@@ -468,12 +489,16 @@
"something_went_wrong": "Algo correu mal",
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
"sort_by": "Ordem",
+ "sort_by_value": "Ordem: {label}",
+ "started_at": "Iniciado Em",
"status": "Estado",
+ "steps": "Passos",
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
"string": "Texto",
"styling": "Estilo",
"subheader": "Subtítulo",
"submit": "Submeter",
+ "succeeded": "Com sucesso",
"summary": "Resumo",
"survey": "Inquérito",
"survey_completed": "Inquérito concluído.",
@@ -506,8 +531,11 @@
"trial_expired": "O teu período de teste expirou",
"trial_one_day_remaining": "1 dia restante no teu período de teste",
"trial_plan_badge": "Teste {plan}",
+ "trigger": "Acionar",
+ "trigger_payload": "Payload de acionamento",
"try_again": "Tente novamente",
"type": "Tipo",
+ "unarchive": "Desarquivar",
"undo": "Desfazer",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueia mais espaços de trabalho com um plano superior.",
"update": "Atualizar",
@@ -527,6 +555,7 @@
"verified_email": "Email verificado",
"video": "Vídeo",
"view": "Ver",
+ "view_workflow": "Ver fluxo de trabalho",
"warning": "Aviso",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não foi possível verificar a sua licença porque o servidor de licenças está inacessível.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"whats_new": "Novidades",
+ "workflow_name": "Nome do Fluxo de Trabalho",
+ "workflow_runs": "Execuções de workflows",
+ "workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_created_successfully": "Espaço de trabalho criado com sucesso",
"workspace_creation_description": "Organiza inquéritos em espaços de trabalho para um melhor controlo de acesso.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
- "response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅",
"schedule_your_meeting": "Agende a sua reunião",
"select_a_date": "Selecionar uma data",
"survey_response_finished_email_congrats": "Parabéns, recebeu uma nova resposta ao seu inquérito! Alguém acabou de completar o seu inquérito: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Autenticação de dois fatores",
"comparison_row_unify_feedback": "Unificar feedback de todas as fontes",
"comparison_row_unlimited_seats": "Lugares ilimitados",
+ "comparison_row_workflows": "Fluxos de Trabalho",
"comparison_row_workspaces": "Áreas de trabalho",
"comparison_section_all_plans": "Todos os planos",
"comparison_section_basic_usage": "Utilização principal",
"comparison_section_pro_unlocks": "Desbloqueios Pro",
"comparison_section_scale_unlocks": "Desbloqueios Scale",
+ "confirm_hobby_downgrade_body": "O teu período experimental gratuito do plano {plan} terminará agora e mudarás imediatamente para o plano Hobby.",
+ "confirm_hobby_downgrade_description": "Podes fazer upgrade novamente a qualquer momento.",
+ "confirm_hobby_downgrade_title": "Mudar para o plano Hobby agora?",
+ "confirm_trial_continue_body": "Follow-ups, ligações personalizadas e tudo o resto em {plan} — desbloqueado instantaneamente. {chargeNow} hoje, depois {fullPrice} {period} com taxas incluídas. A faturação começa hoje.",
+ "confirm_trial_continue_body_fallback": "Follow-ups, ligações personalizadas e tudo o resto em {plan} — desbloqueado instantaneamente. {fullPrice} {period} mais taxas. A faturação começa hoje.",
+ "confirm_trial_continue_description": "Podes alterar o teu plano novamente a qualquer momento.",
+ "confirm_trial_continue_pay_now": "Pagar {chargeNow} agora",
+ "confirm_trial_continue_pay_now_generic": "Pagar agora e desbloquear",
+ "confirm_trial_continue_title": "Começar o {plan} agora?",
"confirm_upgrade_body": "Estás prestes a fazer upgrade para o plano {plan} por {amount} {period}. É aplicado imediatamente um débito proporcional para o resto do teu período de faturação atual, e quaisquer impostos aplicáveis são calculados no momento do pagamento.",
"confirm_upgrade_body_with_charge": "Estás prestes a fazer upgrade para o plano {plan} ({period}). Vais ser cobrado {chargeNow} agora pelo resto do teu período de faturação atual, com quaisquer impostos aplicáveis calculados no momento do pagamento.",
"confirm_upgrade_button": "Confirmar atualização",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Fala connosco",
"contact_sales_description": "Descobre mais sobre o Formbricks para empresas e como podemos adaptar as nossas soluções para ti.",
"contact_sales_title": "Contactar Vendas",
- "continue_with_plan_after_trial": "Continuar com Pro após teste",
"current_plan_badge": "Atual",
"current_plan_cta": "Plano atual",
"custom_plan_description": "A tua organização tem uma configuração de faturação personalizada. Podes mudar para um dos planos padrão abaixo.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos",
"plan_scale_feature_security": "2FA e proteção contra spam",
"plan_scale_feature_semantic_analysis": "Análise Semântica (IA)",
+ "plan_scale_feature_workflows": "Fluxos de Trabalho",
"plan_scale_feature_workspaces": "5 áreas de trabalho",
"plan_selection_description": "Compara Hobby, Pro e Scale, e depois muda de plano diretamente no Formbricks.",
"plan_selection_title": "Escolhe o teu plano",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Mudar no fim do período",
"switch_plan_now": "Mudar de plano agora",
"this_includes": "Isto inclui",
- "trial_alert_description": "Adiciona um método de pagamento para manteres acesso a todas as funcionalidades.",
+ "trial_alert_description": "Algumas funcionalidades como follow-ups e ligações personalizadas permanecem bloqueadas durante o período de teste. Faz upgrade agora para desbloquear tudo.",
"trial_already_used": "Já foi utilizado um período de teste gratuito para este endereço de email. Por favor, atualiza para um plano pago.",
"trial_cancels_automatically": "O teu período de avaliação cancela automaticamente a {date}.",
"trial_ending_add_payment_method": "Adicionar método de pagamento",
"trial_ending_description": "Quando terminar, vais perder acesso a tudo o que configuraste no Pro:",
"trial_ending_title": "{count, plural, one {Resta apenas # dia do teu período experimental} other {Restam apenas # dias do teu período experimental}}",
- "trial_payment_method_added_description": "Está tudo pronto! O teu plano Pro continuará automaticamente após o fim do período experimental.",
"trial_warning_200_description": "Recolheste 200 respostas. Quando chegares a 250, os teus inquéritos deixarão de aceitar novas respostas até ao fim do período de 30 dias.",
"trial_warning_200_title": "Recolheste 80% do teu limite de respostas",
"trial_warning_250_description": "Recolheste 250 respostas. A partir de agora, os teus inquéritos não aceitarão novas respostas até ao final do período de 30 dias.",
"trial_warning_250_title": "Atingiste o teu limite",
- "trial_warning_add_payment_method": "Adicionar método de pagamento",
+ "trial_warning_add_payment_method": "Desbloquear todas as funcionalidades",
"trial_warning_remind_me_later": "Lembrar-me mais tarde",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Espaços de Trabalho Ilimitados",
+ "unlock_all_plan_features": "Desbloquear todas as funcionalidades do {plan}",
"upgrade": "Atualizar",
"upgrade_checkout_pending": "A configurar o teu plano…",
"upgrade_checkout_success": "Estás agora no plano {plan}.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do email",
+ "follow_ups_modal_action_email_already_added": "Este e-mail já foi adicionado",
"follow_ups_modal_action_email_content": "Conteúdo do email",
+ "follow_ups_modal_action_email_input_placeholder": "Escreve um e-mail e pressiona a barra de espaço",
+ "follow_ups_modal_action_email_invalid": "Por favor, introduz um endereço de e-mail válido",
"follow_ups_modal_action_email_settings": "Configurações de email",
"follow_ups_modal_action_from_description": "Endereço de email para enviar o email de",
"follow_ups_modal_action_from_label": "De",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Respondente conclui inquérito",
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
"follow_ups_new": "Novo acompanhamento",
+ "follow_ups_workflows_alert_title": "Precisas de mais flexibilidade? Automatiza seguimentos e muito mais com Workflows.",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Cabeçalho",
@@ -4134,6 +4179,119 @@
"value_number": "Valor (Número)",
"value_text": "Valor (Texto)"
},
+ "workflows": {
+ "add_action": "Adicionar ação",
+ "add_trigger": "Adicionar acionador",
+ "add_trigger_description": "Escolhe o que inicia este fluxo de trabalho.",
+ "all_changes_saved": "Todas as alterações guardadas",
+ "alphabetical": "Alfabética",
+ "archive_confirm_body": "Arquivar desativa este fluxo de trabalho e impede a sua execução. Podes desarquivá-lo mais tarde.",
+ "archive_confirm_title": "Arquivar fluxo de trabalho?",
+ "archive_failed": "Falha ao arquivar o fluxo de trabalho. Por favor, tenta novamente.",
+ "archive_success": "Fluxo de trabalho arquivado.",
+ "archive_workflow": "Arquivar fluxo de trabalho",
+ "archive_workflow_confirmation": "Tens a certeza de que queres arquivar \"{name}\"? Podes restaurá-lo mais tarde.",
+ "archive_workflow_description": "Arquivar oculta o fluxo de trabalho da lista. Podes restaurá-lo mais tarde.",
+ "auto_layout": "Disposição automática",
+ "autosave_failed": "Falha ao guardar",
+ "autosave_failed_tooltip": "Não foi possível guardar as tuas últimas alterações. Verifica a tua ligação e tenta novamente.",
+ "autosave_failed_tooltip_rejected": "Não foi possível guardar as tuas últimas alterações: {detail}",
+ "collapse_inspector": "Recolher inspetor",
+ "create_failed": "Falha ao criar o fluxo de trabalho. Por favor, tenta novamente.",
+ "delete_failed": "Falha ao eliminar o fluxo de trabalho. Por favor, tenta novamente.",
+ "delete_success": "Fluxo de trabalho eliminado.",
+ "delete_workflow_confirmation": "Isto elimina permanentemente \"{name}\" e o seu histórico de execuções.",
+ "disable_failed": "Não foi possível desativar o fluxo de trabalho.",
+ "disable_success": "Fluxo de trabalho desativado.",
+ "duplicate_failed": "Falha ao duplicar o fluxo de trabalho. Por favor, tenta novamente.",
+ "duplicate_success": "Fluxo de trabalho duplicado.",
+ "edit_blocked_active": "Desativa o fluxo de trabalho para fazer alterações aqui.",
+ "email_attach_response_data_description": "Incluir a resposta do inquérito que desencadeou a ação no corpo do e-mail.",
+ "email_attach_response_data_label": "Anexar dados da resposta",
+ "email_body_label": "Corpo",
+ "email_body_placeholder": "Escreve a mensagem que queres enviar…",
+ "email_body_required": "Adiciona a mensagem a enviar.",
+ "email_from_label": "De",
+ "email_include_hidden_fields_label": "Incluir campos ocultos",
+ "email_include_variables_label": "Incluir variáveis",
+ "email_needs_survey": "Liga primeiro um questionário no passo do acionador. As opções de destinatário e mensagem vêm das respostas do questionário.",
+ "email_reply_to_label": "Responder para",
+ "email_set_up_trigger": "Configurar acionador",
+ "email_subject_label": "Assunto",
+ "email_subject_placeholder": "Obrigado por completares o questionário",
+ "email_subject_required": "Adiciona uma linha de assunto.",
+ "email_to_label": "Enviar para",
+ "email_to_placeholder": "equipa@exemplo.com",
+ "email_to_required": "Escolhe quem deve receber este email.",
+ "enable_blocked_unsaved_changes": "Não foi possível guardar as tuas últimas alterações, por isso o workflow não foi ativado.",
+ "enable_failed": "Não foi possível ativar o fluxo de trabalho.",
+ "enable_success": "Fluxo de trabalho ativado.",
+ "expand_inspector": "Expandir inspetor",
+ "if_else": "Se / Senão",
+ "if_else_summary": "Ramifica o fluxo de trabalho com base numa condição.",
+ "inspector_unsupported_node": "Este tipo de nó ainda não tem um formulário de configuração.",
+ "load_failed": "Não foi possível carregar o fluxo de trabalho.",
+ "name_required": "Introduza um nome.",
+ "no_results_description": "Tenta ajustar a tua pesquisa ou filtros.",
+ "no_results_title": "Nenhum fluxo de trabalho encontrado",
+ "no_workflows_description": "Cria o teu primeiro fluxo de trabalho para automatizar ações quando chegarem respostas.",
+ "no_workflows_title": "Ainda sem fluxos de trabalho",
+ "node_actions": "Ações do nó",
+ "node_needs_email_content": "Definir destinatário e conteúdo",
+ "node_needs_survey": "Escolhe um questionário para começar",
+ "pan_mode": "Modo panorâmica",
+ "pointer_mode": "Modo ponteiro",
+ "read_only": "Apenas leitura",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {há # dia} other {há # dias}}, {time}",
+ "relative_today": "Hoje, {time}",
+ "relative_yesterday": "Ontem, {time}",
+ "response_completed": "Resposta concluída",
+ "response_completed_description": "Executa quando alguém completa uma resposta ao questionário.",
+ "save_failed": "Não foi possível guardar o fluxo de trabalho.",
+ "save_success": "Fluxo de trabalho guardado.",
+ "saving_changes": "A guardar…",
+ "search_by_workflow_name": "Pesquisar por nome do fluxo de trabalho",
+ "send_email": "Enviar e-mail",
+ "send_email_description": "Envia um email quando este fluxo de trabalho é executado.",
+ "send_email_summary": "Envia um e-mail para {to}.",
+ "send_email_unconfigured": "Configura o destinatário do e-mail.",
+ "trigger_ending_cards_label": "Cartões de finalização",
+ "trigger_ending_cards_none": "Este inquérito não tem finalizações configuradas.",
+ "trigger_ending_cards_pick_survey": "Escolhe um inquérito para ver as suas finalizações.",
+ "trigger_ending_cards_scope_all": "Todas as finalizações",
+ "trigger_ending_cards_scope_specific": "Finalizações específicas",
+ "trigger_ending_cards_select_at_least_one": "Seleciona pelo menos uma finalização. Sem nenhuma selecionada, todas as finalizações acionam este fluxo de trabalho.",
+ "trigger_summary_all_endings": "Acionar em qualquer resposta ao questionário.",
+ "trigger_summary_ending_cards": "Acionar em {count, plural, one {# cartão de finalização} other {# cartões de finalização}}.",
+ "trigger_survey_description": "Escolhe o inquérito cujas respostas completas acionam este fluxo de trabalho.",
+ "trigger_survey_empty": "Ainda não há inquéritos neste espaço de trabalho.",
+ "trigger_survey_label": "Inquérito",
+ "trigger_survey_placeholder": "Seleciona um inquérito",
+ "triggers": "Acionadores",
+ "unarchive": "Desarquivar",
+ "unarchive_failed": "Falha ao desarquivar o fluxo de trabalho. Por favor, tenta novamente.",
+ "unarchive_success": "Fluxo de trabalho desarquivado.",
+ "upgrade_prompt_description": "Automatiza tarefas orientadas por respostas com acionadores, filtros e ações.",
+ "upgrade_prompt_title": "Faz upgrade para desbloquear Fluxos de Trabalho",
+ "validation_failed": "A validação do workflow falhou.",
+ "validation_problem_fix_label": "Corrigir: {problem}",
+ "validation_problem_flow_invalid": "Os passos do fluxo de trabalho não estão ligados num fluxo executável único.",
+ "validation_problem_generic": "Esta parte do fluxo de trabalho tem um problema de configuração.",
+ "validation_problem_name_missing": "Dá um nome ao fluxo de trabalho.",
+ "validation_problem_step_incomplete": "Preenche o destinatário, assunto e corpo do passo de e-mail.",
+ "validation_problem_step_not_executable": "Este tipo de passo ainda não pode ser executado. Remove-o antes de ativar o fluxo de trabalho.",
+ "validation_problem_trigger_ending_not_found": "Um final selecionado já não existe no questionário associado.",
+ "validation_problem_trigger_missing": "Adiciona um acionador para iniciar o fluxo de trabalho.",
+ "validation_problem_trigger_not_connected": "Liga um passo após o acionador.",
+ "validation_problem_trigger_survey_unbound": "Liga o acionador a um inquérito neste espaço de trabalho.",
+ "validation_problems_count": "{count, plural, one {# problema} other {# problemas}}",
+ "validation_problems_description": "Resolve estes problemas antes de o fluxo de trabalho poder ser executado:",
+ "validation_problems_title": "Problemas de validação",
+ "validation_status_valid": "Válido",
+ "zoom_in": "Ampliar",
+ "zoom_out": "Reduzir"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Customer Effort Score",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index 699661659f9c..f8c2ba12c0bd 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Am verificat pentru un cont asociat cu {email}. Dacă nu a existat niciunul, am creat unul pentru tine. Dacă un cont deja exista, nu s-au făcut modificări. Vă rugăm să vă conectați mai jos pentru a continua."
},
"verification-requested": {
+ "email_not_configured_description": "Această instanță Formbricks nu are un server de email configurat, așa că nu a putut fi trimis niciun link de verificare. Vă rugăm să contactați administratorul.",
+ "email_not_configured_title": "Emailul nu este configurat",
"invalid_email_address": "Adresa de email invalidă",
"invalid_token": "Token invalid ☹️",
"new_email_verification_success": "Dacă adresa este validă, un email de verificare a fost trimis.",
@@ -151,6 +155,7 @@
"accepted": "Acceptat",
"account": "Cont",
"account_settings": "Setări cont",
+ "act": "Acționează",
"action": "Acțiune",
"actions": "Acțiuni",
"actions_description": "Acțiunile Cod și No-Code sunt utilizate pentru a declanșa chestionare de interceptare în aplicații și pe site-uri web.",
@@ -185,6 +190,7 @@
"archive": "Arhivează",
"archived": "Arhivat",
"are_you_sure": "Ești sigur?",
+ "attempt": "Încercare",
"attributes": "Atribute",
"authorized_apps": "Authorized Apps",
"back": "Înapoi",
@@ -193,6 +199,7 @@
"bottom_left": "Stânga Jos",
"bottom_right": "Dreapta Jos",
"cancel": "Anulare",
+ "canceled": "Anulat",
"centered_modal": "Modală centralizată",
"chart": "Grafic",
"charts": "Grafice",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
+ "editor": "Editor",
"elements": "Elemente",
"email": "Email",
"enable": "Activează",
+ "enabled": "Activat",
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
"error_rate_limit_title": "Limită de cereri depășită",
"expand_rows": "Extinde rândurile",
+ "failed": "Eșuat",
"failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtru",
"finish": "Finalizează",
+ "finished_at": "Terminat la",
"first_name": "Prenume",
"formbricks_version": "Versiunea Formbricks",
"full_name": "Nume complet",
@@ -310,6 +321,7 @@
"imprint": "Amprentă",
"in_progress": "În progres",
"inactive_surveys": "Sondaje inactive",
+ "input": "Intrare",
"integration": "integrare",
"integrations": "Integrări",
"invalid_date_with_value": "Dată invalidă: {value}",
@@ -350,6 +362,7 @@
"move_up": "Mută sus",
"name": "Nume",
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
+ "new_workflow": "Flux de lucru nou",
"next": "Următorul",
"no": "Nu",
"no_actions_found": "Nu au fost găsite acțiuni",
@@ -388,10 +401,12 @@
"other": "Altele",
"other_filters": "Alte Filtre",
"other_placeholder": "Alt substituent",
+ "output": "Ieșire",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
"password": "Parolă",
"paused": "Pauză",
+ "pending": "În așteptare",
"pending_downgrade": "Reducere în aşteptare",
"people_manager": "Experiența Angajaților",
"person": "Persoană",
@@ -412,6 +427,7 @@
"question": "întrebare",
"question_id": "ID întrebare",
"questions": "Întrebări",
+ "queued": "În coadă",
"quota": "Cotă",
"quotas": "Cote",
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
@@ -424,15 +440,20 @@
"replace": "Înlocuiește",
"report_survey": "Raportează chestionarul",
"request_trial_license": "Solicitați o licență de încercare",
+ "required": "Obligatoriu",
"reset_to_default": "Revino la implicit",
"resize": "Redimensionați",
"response": "Răspuns",
+ "response_completed": "Răspuns finalizat",
"response_id": "ID răspuns",
"responses": "Răspunsuri",
"restart": "Repornește",
"retry": "Reîncearcă",
"role": "Rolul",
"row_n": "Rândul {n}",
+ "run_data": "Date de execuție",
+ "running": "În execuție",
+ "runs": "Rulări",
"saas": "SaaS",
"sales": "Vânzări",
"save": "Salvează",
@@ -468,12 +489,16 @@
"something_went_wrong": "Ceva nu a mers bine",
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
"sort_by": "Sortare după",
+ "sort_by_value": "Sortare după: {label}",
+ "started_at": "Început la",
"status": "Stare",
+ "steps": "Pași",
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
"string": "Text",
"styling": "Stilizare",
"subheader": "Subtitlu",
"submit": "Trimite",
+ "succeeded": "Reușit",
"summary": "Sumar",
"survey": "Chestionar",
"survey_completed": "Sondaj finalizat",
@@ -506,8 +531,11 @@
"trial_expired": "Perioada ta de probă a expirat",
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
"trial_plan_badge": "Perioadă de probă {plan}",
+ "trigger": "Declanșator",
+ "trigger_payload": "Date de declanșare",
"try_again": "Încearcă din nou",
"type": "Tip",
+ "unarchive": "Dezarhivează",
"undo": "Anulează",
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
"update": "Actualizare",
@@ -527,6 +555,7 @@
"verified_email": "Email verificat",
"video": "Video",
"view": "Vezi",
+ "view_workflow": "Vezi fluxul de lucru",
"warning": "Avertisment",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"whats_new": "Ce este nou",
+ "workflow_name": "Nume flux de lucru",
+ "workflow_runs": "Rulări de workflow",
+ "workflows": "Fluxuri de lucru",
"workspace": "Spațiu de lucru",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
"response_data": "Datele răspunsului",
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
- "response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅",
"schedule_your_meeting": "Programați întâlnirea",
"select_a_date": "Selectați o dată",
"survey_response_finished_email_congrats": "Felicitări, aţi primit un răspuns nou la sondaj! Cineva tocmai a completat sondajul dumneavoastră: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Autentificare cu doi factori",
"comparison_row_unify_feedback": "Unifică feedback-ul din toate sursele",
"comparison_row_unlimited_seats": "Locuri nelimitate",
+ "comparison_row_workflows": "Fluxuri de lucru",
"comparison_row_workspaces": "Spații de lucru",
"comparison_section_all_plans": "Toate planurile",
"comparison_section_basic_usage": "Utilizare de bază",
"comparison_section_pro_unlocks": "Beneficii Pro",
"comparison_section_scale_unlocks": "Beneficii Scale",
+ "confirm_hobby_downgrade_body": "Perioada ta de probă gratuită {plan} se va încheia acum și vei trece imediat la planul Hobby.",
+ "confirm_hobby_downgrade_description": "Poți face upgrade oricând dorești.",
+ "confirm_hobby_downgrade_title": "Treci la planul Hobby acum?",
+ "confirm_trial_continue_body": "Follow-up-uri, link-uri personalizate și tot ce oferă {plan} — deblocate instantaneu. {chargeNow} astăzi, apoi {fullPrice} {period} taxe incluse. Facturarea începe astăzi.",
+ "confirm_trial_continue_body_fallback": "Follow-up-uri, link-uri personalizate și tot ce oferă {plan} — deblocate instantaneu. {fullPrice} {period} plus taxe. Facturarea începe astăzi.",
+ "confirm_trial_continue_description": "Poți schimba planul oricând dorești.",
+ "confirm_trial_continue_pay_now": "Plătește {chargeNow} acum",
+ "confirm_trial_continue_pay_now_generic": "Plătește acum și deblochează",
+ "confirm_trial_continue_title": "Pornești {plan} acum?",
"confirm_upgrade_body": "Ești pe cale să faci upgrade la planul {plan} la {amount} {period}. O taxă proporțională pentru restul perioadei curente de facturare se aplică imediat, iar taxele aplicabile sunt calculate la plată.",
"confirm_upgrade_body_with_charge": "Ești pe cale să faci upgrade la planul {plan} ({period}). Vei fi taxat cu {chargeNow} acum pentru restul perioadei curente de facturare, iar taxele aplicabile vor fi calculate la plată.",
"confirm_upgrade_button": "Confirmă upgrade-ul",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Vorbește cu noi",
"contact_sales_description": "Află mai multe despre Formbricks pentru întreprinderi și cum putem personaliza soluțiile noastre pentru tine.",
"contact_sales_title": "Contactează Vânzări",
- "continue_with_plan_after_trial": "Continuă cu Pro după perioada de probă",
"current_plan_badge": "Curent",
"current_plan_cta": "Plan curent",
"custom_plan_description": "Organizația ta folosește o configurație de facturare personalizată. Poți totuși să treci la unul dintre planurile standard de mai jos.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5.000 de răspunsuri / lună cu prețuri dinamice",
"plan_scale_feature_security": "2FA și protecție împotriva spam-ului",
"plan_scale_feature_semantic_analysis": "Analiză semantică (AI)",
+ "plan_scale_feature_workflows": "Fluxuri de lucru",
"plan_scale_feature_workspaces": "5 spații de lucru",
"plan_selection_description": "Compară Hobby, Pro și Scale, apoi schimbă planurile direct din Formbricks.",
"plan_selection_title": "Alege-ți planul",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Schimbă la sfârșitul perioadei",
"switch_plan_now": "Schimbă planul acum",
"this_includes": "Aceasta include",
- "trial_alert_description": "Adaugă o metodă de plată pentru a păstra accesul la toate funcționalitățile.",
+ "trial_alert_description": "Unele funcții precum follow-up-urile și link-urile personalizate rămân blocate pe durata perioadei de probă. Treci la un abonament acum pentru a debloca totul.",
"trial_already_used": "O perioadă de probă gratuită a fost deja utilizată pentru această adresă de email. Te rugăm să treci la un plan plătit în schimb.",
"trial_cancels_automatically": "Perioada de probă se anulează automat pe {date}.",
"trial_ending_add_payment_method": "Adaugă metodă de plată",
"trial_ending_description": "Când se va încheia, vei pierde accesul la tot ce ai configurat în Pro:",
"trial_ending_title": "{count, plural, one {Doar # zi rămasă în perioada ta de probă} few {Doar # zile rămase în perioada ta de probă} other {Doar # de zile rămase în perioada ta de probă}}",
- "trial_payment_method_added_description": "Totul este pregătit! Planul tău Pro va continua automat după ce se încheie perioada de probă.",
"trial_warning_200_description": "Ai colectat 200 de răspunsuri. Odată ce vei ajunge la 250, sondajele tale nu vor mai accepta răspunsuri noi până la sfârșitul perioadei de 30 de zile.",
"trial_warning_200_title": "Ai colectat 80% din limita de răspunsuri",
"trial_warning_250_description": "Ai colectat 250 de răspunsuri. De acum înainte, sondajele tale nu vor mai accepta răspunsuri noi până la finalul perioadei de 30 de zile.",
"trial_warning_250_title": "Ai atins limita",
- "trial_warning_add_payment_method": "Adaugă metodă de plată",
+ "trial_warning_add_payment_method": "Deblochează toate funcțiile",
"trial_warning_remind_me_later": "Amintește-mi mai târziu",
"unlimited_responses": "Răspunsuri nelimitate",
"unlimited_workspaces": "Workspaces nelimitate",
+ "unlock_all_plan_features": "Deblochează toate funcțiile {plan}",
"upgrade": "Actualizare",
"upgrade_checkout_pending": "Configurăm planul tău...",
"upgrade_checkout_success": "Acum ești pe planul {plan}.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
"follow_ups_modal_action_body_label": "Corp",
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
+ "follow_ups_modal_action_email_already_added": "Acest email a fost deja adăugat",
"follow_ups_modal_action_email_content": "Conținut email",
+ "follow_ups_modal_action_email_input_placeholder": "Scrie un email și apasă bara de spațiu",
+ "follow_ups_modal_action_email_invalid": "Te rugăm să introduci o adresă de email validă",
"follow_ups_modal_action_email_settings": "Setări email",
"follow_ups_modal_action_from_description": "Adresă de email de la care se trimite emailul",
"follow_ups_modal_action_from_label": "De la",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
"follow_ups_new": "Follow-up nou",
+ "follow_ups_workflows_alert_title": "Ai nevoie de mai multă flexibilitate? Automatizează urmăririle și multe altele cu Workflows.",
"formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat",
"four_points": "4 puncte",
"heading": "Titlu",
@@ -4134,6 +4179,119 @@
"value_number": "Valoare (număr)",
"value_text": "Valoare (Text)"
},
+ "workflows": {
+ "add_action": "Adaugă acțiune",
+ "add_trigger": "Adaugă declanșator",
+ "add_trigger_description": "Alege ce pornește acest flux de lucru.",
+ "all_changes_saved": "Toate modificările au fost salvate",
+ "alphabetical": "Alfabetic",
+ "archive_confirm_body": "Arhivarea dezactivează acest flux de lucru și îl oprește din rulare. Îl poți dezarhiva mai târziu.",
+ "archive_confirm_title": "Arhivezi fluxul de lucru?",
+ "archive_failed": "Nu s-a putut arhiva workflow-ul. Te rugăm să încerci din nou.",
+ "archive_success": "Workflow arhivat.",
+ "archive_workflow": "Arhivează workflow-ul",
+ "archive_workflow_confirmation": "Ești sigur că vrei să arhivezi \"{name}\"? Îl poți restabili mai târziu.",
+ "archive_workflow_description": "Arhivarea ascunde workflow-ul din listă. Îl poți restabili mai târziu.",
+ "auto_layout": "Aranjare automată",
+ "autosave_failed": "Salvarea a eșuat",
+ "autosave_failed_tooltip": "Ultimele modificări nu au putut fi salvate. Verifică conexiunea și încearcă din nou.",
+ "autosave_failed_tooltip_rejected": "Ultimele tale modificări nu au putut fi salvate: {detail}",
+ "collapse_inspector": "Restrânge inspectorul",
+ "create_failed": "Nu s-a putut crea workflow-ul. Te rugăm să încerci din nou.",
+ "delete_failed": "Nu s-a putut șterge workflow-ul. Te rugăm să încerci din nou.",
+ "delete_success": "Workflow șters.",
+ "delete_workflow_confirmation": "Această acțiune șterge definitiv \"{name}\" și istoricul său de rulări.",
+ "disable_failed": "Workflow-ul nu a putut fi dezactivat.",
+ "disable_success": "Workflow dezactivat.",
+ "duplicate_failed": "Nu s-a putut duplica workflow-ul. Te rugăm să încerci din nou.",
+ "duplicate_success": "Workflow duplicat.",
+ "edit_blocked_active": "Dezactivează fluxul de lucru pentru a face modificări aici.",
+ "email_attach_response_data_description": "Include răspunsul la sondaj care a declanșat fluxul împreună cu payload-ul emailului.",
+ "email_attach_response_data_label": "Atașează datele răspunsului",
+ "email_body_label": "Conținut",
+ "email_body_placeholder": "Scrie mesajul pe care vrei să-l trimiți…",
+ "email_body_required": "Adaugă mesajul de trimis.",
+ "email_from_label": "De la",
+ "email_include_hidden_fields_label": "Includeți câmpurile ascunse",
+ "email_include_variables_label": "Includeți variabilele",
+ "email_needs_survey": "Conectează mai întâi un sondaj în pasul de declanșare. Opțiunile pentru destinatar și mesaj provin din răspunsurile sondajului.",
+ "email_reply_to_label": "Răspunde la",
+ "email_set_up_trigger": "Configurează declanșatorul",
+ "email_subject_label": "Subiect",
+ "email_subject_placeholder": "Mulțumim că ai completat sondajul",
+ "email_subject_required": "Adaugă un subiect.",
+ "email_to_label": "Trimite către",
+ "email_to_placeholder": "echipa@exemplu.com",
+ "email_to_required": "Alege cine ar trebui să primească acest email.",
+ "enable_blocked_unsaved_changes": "Ultimele tale modificări nu au putut fi salvate, așa că fluxul de lucru nu a fost activat.",
+ "enable_failed": "Nu am putut activa workflow-ul.",
+ "enable_success": "Workflow activat.",
+ "expand_inspector": "Extinde inspectorul",
+ "if_else": "Dacă / Altfel",
+ "if_else_summary": "Ramifică fluxul de lucru pe baza unei condiții.",
+ "inspector_unsupported_node": "Acest tip de nod nu are încă un formular de configurare.",
+ "load_failed": "Nu am putut încărca fluxul de lucru.",
+ "name_required": "Introduceți un nume.",
+ "no_results_description": "Încearcă să ajustezi căutarea sau filtrele.",
+ "no_results_title": "Nu s-au găsit fluxuri de lucru",
+ "no_workflows_description": "Creează primul tău workflow pentru a automatiza acțiuni atunci când primești răspunsuri.",
+ "no_workflows_title": "Niciun flux de lucru încă",
+ "node_actions": "Acțiuni nod",
+ "node_needs_email_content": "Setează destinatarul și conținutul",
+ "node_needs_survey": "Alege un sondaj pentru a începe",
+ "pan_mode": "Mod panoramare",
+ "pointer_mode": "Mod cursor",
+ "read_only": "Doar citire",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {Acum # zi} few {Acum # zile} other {Acum # de zile}}, {time}",
+ "relative_today": "Astăzi, {time}",
+ "relative_yesterday": "Ieri, {time}",
+ "response_completed": "Răspuns finalizat",
+ "response_completed_description": "Se execută când cineva completează un răspuns la sondaj.",
+ "save_failed": "Nu am putut salva fluxul de lucru.",
+ "save_success": "Flux de lucru salvat.",
+ "saving_changes": "Se salvează…",
+ "search_by_workflow_name": "Caută după numele workflow-ului",
+ "send_email": "Trimite email",
+ "send_email_description": "Trimite un email când acest flux de lucru se execută.",
+ "send_email_summary": "Trimite un email către {to}.",
+ "send_email_unconfigured": "Configurează destinatarul emailului.",
+ "trigger_ending_cards_label": "Ecrane de încheiere",
+ "trigger_ending_cards_none": "Acest chestionar nu are încheieri configurate.",
+ "trigger_ending_cards_pick_survey": "Alege un chestionar pentru a vedea încheierea acestuia.",
+ "trigger_ending_cards_scope_all": "Toate încheierea",
+ "trigger_ending_cards_scope_specific": "Încheieri specifice",
+ "trigger_ending_cards_select_at_least_one": "Selectează cel puțin o încheiere. Dacă nu selectezi niciuna, fiecare încheiere va declanșa acest flux de lucru.",
+ "trigger_summary_all_endings": "Declanșare la orice răspuns la sondaj.",
+ "trigger_summary_ending_cards": "Declanșare pe {count, plural, one {# card de încheiere} few {# carduri de încheiere} other {# de carduri de încheiere}}.",
+ "trigger_survey_description": "Alege chestionarul ale cărui răspunsuri completate declanșează acest flux de lucru.",
+ "trigger_survey_empty": "Încă nu există chestionare în acest spațiu de lucru.",
+ "trigger_survey_label": "Chestionar",
+ "trigger_survey_placeholder": "Selectează un chestionar",
+ "triggers": "Declanșatori",
+ "unarchive": "Dezarhivează",
+ "unarchive_failed": "Nu s-a putut dezarhiva workflow-ul. Te rugăm să încerci din nou.",
+ "unarchive_success": "Workflow dezarhivat.",
+ "upgrade_prompt_description": "Automatizează sarcinile bazate pe răspunsuri cu declanșatori, filtre și acțiuni.",
+ "upgrade_prompt_title": "Treci la un plan superior pentru a debloca Fluxurile de lucru",
+ "validation_failed": "Validarea fluxului de lucru a eșuat.",
+ "validation_problem_fix_label": "Remediază: {problem}",
+ "validation_problem_flow_invalid": "Pașii fluxului de lucru nu sunt conectați într-un flux executabil unic.",
+ "validation_problem_generic": "Această parte a fluxului de lucru are o problemă de configurare.",
+ "validation_problem_name_missing": "Dă un nume fluxului de lucru.",
+ "validation_problem_step_incomplete": "Completează destinatarul, subiectul și corpul mesajului pentru pasul de email.",
+ "validation_problem_step_not_executable": "Acest tip de pas nu poate fi executat încă. Elimină-l înainte de a activa fluxul de lucru.",
+ "validation_problem_trigger_ending_not_found": "Un final selectat nu mai există în sondajul conectat.",
+ "validation_problem_trigger_missing": "Adaugă un declanșator pentru a porni fluxul de lucru.",
+ "validation_problem_trigger_not_connected": "Conectează un pas după declanșator.",
+ "validation_problem_trigger_survey_unbound": "Conectează declanșatorul la un chestionar din acest spațiu de lucru.",
+ "validation_problems_count": "{count, plural, one {# problemă} few {# probleme} other {# de probleme}}",
+ "validation_problems_description": "Rezolvă aceste probleme înainte ca fluxul de lucru să poată rula:",
+ "validation_problems_title": "Probleme de validare",
+ "validation_status_valid": "Valid",
+ "zoom_in": "Mărește",
+ "zoom_out": "Micșorează"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Scorul Efortului Clientului",
diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json
index 8ca769e1dbb1..0a5d35515da8 100644
--- a/apps/web/locales/ru-RU.json
+++ b/apps/web/locales/ru-RU.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Мы проверили наличие аккаунта, связанного с {email}. Если его не было, мы создали новый. Если аккаунт уже существовал, изменений не внесено. Пожалуйста, войдите ниже, чтобы продолжить."
},
"verification-requested": {
+ "email_not_configured_description": "Для этого экземпляра Formbricks не настроен почтовый сервер, поэтому ссылку для подтверждения отправить не удалось. Обратитесь к своему администратору.",
+ "email_not_configured_title": "Электронная почта не настроена",
"invalid_email_address": "Некорректный адрес электронной почты",
"invalid_token": "Недействительный токен ☹️",
"new_email_verification_success": "Если адрес действителен, письмо с подтверждением отправлено.",
@@ -151,6 +155,7 @@
"accepted": "Принято",
"account": "Аккаунт",
"account_settings": "Настройки аккаунта",
+ "act": "Действовать",
"action": "Действие",
"actions": "Действия",
"actions_description": "Действия с кодом и без кода используются для запуска опросов-перехватчиков в приложениях и на сайтах.",
@@ -185,6 +190,7 @@
"archive": "Архивировать",
"archived": "Архивный",
"are_you_sure": "Вы уверены?",
+ "attempt": "Попытка",
"attributes": "Атрибуты",
"authorized_apps": "Authorized Apps",
"back": "Назад",
@@ -193,6 +199,7 @@
"bottom_left": "Внизу слева",
"bottom_right": "Внизу справа",
"cancel": "Отмена",
+ "canceled": "Отменено",
"centered_modal": "Центрированное модальное окно",
"chart": "График",
"charts": "Графики",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
+ "editor": "Редактор",
"elements": "Элементы",
"email": "Email",
"enable": "Включить",
+ "enabled": "Включено",
"ending_card": "Завершающая карточка",
"enter_url": "Введите URL",
"enterprise_license": "Корпоративная лицензия",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Достигнуто максимальное количество запросов. Пожалуйста, попробуйте позже.",
"error_rate_limit_title": "Превышен лимит запросов",
"expand_rows": "Развернуть строки",
+ "failed": "Не удалось",
"failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена",
"failed_to_load_organizations": "Не удалось загрузить организации",
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Фильтр",
"finish": "Завершить",
+ "finished_at": "Завершено",
"first_name": "Имя",
"formbricks_version": "Версия Formbricks",
"full_name": "Полное имя",
@@ -310,6 +321,7 @@
"imprint": "Выходные данные",
"in_progress": "В процессе",
"inactive_surveys": "Неактивные опросы",
+ "input": "Входные данные",
"integration": "интеграция",
"integrations": "Интеграции",
"invalid_date_with_value": "Неверная дата: {value}",
@@ -350,6 +362,7 @@
"move_up": "Переместить вверх",
"name": "Имя",
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
+ "new_workflow": "Новый рабочий процесс",
"next": "Далее",
"no": "Нет",
"no_actions_found": "Действия не найдены",
@@ -388,10 +401,12 @@
"other": "Другое",
"other_filters": "Другие фильтры",
"other_placeholder": "Другой заполнитель",
+ "output": "Выходные данные",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
"password": "Пароль",
"paused": "Приостановлено",
+ "pending": "Ожидание",
"pending_downgrade": "Ожидает понижения тарифа",
"people_manager": "Опыт сотрудников",
"person": "Человек",
@@ -412,6 +427,7 @@
"question": "вопрос",
"question_id": "ID вопроса",
"questions": "Вопросы",
+ "queued": "В очереди",
"quota": "Квота",
"quotas": "Квоты",
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
@@ -424,15 +440,20 @@
"replace": "Заменить",
"report_survey": "Пожаловаться на опрос",
"request_trial_license": "Запросить пробную лицензию",
+ "required": "Обязательно",
"reset_to_default": "Сбросить по умолчанию",
"resize": "Изменить размер",
"response": "Ответ",
+ "response_completed": "Ответ получен",
"response_id": "ID ответа",
"responses": "Ответы",
"restart": "Перезапустить",
"retry": "Повторить",
"role": "Роль",
"row_n": "Строка {n}",
+ "run_data": "Данные выполнения",
+ "running": "Выполняется",
+ "runs": "Запуски",
"saas": "SaaS",
"sales": "Продажи",
"save": "Сохранить",
@@ -468,12 +489,16 @@
"something_went_wrong": "Что-то пошло не так",
"something_went_wrong_please_try_again": "Что-то пошло не так. Пожалуйста, попробуйте ещё раз.",
"sort_by": "Сортировать по",
+ "sort_by_value": "Сортировать по: {label}",
+ "started_at": "Начато",
"status": "Статус",
+ "steps": "Шаги",
"storage_not_configured": "Хранилище файлов не настроено, загрузка, скорее всего, не удастся",
"string": "Текст",
"styling": "Стилизация",
"subheader": "Подзаголовок",
"submit": "Отправить",
+ "succeeded": "Успешно",
"summary": "Сводка",
"survey": "Опрос",
"survey_completed": "Опрос завершён.",
@@ -506,8 +531,11 @@
"trial_expired": "Пробный период истёк",
"trial_one_day_remaining": "Остался 1 день пробного периода",
"trial_plan_badge": "Пробная версия {plan}",
+ "trigger": "Триггер",
+ "trigger_payload": "Полезная нагрузка триггера",
"try_again": "Попробуйте ещё раз",
"type": "Тип",
+ "unarchive": "Разархивировать",
"undo": "Отменить",
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
"update": "Обновить",
@@ -527,6 +555,7 @@
"verified_email": "Подтверждённый email",
"video": "Видео",
"view": "Просмотреть",
+ "view_workflow": "Посмотреть рабочий процесс",
"warning": "Предупреждение",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"whats_new": "Что нового",
+ "workflow_name": "Название рабочего процесса",
+ "workflow_runs": "Запуски рабочих процессов",
+ "workflows": "Рабочие процессы",
"workspace": "Рабочее пространство",
"workspace_created_successfully": "Рабочее пространство успешно создано",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Ссылка на загруженный файл не включена по соображениям конфиденциальности данных",
"response_data": "Данные ответа",
"response_finished_email_subject": "Ответ на {surveyName} был получен ✅",
- "response_finished_email_subject_with_email": "{personEmail} только что завершил(а) ваш опрос {surveyName} ✅",
"schedule_your_meeting": "Запланируйте встречу",
"select_a_date": "Выберите дату",
"survey_response_finished_email_congrats": "Поздравляем, вы получили новый ответ на свой опрос! Кто-то только что завершил ваш опрос: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Двухфакторная аутентификация",
"comparison_row_unify_feedback": "Объединение обратной связи из всех источников",
"comparison_row_unlimited_seats": "Неограниченное количество мест",
+ "comparison_row_workflows": "Рабочие процессы",
"comparison_row_workspaces": "Рабочие пространства",
"comparison_section_all_plans": "Все планы",
"comparison_section_basic_usage": "Основное использование",
"comparison_section_pro_unlocks": "Возможности Pro",
"comparison_section_scale_unlocks": "Возможности Scale",
+ "confirm_hobby_downgrade_body": "Ваша бесплатная пробная версия тарифа {plan} завершится сейчас, и вы сразу перейдёте на тариф Hobby.",
+ "confirm_hobby_downgrade_description": "Вы можете снова повысить тариф в любое время.",
+ "confirm_hobby_downgrade_title": "Перейти на тариф Hobby сейчас?",
+ "confirm_trial_continue_body": "Последующие действия, персонализированные ссылки и всё остальное в {plan} — доступно мгновенно. {chargeNow} сегодня, затем {fullPrice} {period} включая налоги. Выставление счетов начинается сегодня.",
+ "confirm_trial_continue_body_fallback": "Последующие действия, персонализированные ссылки и всё остальное в {plan} — доступно мгновенно. {fullPrice} {period} плюс налоги. Выставление счетов начинается сегодня.",
+ "confirm_trial_continue_description": "Вы можете изменить тариф снова в любое время.",
+ "confirm_trial_continue_pay_now": "Заплатить {chargeNow} сейчас",
+ "confirm_trial_continue_pay_now_generic": "Заплатить и разблокировать",
+ "confirm_trial_continue_title": "Начать использовать {plan} сейчас?",
"confirm_upgrade_body": "Вы собираетесь перейти на тариф {plan} по цене {amount} {period}. Пропорциональная плата за оставшуюся часть текущего расчётного периода списывается немедленно, а все применимые налоги рассчитываются при оплате.",
"confirm_upgrade_body_with_charge": "Вы собираетесь перейти на тариф {plan} ({period}). С вас будет списано {chargeNow} сейчас за оставшуюся часть текущего расчётного периода, а все применимые налоги рассчитываются при оплате.",
"confirm_upgrade_button": "Подтвердить обновление",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Поговорить с нами",
"contact_sales_description": "Узнайте больше о Formbricks для предприятий и о том, как мы можем адаптировать наши решения под ваши задачи.",
"contact_sales_title": "Связаться с отделом продаж",
- "continue_with_plan_after_trial": "Продолжить с тарифом Pro после пробного периода",
"current_plan_badge": "Текущий",
"current_plan_cta": "Текущий тариф",
"custom_plan_description": "Ваша организация использует индивидуальные настройки оплаты. Вы все равно можете переключиться на один из стандартных тарифов ниже.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5 000 ответов в месяц с динамическим ценообразованием",
"plan_scale_feature_security": "Двухфакторная аутентификация и защита от спама",
"plan_scale_feature_semantic_analysis": "Семантический анализ (AI)",
+ "plan_scale_feature_workflows": "Рабочие процессы",
"plan_scale_feature_workspaces": "5 рабочих пространств",
"plan_selection_description": "Сравни планы Hobby, Pro и Scale, а затем переключайся между ними прямо в Formbricks.",
"plan_selection_title": "Выбери свой план",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Переключить в конце периода",
"switch_plan_now": "Переключить план сейчас",
"this_includes": "Это включает",
- "trial_alert_description": "Добавьте способ оплаты, чтобы сохранить доступ ко всем функциям.",
+ "trial_alert_description": "Некоторые функции, такие как последующие действия и персонализированные ссылки, остаются заблокированными во время пробного периода. Обновите подписку сейчас, чтобы разблокировать всё.",
"trial_already_used": "Бесплатный пробный период уже был использован для этого адреса электронной почты. Пожалуйста, перейдите на платный тариф.",
"trial_cancels_automatically": "Ваш пробный период автоматически завершится {date}.",
"trial_ending_add_payment_method": "Добавить способ оплаты",
"trial_ending_description": "Когда пробный период закончится, вы потеряете доступ ко всему, что настроили в тарифе Pro:",
"trial_ending_title": "{count, plural, one {Остался всего # день пробного периода} few {Осталось всего # дня пробного периода} many {Осталось всего # дней пробного периода} other {Осталось всего # дней пробного периода}}",
- "trial_payment_method_added_description": "Всё готово! Твой тарифный план Pro продолжится автоматически после окончания пробного периода.",
"trial_warning_200_description": "Вы собрали 200 ответов. Когда достигнете 250, ваши опросы перестанут принимать новые ответы до конца 30-дневного периода.",
"trial_warning_200_title": "Вы собрали 80% от лимита ответов",
"trial_warning_250_description": "Вы собрали 250 ответов. С этого момента ваши опросы не будут принимать новые ответы до окончания 30-дневного периода.",
"trial_warning_250_title": "Вы достигли своего лимита",
- "trial_warning_add_payment_method": "Добавить способ оплаты",
+ "trial_warning_add_payment_method": "Разблокировать все функции",
"trial_warning_remind_me_later": "Напомнить позже",
"unlimited_responses": "Неограниченное количество ответов",
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
+ "unlock_all_plan_features": "Разблокировать все функции тарифа {plan}",
"upgrade": "Обновить",
"upgrade_checkout_pending": "Настраиваем твой тариф…",
"upgrade_checkout_success": "Теперь ты на тарифе {plan}.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Прикрепить данные ответа",
"follow_ups_modal_action_body_label": "Текст письма",
"follow_ups_modal_action_body_placeholder": "Текст письма",
+ "follow_ups_modal_action_email_already_added": "Этот email уже добавлен",
"follow_ups_modal_action_email_content": "Содержимое письма",
+ "follow_ups_modal_action_email_input_placeholder": "Введите email и нажмите пробел",
+ "follow_ups_modal_action_email_invalid": "Пожалуйста, введите корректный email-адрес",
"follow_ups_modal_action_email_settings": "Настройки email",
"follow_ups_modal_action_from_description": "Адрес электронной почты, с которого будет отправлено письмо",
"follow_ups_modal_action_from_label": "От кого",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Респондент завершает опрос",
"follow_ups_modal_updated_successfull_toast": "Фоллоу-ап обновлён и будет сохранён после сохранения опроса.",
"follow_ups_new": "Новый фоллоу-ап",
+ "follow_ups_workflows_alert_title": "Нужно больше гибкости? Автоматизируйте напоминания и многое другое с помощью Workflows.",
"formbricks_sdk_is_not_connected": "Formbricks SDK не подключён",
"four_points": "4 балла",
"heading": "Заголовок",
@@ -4134,6 +4179,119 @@
"value_number": "Значение (число)",
"value_text": "Значение (текст)"
},
+ "workflows": {
+ "add_action": "Добавить действие",
+ "add_trigger": "Добавить триггер",
+ "add_trigger_description": "Выберите, что запускает этот рабочий процесс.",
+ "all_changes_saved": "Все изменения сохранены",
+ "alphabetical": "По алфавиту",
+ "archive_confirm_body": "Архивирование отключает этот рабочий процесс и останавливает его выполнение. Вы сможете восстановить его позже.",
+ "archive_confirm_title": "Архивировать рабочий процесс?",
+ "archive_failed": "Не удалось архивировать воркфлоу. Попробуй ещё раз.",
+ "archive_success": "Воркфлоу архивирован.",
+ "archive_workflow": "Архивировать воркфлоу",
+ "archive_workflow_confirmation": "Точно хочешь архивировать «{name}»? Ты сможешь восстановить его позже.",
+ "archive_workflow_description": "Архивирование скрывает воркфлоу из списка. Ты сможешь восстановить его позже.",
+ "auto_layout": "Автораскладка",
+ "autosave_failed": "Не удалось сохранить",
+ "autosave_failed_tooltip": "Не удалось сохранить последние изменения. Проверьте подключение к интернету и попробуйте снова.",
+ "autosave_failed_tooltip_rejected": "Последние изменения не удалось сохранить: {detail}",
+ "collapse_inspector": "Свернуть инспектор",
+ "create_failed": "Не удалось создать воркфлоу. Попробуй ещё раз.",
+ "delete_failed": "Не удалось удалить воркфлоу. Попробуй ещё раз.",
+ "delete_success": "Воркфлоу удалён.",
+ "delete_workflow_confirmation": "Это навсегда удалит «{name}» и историю его запусков.",
+ "disable_failed": "Не удалось отключить рабочий процесс.",
+ "disable_success": "Рабочий процесс отключен.",
+ "duplicate_failed": "Не удалось дублировать воркфлоу. Попробуй ещё раз.",
+ "duplicate_success": "Воркфлоу дублирован.",
+ "edit_blocked_active": "Отключите процесс, чтобы внести изменения.",
+ "email_attach_response_data_description": "Включить в письмо данные ответа на опрос, который запустил рабочий процесс.",
+ "email_attach_response_data_label": "Прикрепить данные ответа",
+ "email_body_label": "Текст письма",
+ "email_body_placeholder": "Напишите сообщение, которое хотите отправить…",
+ "email_body_required": "Добавьте текст сообщения.",
+ "email_from_label": "От кого",
+ "email_include_hidden_fields_label": "Включить скрытые поля",
+ "email_include_variables_label": "Включить переменные",
+ "email_needs_survey": "Сначала подключи опрос на этапе триггера. Параметры получателя и сообщения берутся из ответов опроса.",
+ "email_reply_to_label": "Ответить на",
+ "email_set_up_trigger": "Настроить триггер",
+ "email_subject_label": "Тема",
+ "email_subject_placeholder": "Спасибо за прохождение опроса",
+ "email_subject_required": "Добавьте тему письма.",
+ "email_to_label": "Отправить на",
+ "email_to_placeholder": "team@example.com",
+ "email_to_required": "Укажите, кто должен получить это письмо.",
+ "enable_blocked_unsaved_changes": "Последние изменения не удалось сохранить, поэтому процесс не был включён.",
+ "enable_failed": "Не удалось включить рабочий процесс.",
+ "enable_success": "Рабочий процесс включен.",
+ "expand_inspector": "Развернуть инспектор",
+ "if_else": "Если / Иначе",
+ "if_else_summary": "Разветвить workflow на основе условия.",
+ "inspector_unsupported_node": "Для этого типа узла пока нет формы настройки.",
+ "load_failed": "Не удалось загрузить workflow.",
+ "name_required": "Введите название.",
+ "no_results_description": "Попробуйте изменить поисковый запрос или фильтры.",
+ "no_results_title": "Рабочие процессы не найдены",
+ "no_workflows_description": "Создай свой первый воркфлоу, чтобы автоматизировать действия при получении ответов.",
+ "no_workflows_title": "Пока нет рабочих процессов",
+ "node_actions": "Действия узла",
+ "node_needs_email_content": "Укажите получателя и содержимое",
+ "node_needs_survey": "Выбери опрос, чтобы начать",
+ "pan_mode": "Режим панорамирования",
+ "pointer_mode": "Режим курсора",
+ "read_only": "Только для чтения",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {# день назад} few {# дня назад} many {# дней назад} other {# дней назад}}, {time}",
+ "relative_today": "Сегодня, {time}",
+ "relative_yesterday": "Вчера, {time}",
+ "response_completed": "Ответ завершён",
+ "response_completed_description": "Запускается, когда кто-то заполняет опрос.",
+ "save_failed": "Не удалось сохранить workflow.",
+ "save_success": "Workflow сохранён.",
+ "saving_changes": "Сохранение…",
+ "search_by_workflow_name": "Поиск по названию воркфлоу",
+ "send_email": "Отправить email",
+ "send_email_description": "Отправить письмо при запуске этого рабочего процесса.",
+ "send_email_summary": "Отправить email на {to}.",
+ "send_email_unconfigured": "Настрой получателя email.",
+ "trigger_ending_cards_label": "Завершающие экраны",
+ "trigger_ending_cards_none": "В этом опросе не настроено ни одного завершающего экрана.",
+ "trigger_ending_cards_pick_survey": "Выберите опрос, чтобы увидеть его завершающие экраны.",
+ "trigger_ending_cards_scope_all": "Все завершающие экраны",
+ "trigger_ending_cards_scope_specific": "Определённые завершающие экраны",
+ "trigger_ending_cards_select_at_least_one": "Выберите хотя бы один завершающий экран. Если ничего не выбрано, каждый завершающий экран запускает этот рабочий процесс.",
+ "trigger_summary_all_endings": "Запускать при любом ответе на опрос.",
+ "trigger_summary_ending_cards": "Запускать при {count, plural, one {# финальной карточке} few {# финальных карточках} many {# финальных карточках} other {# финальных карточках}}.",
+ "trigger_survey_description": "Выберите опрос, завершённые ответы которого запускают этот рабочий процесс.",
+ "trigger_survey_empty": "В этом рабочем пространстве пока нет опросов.",
+ "trigger_survey_label": "Опрос",
+ "trigger_survey_placeholder": "Выберите опрос",
+ "triggers": "Триггеры",
+ "unarchive": "Восстановить из архива",
+ "unarchive_failed": "Не удалось восстановить воркфлоу из архива. Попробуй ещё раз.",
+ "unarchive_success": "Воркфлоу восстановлен из архива.",
+ "upgrade_prompt_description": "Автоматизируйте задачи на основе ответов с помощью триггеров, фильтров и действий.",
+ "upgrade_prompt_title": "Обновите план, чтобы получить доступ к рабочим процессам",
+ "validation_failed": "Проверка рабочего процесса не пройдена.",
+ "validation_problem_fix_label": "Исправить: {problem}",
+ "validation_problem_flow_invalid": "Шаги рабочего процесса не связаны в единый выполняемый поток.",
+ "validation_problem_generic": "Эта часть рабочего процесса имеет проблему конфигурации.",
+ "validation_problem_name_missing": "Дай рабочему процессу название.",
+ "validation_problem_step_incomplete": "Заполни получателя, тему и текст для шага отправки email.",
+ "validation_problem_step_not_executable": "Этот тип шага пока не может выполняться. Удали его перед включением рабочего процесса.",
+ "validation_problem_trigger_ending_not_found": "Выбранное окончание больше не существует в подключенном опросе.",
+ "validation_problem_trigger_missing": "Добавь триггер для запуска рабочего процесса.",
+ "validation_problem_trigger_not_connected": "Подключи шаг после триггера.",
+ "validation_problem_trigger_survey_unbound": "Привяжи триггер к опросу в этом рабочем пространстве.",
+ "validation_problems_count": "{count, plural, one {# проблема} few {# проблемы} many {# проблем} other {# проблем}}",
+ "validation_problems_description": "Исправь эти проблемы, чтобы рабочий процесс мог запуститься:",
+ "validation_problems_title": "Проблемы валидации",
+ "validation_status_valid": "Валидный",
+ "zoom_in": "Увеличить",
+ "zoom_out": "Уменьшить"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Индекс усилий клиента",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index 9caa707cb15c..d450a17d34cf 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "Vi har kontrollerat om det finns ett konto kopplat till {email}. Om inget fanns har vi skapat ett åt dig. Om ett konto redan fanns gjordes inga ändringar. Vänligen logga in nedan för att fortsätta."
},
"verification-requested": {
+ "email_not_configured_description": "Den här Formbricks-instansen har ingen e-postserver konfigurerad, så ingen verifieringslänk kunde skickas. Kontakta din administratör.",
+ "email_not_configured_title": "E-post är inte konfigurerad",
"invalid_email_address": "Ogiltig e-postadress",
"invalid_token": "Ogiltig token ☹️",
"new_email_verification_success": "Om adressen är giltig har ett verifieringsmeddelande skickats.",
@@ -151,6 +155,7 @@
"accepted": "Accepterad",
"account": "Konto",
"account_settings": "Kontoinställningar",
+ "act": "Agera",
"action": "Åtgärd",
"actions": "Åtgärder",
"actions_description": "Kod- och No-Code-åtgärder används för att utlösa enkäter i appar och på webbplatser.",
@@ -185,6 +190,7 @@
"archive": "Arkivera",
"archived": "Arkiverad",
"are_you_sure": "Är du säker?",
+ "attempt": "Försök",
"attributes": "Attribut",
"authorized_apps": "Authorized Apps",
"back": "Tillbaka",
@@ -193,6 +199,7 @@
"bottom_left": "Nedre vänster",
"bottom_right": "Nedre höger",
"cancel": "Avbryt",
+ "canceled": "Avbruten",
"centered_modal": "Centrerad modal",
"chart": "Diagram",
"charts": "Diagram",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
+ "editor": "Redigerare",
"elements": "Element",
"email": "E-post",
"enable": "Aktivera",
+ "enabled": "Aktiverad",
"ending_card": "Avslutningskort",
"enter_url": "Ange URL",
"enterprise_license": "Företagslicens",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Maximalt antal förfrågningar har nåtts. Försök igen senare.",
"error_rate_limit_title": "Begränsningsgräns överskriden",
"expand_rows": "Visa rader",
+ "failed": "Misslyckades",
"failed_to_copy_to_clipboard": "Misslyckades att kopiera till urklipp",
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Slutför",
+ "finished_at": "Avslutad",
"first_name": "Förnamn",
"formbricks_version": "Formbricks-version",
"full_name": "Fullständigt namn",
@@ -310,6 +321,7 @@
"imprint": "Impressum",
"in_progress": "Pågående",
"inactive_surveys": "Inaktiva enkäter",
+ "input": "Indata",
"integration": "integration",
"integrations": "Integrationer",
"invalid_date_with_value": "Ogiltigt datum: {value}",
@@ -350,6 +362,7 @@
"move_up": "Flytta upp",
"name": "Namn",
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
+ "new_workflow": "Nytt arbetsflöde",
"next": "Nästa",
"no": "Nej",
"no_actions_found": "Inga åtgärder hittades",
@@ -388,10 +401,12 @@
"other": "Annat",
"other_filters": "Andra filter",
"other_placeholder": "Annan platshållare",
+ "output": "Utdata",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
"password": "Lösenord",
"paused": "Pausad",
+ "pending": "Väntande",
"pending_downgrade": "Väntande nedgradering",
"people_manager": "Medarbetarupplevelse",
"person": "Person",
@@ -412,6 +427,7 @@
"question": "fråga",
"question_id": "Fråge-ID",
"questions": "Frågor",
+ "queued": "I kö",
"quota": "Kvot",
"quotas": "Kvoter",
"quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.",
@@ -424,15 +440,20 @@
"replace": "Ersätt",
"report_survey": "Rapportera enkät",
"request_trial_license": "Begär provlicens",
+ "required": "Obligatorisk",
"reset_to_default": "Återställ till standard",
"resize": "Ändra storlek",
"response": "Svar",
+ "response_completed": "Svar slutfört",
"response_id": "Svar-ID",
"responses": "Svar",
"restart": "Starta om",
"retry": "Försök igen",
"role": "Roll",
"row_n": "Rad {n}",
+ "run_data": "Körningsdata",
+ "running": "Körs",
+ "runs": "Körningar",
"saas": "SaaS",
"sales": "Försäljning",
"save": "Spara",
@@ -468,12 +489,16 @@
"something_went_wrong": "Något gick fel",
"something_went_wrong_please_try_again": "Något gick fel. Försök igen.",
"sort_by": "Sortera efter",
+ "sort_by_value": "Sortera efter: {label}",
+ "started_at": "Startad",
"status": "Status",
+ "steps": "Steg",
"storage_not_configured": "Fillagring är inte konfigurerad, uppladdningar kommer sannolikt att misslyckas",
"string": "Text",
"styling": "Styling",
"subheader": "Underrubrik",
"submit": "Skicka",
+ "succeeded": "Lyckades",
"summary": "Sammanfattning",
"survey": "Enkät",
"survey_completed": "Enkät slutförd.",
@@ -506,8 +531,11 @@
"trial_expired": "Din provperiod har gått ut",
"trial_one_day_remaining": "1 dag kvar av din provperiod",
"trial_plan_badge": "{plan} provperiod",
+ "trigger": "Utlösare",
+ "trigger_payload": "Utlösningslast",
"try_again": "Försök igen",
"type": "Typ",
+ "unarchive": "Återställ från arkiv",
"undo": "Ångra",
"unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.",
"update": "Uppdatera",
@@ -527,6 +555,7 @@
"verified_email": "Verifierad e-post",
"video": "Video",
"view": "Visa",
+ "view_workflow": "Visa arbetsflöde",
"warning": "Varning",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Vi kunde inte verifiera din licens eftersom licensservern inte kan nås.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"whats_new": "Nyheter",
+ "workflow_name": "Arbetsflödesnamn",
+ "workflow_runs": "Arbetsflödeskörningar",
+ "workflows": "Arbetsflöden",
"workspace": "Arbetsyta",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Länk till uppladdad fil ingår inte av dataskyddsskäl",
"response_data": "Svarsdata",
"response_finished_email_subject": "Ett svar för {surveyName} har slutförts ✅",
- "response_finished_email_subject_with_email": "{personEmail} har precis slutfört din {surveyName}-enkät ✅",
"schedule_your_meeting": "Boka ditt möte",
"select_a_date": "Välj ett datum",
"survey_response_finished_email_congrats": "Grattis, du har fått ett nytt svar på din enkät! Någon har precis slutfört din enkät: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "Tvåfaktorsautentisering",
"comparison_row_unify_feedback": "Samla feedback från alla källor",
"comparison_row_unlimited_seats": "Obegränsat antal platser",
+ "comparison_row_workflows": "Arbetsflöden",
"comparison_row_workspaces": "Arbetsytor",
"comparison_section_all_plans": "Alla planer",
"comparison_section_basic_usage": "Grundläggande användning",
"comparison_section_pro_unlocks": "Pro låser upp",
"comparison_section_scale_unlocks": "Scale låser upp",
+ "confirm_hobby_downgrade_body": "Din kostnadsfria provperiod för {plan} avslutas nu och du byter omedelbart till Hobby-planen.",
+ "confirm_hobby_downgrade_description": "Du kan uppgradera igen när som helst.",
+ "confirm_hobby_downgrade_title": "Byta till Hobby-planen nu?",
+ "confirm_trial_continue_body": "Uppföljningar, anpassade länkar och allt annat i {plan} – låses upp direkt. {chargeNow} idag, sedan {fullPrice} {period} inkl. moms. Faktureringen börjar idag.",
+ "confirm_trial_continue_body_fallback": "Uppföljningar, anpassade länkar och allt annat i {plan} – låses upp direkt. {fullPrice} {period} plus moms. Faktureringen börjar idag.",
+ "confirm_trial_continue_description": "Du kan ändra din plan igen när som helst.",
+ "confirm_trial_continue_pay_now": "Betala {chargeNow} nu",
+ "confirm_trial_continue_pay_now_generic": "Betala nu & lås upp",
+ "confirm_trial_continue_title": "Starta {plan} nu?",
"confirm_upgrade_body": "Du är på väg att uppgradera till {plan}-planen för {amount} {period}. En proportionell kostnad för resten av din nuvarande faktureringsperiod debiteras omedelbart, och eventuella tillämpliga skatter beräknas vid betalning.",
"confirm_upgrade_body_with_charge": "Du är på väg att uppgradera till {plan}-planen ({period}). Du debiteras {chargeNow} nu för resten av din nuvarande faktureringsperiod, och eventuella tillämpliga skatter beräknas vid betalning.",
"confirm_upgrade_button": "Bekräfta uppgradering",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Prata med oss",
"contact_sales_description": "Läs mer om Formbricks för företag och hur vi kan skräddarsy våra lösningar för dig.",
"contact_sales_title": "Kontakta försäljning",
- "continue_with_plan_after_trial": "Fortsätt med Pro efter provperioden",
"current_plan_badge": "Nuvarande",
"current_plan_cta": "Nuvarande abonnemang",
"custom_plan_description": "Din organisation har en anpassad faktureringslösning. Du kan fortfarande byta till något av standardabonnemangen nedan.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "5 000 svar / månad med dynamisk prissättning",
"plan_scale_feature_security": "2FA och skräppostskydd",
"plan_scale_feature_semantic_analysis": "Semantisk analys (AI)",
+ "plan_scale_feature_workflows": "Arbetsflöden",
"plan_scale_feature_workspaces": "5 arbetsytor",
"plan_selection_description": "Jämför Hobby, Pro och Scale och byt sedan plan direkt från Formbricks.",
"plan_selection_title": "Välj din plan",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Byt vid periodens slut",
"switch_plan_now": "Byt plan nu",
"this_includes": "Detta inkluderar",
- "trial_alert_description": "Lägg till en betalningsmetod för att behålla tillgång till alla funktioner.",
+ "trial_alert_description": "Vissa funktioner som uppföljningar och anpassade länkar förblir låsta under provperioden. Uppgradera nu för att låsa upp allt.",
"trial_already_used": "En gratis provperiod har redan använts för denna e-postadress. Uppgradera till en betald plan istället.",
"trial_cancels_automatically": "Din testperiod avslutas automatiskt den {date}.",
"trial_ending_add_payment_method": "Lägg till betalningsmetod",
"trial_ending_description": "När den tar slut förlorar du tillgång till allt du har satt upp i Pro:",
"trial_ending_title": "{count, plural, one {Bara # dag kvar av din provperiod} other {Bara # dagar kvar av din provperiod}}",
- "trial_payment_method_added_description": "Du är redo! Din Pro-plan kommer att fortsätta automatiskt efter att provperioden slutar.",
"trial_warning_200_description": "Du har samlat in 200 svar. När du når 250 kommer dina undersökningar att sluta ta emot nya svar fram till slutet av 30-dagarsperioden.",
"trial_warning_200_title": "Du har samlat in 80% av din svarsgräns",
"trial_warning_250_description": "Du har samlat in 250 svar. Från och med nu kommer dina undersökningar inte att acceptera nya svar förrän 30-dagarsperioden är slut.",
"trial_warning_250_title": "Du har nått din gräns",
- "trial_warning_add_payment_method": "Lägg till betalningsmetod",
+ "trial_warning_add_payment_method": "Lås upp alla funktioner",
"trial_warning_remind_me_later": "Påminn mig senare",
"unlimited_responses": "Obegränsade svar",
"unlimited_workspaces": "Obegränsat antal arbetsytor",
+ "unlock_all_plan_features": "Lås upp alla {plan}-funktioner",
"upgrade": "Uppgradera",
"upgrade_checkout_pending": "Ställer in din plan…",
"upgrade_checkout_success": "Du har nu {plan}-planen.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Bifoga svarsdata",
"follow_ups_modal_action_body_label": "Brödtext",
"follow_ups_modal_action_body_placeholder": "E-postmeddelandets brödtext",
+ "follow_ups_modal_action_email_already_added": "Den här e-postadressen har redan lagts till",
"follow_ups_modal_action_email_content": "E-postinnehåll",
+ "follow_ups_modal_action_email_input_placeholder": "Skriv en e-postadress och tryck på mellanslag",
+ "follow_ups_modal_action_email_invalid": "Ange en giltig e-postadress",
"follow_ups_modal_action_email_settings": "E-postinställningar",
"follow_ups_modal_action_from_description": "E-postadress att skicka e-post från",
"follow_ups_modal_action_from_label": "Från",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Respondenten slutför enkäten",
"follow_ups_modal_updated_successfull_toast": "Uppföljning uppdaterad och sparas när du sparar enkäten.",
"follow_ups_new": "Ny uppföljning",
+ "follow_ups_workflows_alert_title": "Behöver du mer flexibilitet? Automatisera uppföljningar och mycket mer med Workflows.",
"formbricks_sdk_is_not_connected": "Formbricks SDK är inte anslutet",
"four_points": "4 poäng",
"heading": "Rubrik",
@@ -4134,6 +4179,119 @@
"value_number": "Värde (antal)",
"value_text": "Värde (text)"
},
+ "workflows": {
+ "add_action": "Lägg till åtgärd",
+ "add_trigger": "Lägg till utlösare",
+ "add_trigger_description": "Välj vad som startar det här arbetsflödet.",
+ "all_changes_saved": "Alla ändringar sparade",
+ "alphabetical": "Alfabetisk",
+ "archive_confirm_body": "Arkivering inaktiverar det här arbetsflödet och stoppar det från att köras. Du kan återställa det senare.",
+ "archive_confirm_title": "Arkivera arbetsflöde?",
+ "archive_failed": "Det gick inte att arkivera arbetsflödet. Försök igen.",
+ "archive_success": "Arbetsflödet har arkiverats.",
+ "archive_workflow": "Arkivera arbetsflöde",
+ "archive_workflow_confirmation": "Är du säker på att du vill arkivera \"{name}\"? Du kan återställa det senare.",
+ "archive_workflow_description": "Arkivering döljer arbetsflödet från listan. Du kan återställa det senare.",
+ "auto_layout": "Automatisk layout",
+ "autosave_failed": "Sparande misslyckades",
+ "autosave_failed_tooltip": "Dina senaste ändringar kunde inte sparas. Kontrollera din anslutning och försök igen.",
+ "autosave_failed_tooltip_rejected": "Dina senaste ändringar kunde inte sparas: {detail}",
+ "collapse_inspector": "Dölj inspektör",
+ "create_failed": "Det gick inte att skapa arbetsflödet. Försök igen.",
+ "delete_failed": "Det gick inte att ta bort arbetsflödet. Försök igen.",
+ "delete_success": "Arbetsflödet har tagits bort.",
+ "delete_workflow_confirmation": "Detta tar permanent bort \"{name}\" och dess körningshistorik.",
+ "disable_failed": "Kunde inte inaktivera arbetsflödet.",
+ "disable_success": "Arbetsflödet inaktiverat.",
+ "duplicate_failed": "Det gick inte att duplicera arbetsflödet. Försök igen.",
+ "duplicate_success": "Arbetsflödet har duplicerats.",
+ "edit_blocked_active": "Inaktivera arbetsflödet för att göra ändringar här.",
+ "email_attach_response_data_description": "Inkludera det utlösande enkätsvaret tillsammans med e-postinnehållet.",
+ "email_attach_response_data_label": "Bifoga svarsdata",
+ "email_body_label": "Meddelande",
+ "email_body_placeholder": "Skriv meddelandet du vill skicka…",
+ "email_body_required": "Lägg till meddelandet som ska skickas.",
+ "email_from_label": "Från",
+ "email_include_hidden_fields_label": "Inkludera dolda fält",
+ "email_include_variables_label": "Inkludera variabler",
+ "email_needs_survey": "Koppla en enkät i utlösarsteget först. Mottagare och meddelandealternativ kommer från enkätens svar.",
+ "email_reply_to_label": "Svara till",
+ "email_set_up_trigger": "Konfigurera utlösare",
+ "email_subject_label": "Ämne",
+ "email_subject_placeholder": "Tack för att du slutförde enkäten",
+ "email_subject_required": "Lägg till en ämnesrad.",
+ "email_to_label": "Skicka till",
+ "email_to_placeholder": "team@example.com",
+ "email_to_required": "Välj vem som ska ta emot det här mejlet.",
+ "enable_blocked_unsaved_changes": "Dina senaste ändringar kunde inte sparas, så arbetsflödet aktiverades inte.",
+ "enable_failed": "Kunde inte aktivera arbetsflödet.",
+ "enable_success": "Arbetsflöde aktiverat.",
+ "expand_inspector": "Visa inspektör",
+ "if_else": "Om / Annars",
+ "if_else_summary": "Förgrena arbetsflödet baserat på ett villkor.",
+ "inspector_unsupported_node": "Den här nodtypen har inte något konfigurationsformulär än.",
+ "load_failed": "Kunde inte ladda arbetsflödet.",
+ "name_required": "Ange ett namn.",
+ "no_results_description": "Prova att justera din sökning eller dina filter.",
+ "no_results_title": "Inga arbetsflöden hittades",
+ "no_workflows_description": "Skapa ditt första arbetsflöde för att automatisera åtgärder när svar kommer in.",
+ "no_workflows_title": "Inga arbetsflöden än",
+ "node_actions": "Nodåtgärder",
+ "node_needs_email_content": "Ange mottagare och innehåll",
+ "node_needs_survey": "Välj en enkät för att komma igång",
+ "pan_mode": "Panoreringsläge",
+ "pointer_mode": "Pekarläge",
+ "read_only": "Skrivskyddad",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {# dag sedan} other {# dagar sedan}}, {time}",
+ "relative_today": "Idag, {time}",
+ "relative_yesterday": "Igår, {time}",
+ "response_completed": "Svar slutfört",
+ "response_completed_description": "Körs när någon slutför ett enkätsvar.",
+ "save_failed": "Kunde inte spara arbetsflödet.",
+ "save_success": "Arbetsflödet sparat.",
+ "saving_changes": "Sparar…",
+ "search_by_workflow_name": "Sök efter arbetsflödets namn",
+ "send_email": "Skicka e-post",
+ "send_email_description": "Skicka ett e-postmeddelande när det här arbetsflödet körs.",
+ "send_email_summary": "Skicka ett e-postmeddelande till {to}.",
+ "send_email_unconfigured": "Konfigurera e-postmottagaren.",
+ "trigger_ending_cards_label": "Avslutningskort",
+ "trigger_ending_cards_none": "Denna undersökning har inga avslutningar konfigurerade.",
+ "trigger_ending_cards_pick_survey": "Välj en undersökning för att se dess avslutningar.",
+ "trigger_ending_cards_scope_all": "Alla avslutningar",
+ "trigger_ending_cards_scope_specific": "Specifika avslutningar",
+ "trigger_ending_cards_select_at_least_one": "Välj minst en avslutning. Om ingen är vald utlöser varje avslutning detta arbetsflöde.",
+ "trigger_summary_all_endings": "Utlös vid alla enkätsvar.",
+ "trigger_summary_ending_cards": "Utlös vid {count, plural, one {# avslutningskort} other {# avslutningskort}}.",
+ "trigger_survey_description": "Välj den undersökning vars slutförda svar utlöser detta arbetsflöde.",
+ "trigger_survey_empty": "Inga undersökningar i den här arbetsytan ännu.",
+ "trigger_survey_label": "Undersökning",
+ "trigger_survey_placeholder": "Välj en undersökning",
+ "triggers": "Utlösare",
+ "unarchive": "Återställ",
+ "unarchive_failed": "Det gick inte att återställa arbetsflödet. Försök igen.",
+ "unarchive_success": "Arbetsflödet har återställts.",
+ "upgrade_prompt_description": "Automatisera svardrivna uppgifter med triggers, filter och åtgärder.",
+ "upgrade_prompt_title": "Uppgradera för att låsa upp Arbetsflöden",
+ "validation_failed": "Validering av arbetsflöde misslyckades.",
+ "validation_problem_fix_label": "Åtgärda: {problem}",
+ "validation_problem_flow_invalid": "Arbetsflödesstegen är inte sammankopplade till ett körbart flöde.",
+ "validation_problem_generic": "Den här delen av arbetsflödet har ett konfigurationsproblem.",
+ "validation_problem_name_missing": "Ge arbetsflödet ett namn.",
+ "validation_problem_step_incomplete": "Fyll i e-poststegets mottagare, ämne och brödtext.",
+ "validation_problem_step_not_executable": "Den här steptypen kan inte köras än. Ta bort den innan du aktiverar arbetsflödet.",
+ "validation_problem_trigger_ending_not_found": "Ett valt avslut finns inte längre i den anslutna enkäten.",
+ "validation_problem_trigger_missing": "Lägg till en utlösare för att starta arbetsflödet.",
+ "validation_problem_trigger_not_connected": "Koppla ett steg efter utlösaren.",
+ "validation_problem_trigger_survey_unbound": "Koppla utlösaren till en enkät i den här arbetsytan.",
+ "validation_problems_count": "{count, plural, one {# problem} other {# problem}}",
+ "validation_problems_description": "Åtgärda dessa problem innan arbetsflödet kan köras:",
+ "validation_problems_title": "Valideringsproblem",
+ "validation_status_valid": "Giltig",
+ "zoom_in": "Zooma in",
+ "zoom_out": "Zooma ut"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Customer Effort Score",
diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json
index cf81add80693..65e166bff8b3 100644
--- a/apps/web/locales/tr-TR.json
+++ b/apps/web/locales/tr-TR.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "{email} ile ilişkili bir hesap kontrol ettik. Yoksa sizin için bir tane oluşturduk. Zaten bir hesap varsa, herhangi bir değişiklik yapılmadı. Devam etmek için lütfen aşağıdan giriş yapın."
},
"verification-requested": {
+ "email_not_configured_description": "Bu Formbricks örneğinde yapılandırılmış bir email sunucusu yok, bu nedenle doğrulama bağlantısı gönderilemedi. Lütfen yöneticinizle iletişime geçin.",
+ "email_not_configured_title": "Email yapılandırılmamış",
"invalid_email_address": "Geçersiz email adresi",
"invalid_token": "Geçersiz token ☹️",
"new_email_verification_success": "Adres geçerliyse bir doğrulama email'i gönderildi.",
@@ -151,6 +155,7 @@
"accepted": "Kabul Edildi",
"account": "Hesap",
"account_settings": "Hesap ayarları",
+ "act": "Hareket Et",
"action": "Eylem",
"actions": "Eylemler",
"actions_description": "Kod ve Kodsuz Eylemler, uygulamalarda ve web sitelerinde survey'leri tetiklemek için kullanılır.",
@@ -185,6 +190,7 @@
"archive": "Arşivle",
"archived": "Arşivlenmiş",
"are_you_sure": "Emin misiniz?",
+ "attempt": "Deneme",
"attributes": "Öznitelikler",
"authorized_apps": "Authorized Apps",
"back": "Geri",
@@ -193,6 +199,7 @@
"bottom_left": "Sol Alt",
"bottom_right": "Sağ Alt",
"cancel": "İptal",
+ "canceled": "İptal Edildi",
"centered_modal": "Ortalanmış Modal",
"chart": "Grafik",
"charts": "Grafikler",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(kopya {copyNumber})",
"e_commerce": "E-Ticaret",
"edit": "Düzenle",
+ "editor": "Editör",
"elements": "Elemanlar",
"email": "E-posta",
"enable": "Etkinleştir",
+ "enabled": "Etkin",
"ending_card": "Bitiş kartı",
"enter_url": "URL girin",
"enterprise_license": "Kurumsal Lisans",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "Maksimum istek sayısına ulaşıldı. Lütfen daha sonra tekrar deneyin.",
"error_rate_limit_title": "İstek Sınırı Aşıldı",
"expand_rows": "Satırları genişlet",
+ "failed": "Başarısız",
"failed_to_copy_to_clipboard": "Panoya kopyalama başarısız oldu",
"failed_to_load_organizations": "Organizasyonlar yüklenemedi",
"failed_to_load_workspaces": "Çalışma alanları yüklenemedi",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtre",
"finish": "Bitir",
+ "finished_at": "Tamamlanma Zamanı",
"first_name": "Ad",
"formbricks_version": "Formbricks Sürümü",
"full_name": "Tam ad",
@@ -310,6 +321,7 @@
"imprint": "Künye",
"in_progress": "Devam Ediyor",
"inactive_surveys": "Pasif anketler",
+ "input": "Girdi",
"integration": "entegrasyon",
"integrations": "Entegrasyonlar",
"invalid_date_with_value": "Geçersiz tarih: {value}",
@@ -350,6 +362,7 @@
"move_up": "Yukarı taşı",
"name": "Ad",
"new_version_available": "Formbricks {version} burada. Şimdi yükseltin!",
+ "new_workflow": "Yeni iş akışı",
"next": "Sonraki",
"no": "Hayır",
"no_actions_found": "Eylem bulunamadı",
@@ -388,10 +401,12 @@
"other": "Diğer",
"other_filters": "Diğer Filtreler",
"other_placeholder": "Diğer Yer Tutucu",
+ "output": "Çıktı",
"overlay_color": "Kaplama rengi",
"overview": "Genel Bakış",
"password": "Şifre",
"paused": "Duraklatıldı",
+ "pending": "Beklemede",
"pending_downgrade": "Bekleyen Düşürme",
"people_manager": "Çalışan Deneyimi",
"person": "Kişi",
@@ -412,6 +427,7 @@
"question": "soru",
"question_id": "Soru ID",
"questions": "Sorular",
+ "queued": "Sırada",
"quota": "Kota",
"quotas": "Kotalar",
"quotas_description": "Belirli kriterleri karşılayan katılımcılardan aldığınız yanıt miktarını sınırlayın.",
@@ -424,15 +440,20 @@
"replace": "Değiştir",
"report_survey": "Anketi Raporla",
"request_trial_license": "Deneme lisansı iste",
+ "required": "Gerekli",
"reset_to_default": "Varsayılana sıfırla",
"resize": "Yeniden boyutlandır",
"response": "Yanıt",
+ "response_completed": "Yanıt tamamlandı",
"response_id": "Yanıt ID",
"responses": "Yanıtlar",
"restart": "Yeniden başlat",
"retry": "Yeniden dene",
"role": "Rol",
"row_n": "Satır {n}",
+ "run_data": "Çalıştırma verisi",
+ "running": "Çalışıyor",
+ "runs": "Çalıştırmalar",
"saas": "SaaS",
"sales": "Satış",
"save": "Kaydet",
@@ -468,12 +489,16 @@
"something_went_wrong": "Bir şeyler ters gitti",
"something_went_wrong_please_try_again": "Bir sorun oluştu. Lütfen tekrar deneyin.",
"sort_by": "Sıralama",
+ "sort_by_value": "Sıralama: {label}",
+ "started_at": "Başlangıç Zamanı",
"status": "Durum",
+ "steps": "Adımlar",
"storage_not_configured": "Dosya depolama yapılandırılmadı, yüklemeler muhtemelen başarısız olacak",
"string": "Metin",
"styling": "Stil",
"subheader": "Alt Başlık",
"submit": "Gönder",
+ "succeeded": "Başarılı",
"summary": "Özet",
"survey": "Anket",
"survey_completed": "Anket tamamlandı.",
@@ -506,8 +531,11 @@
"trial_expired": "Deneme süreniz doldu",
"trial_one_day_remaining": "Deneme sürenizde 1 gün kaldı",
"trial_plan_badge": "{plan} Deneme",
+ "trigger": "Tetikleyici",
+ "trigger_payload": "Tetikleyici yükü",
"try_again": "Tekrar dene",
"type": "Tür",
+ "unarchive": "Arşivden Çıkar",
"undo": "Geri Al",
"unlock_more_workspaces_with_a_higher_plan": "Daha yüksek bir planla daha fazla çalışma alanının kilidini açın.",
"update": "Güncelle",
@@ -527,6 +555,7 @@
"verified_email": "Doğrulanmış E-posta",
"video": "Video",
"view": "Görüntüle",
+ "view_workflow": "İş akışını görüntüle",
"warning": "Uyarı",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Lisans sunucusuna erişilemediği için lisansınızı doğrulayamadık.",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "hafta",
"welcome_card": "Karşılama kartı",
"whats_new": "Yenilikler",
+ "workflow_name": "İş Akışı Adı",
+ "workflow_runs": "İş akışı çalıştırmaları",
+ "workflows": "İş akışları",
"workspace": "Çalışma Alanı",
"workspace_created_successfully": "Workspace başarıyla oluşturuldu",
"workspace_creation_description": "Daha iyi erişim kontrolü için survey'leri çalışma alanlarında düzenleyin.",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "Yüklenen dosya bağlantısı veri gizliliği nedeniyle dahil edilmemiştir",
"response_data": "Yanıt verileri",
"response_finished_email_subject": "{surveyName} için bir yanıt tamamlandı ✅",
- "response_finished_email_subject_with_email": "{personEmail} az önce {surveyName} survey'inizi tamamladı ✅",
"schedule_your_meeting": "Toplantınızı planlayın",
"select_a_date": "Bir tarih seçin",
"survey_response_finished_email_congrats": "Tebrikler, survey'inize yeni bir yanıt aldınız! Birisi survey'inizi tamamladı: {surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "İki faktörlü kimlik doğrulama",
"comparison_row_unify_feedback": "Tüm kaynaklardan geri bildirimleri birleştir",
"comparison_row_unlimited_seats": "Sınırsız kullanıcı",
+ "comparison_row_workflows": "İş Akışları",
"comparison_row_workspaces": "Çalışma alanları",
"comparison_section_all_plans": "Tüm planlar",
"comparison_section_basic_usage": "Temel kullanım",
"comparison_section_pro_unlocks": "Pro özellikleri",
"comparison_section_scale_unlocks": "Scale özellikleri",
+ "confirm_hobby_downgrade_body": "Ücretsiz {plan} deneme süreniz şimdi sona erecek ve hemen Hobby planına geçeceksiniz.",
+ "confirm_hobby_downgrade_description": "İstediğiniz zaman yeniden yükseltme yapabilirsiniz.",
+ "confirm_hobby_downgrade_title": "Şimdi Hobby planına geçilsin mi?",
+ "confirm_trial_continue_body": "Takipler, özel linkler ve {plan} planındaki diğer her şey — anında kullanıma açılır. Bugün {chargeNow}, ardından vergiler dahil {period} {fullPrice}. Faturalandırma bugün başlar.",
+ "confirm_trial_continue_body_fallback": "Takipler, özel linkler ve {plan} planındaki diğer her şey — anında kullanıma açılır. {period} {fullPrice} artı vergiler. Faturalandırma bugün başlar.",
+ "confirm_trial_continue_description": "Planınızı istediğiniz zaman değiştirebilirsiniz.",
+ "confirm_trial_continue_pay_now": "Şimdi {chargeNow} öde",
+ "confirm_trial_continue_pay_now_generic": "Şimdi öde ve kilidi aç",
+ "confirm_trial_continue_title": "{plan} şimdi başlatılsın mı?",
"confirm_upgrade_body": "{plan} planına {amount} {period} olarak yükseltme yapmak üzeresin. Mevcut faturalama döneminizin geri kalanı için orantılı ücret hemen uygulanır ve ilgili vergiler ödeme sırasında hesaplanır.",
"confirm_upgrade_body_with_charge": "{plan} planına ({period}) yükseltme yapmak üzeresin. Mevcut faturalama döneminizin geri kalanı için şimdi {chargeNow} ücretlendirileceksin ve ilgili vergiler ödeme sırasında hesaplanacak.",
"confirm_upgrade_button": "Yükseltmeyi onayla",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "Bizimle konuş",
"contact_sales_description": "Kurumlar için Formbricks hakkında daha fazla bilgi edin ve çözümlerimizi sizin için nasıl özelleştirebileceğimizi keşfedin.",
"contact_sales_title": "Satış Ekibiyle İletişime Geç",
- "continue_with_plan_after_trial": "Deneme sonrası Pro ile devam et",
"current_plan_badge": "Mevcut",
"current_plan_cta": "Mevcut plan",
"custom_plan_description": "Kuruluşunuz özel bir fatura yapılandırmasına sahip. Yine de aşağıdaki standart planlardan birine geçebilirsin.",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "Ayda 5.000 yanıt, dinamik fiyatlandırma ile",
"plan_scale_feature_security": "2FA ve spam koruması",
"plan_scale_feature_semantic_analysis": "Anlamsal Analiz (Yapay Zeka)",
+ "plan_scale_feature_workflows": "İş Akışları",
"plan_scale_feature_workspaces": "5 çalışma alanı",
"plan_selection_description": "Hobby, Pro ve Scale planlarını karşılaştır, ardından doğrudan Formbricks'ten plan değiştir.",
"plan_selection_title": "Planını seç",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "Dönem sonunda geçiş yap",
"switch_plan_now": "Şimdi plan değiştir",
"this_includes": "Bu şunları içerir",
- "trial_alert_description": "Tüm özelliklere erişimi sürdürmek için bir ödeme yöntemi ekle.",
+ "trial_alert_description": "Takipler ve özel linkler gibi bazı özellikler deneme süresi boyunca kilitli kalır. Her şeyin kilidini açmak için şimdi yükselt.",
"trial_already_used": "Bu e-posta adresi için ücretsiz deneme zaten kullanılmış. Lütfen bunun yerine ücretli bir plana yükselt.",
"trial_cancels_automatically": "Deneme sürümün {date} tarihinde otomatik olarak iptal edilecek.",
"trial_ending_add_payment_method": "Ödeme yöntemi ekle",
"trial_ending_description": "Deneme süresi sona erdiğinde, Pro ile kurduğunuz her şeye erişiminizi kaybedeceksiniz:",
"trial_ending_title": "{count, plural, one {Deneme sürenizde sadece # gün kaldı} other {Deneme sürenizde sadece # gün kaldı}}",
- "trial_payment_method_added_description": "Her şey tamam! Pro planın deneme süresi sona erdikten sonra otomatik olarak devam edecek.",
"trial_warning_200_description": "200 yanıt topladınız. 250'ye ulaştığınızda, anketleriniz 30 günlük süre sonuna kadar yeni yanıt kabul etmeyi durduracak.",
"trial_warning_200_title": "Yanıt limitinin %80'ine ulaştın",
"trial_warning_250_description": "250 yanıt topladın. Bundan sonra anketlerin, 30 günlük süre sonuna kadar yeni yanıt kabul etmeyecek.",
"trial_warning_250_title": "Limitine ulaştın",
- "trial_warning_add_payment_method": "Ödeme yöntemi ekle",
+ "trial_warning_add_payment_method": "Tüm özelliklerin kilidini aç",
"trial_warning_remind_me_later": "Daha sonra hatırlat",
"unlimited_responses": "Sınırsız Yanıt",
"unlimited_workspaces": "Sınırsız Çalışma Alanı",
+ "unlock_all_plan_features": "Tüm {plan} özelliklerinin kilidini aç",
"upgrade": "Yükselt",
"upgrade_checkout_pending": "Planın ayarlanıyor…",
"upgrade_checkout_success": "Artık {plan} planındasın.",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "Yanıt verilerini ekle",
"follow_ups_modal_action_body_label": "Gövde",
"follow_ups_modal_action_body_placeholder": "E-postanın içeriği",
+ "follow_ups_modal_action_email_already_added": "Bu e-posta adresi zaten eklendi",
"follow_ups_modal_action_email_content": "E-posta içeriği",
+ "follow_ups_modal_action_email_input_placeholder": "Bir e-posta yazın ve boşluk tuşuna basın",
+ "follow_ups_modal_action_email_invalid": "Lütfen geçerli bir e-posta adresi girin",
"follow_ups_modal_action_email_settings": "E-posta ayarları",
"follow_ups_modal_action_from_description": "E-postanın gönderileceği adres",
"follow_ups_modal_action_from_label": "Gönderen",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "Katılımcı anketi tamamlıyor",
"follow_ups_modal_updated_successfull_toast": "Takip güncellendi ve anketi kaydettiğinde kaydedilecek.",
"follow_ups_new": "Yeni takip",
+ "follow_ups_workflows_alert_title": "Daha fazla esnekliğe mi ihtiyacın var? Takip işlemlerini ve daha fazlasını İş Akışları ile otomatikleştir.",
"formbricks_sdk_is_not_connected": "Formbricks SDK bağlı değil",
"four_points": "4 puan",
"heading": "Başlık",
@@ -4134,6 +4179,119 @@
"value_number": "Değer (Sayı)",
"value_text": "Değer (Metin)"
},
+ "workflows": {
+ "add_action": "Eylem ekle",
+ "add_trigger": "Tetikleyici ekle",
+ "add_trigger_description": "Bu iş akışını neyin başlatacağını seç.",
+ "all_changes_saved": "Tüm değişiklikler kaydedildi",
+ "alphabetical": "Alfabetik",
+ "archive_confirm_body": "Arşivleme bu iş akışını devre dışı bırakır ve çalışmasını durdurur. Daha sonra tekrar arşivden çıkarabilirsin.",
+ "archive_confirm_title": "İş akışı arşivlensin mi?",
+ "archive_failed": "İş akışı arşivlenemedi. Lütfen tekrar deneyin.",
+ "archive_success": "İş akışı arşivlendi.",
+ "archive_workflow": "İş akışını arşivle",
+ "archive_workflow_confirmation": "\"{name}\" iş akışını arşivlemek istediğinizden emin misiniz? Daha sonra geri yükleyebilirsiniz.",
+ "archive_workflow_description": "Arşivleme, iş akışını listeden gizler. Daha sonra geri yükleyebilirsiniz.",
+ "auto_layout": "Otomatik düzen",
+ "autosave_failed": "Kaydetme başarısız oldu",
+ "autosave_failed_tooltip": "Son değişiklikleriniz kaydedilemedi. Bağlantınızı kontrol edin ve tekrar deneyin.",
+ "autosave_failed_tooltip_rejected": "Son değişiklikleriniz kaydedilemedi: {detail}",
+ "collapse_inspector": "Denetçiyi daralt",
+ "create_failed": "İş akışı oluşturulamadı. Lütfen tekrar deneyin.",
+ "delete_failed": "İş akışı silinemedi. Lütfen tekrar deneyin.",
+ "delete_success": "İş akışı silindi.",
+ "delete_workflow_confirmation": "Bu işlem \"{name}\" iş akışını ve çalıştırma geçmişini kalıcı olarak siler.",
+ "disable_failed": "İş akışı devre dışı bırakılamadı.",
+ "disable_success": "İş akışı devre dışı bırakıldı.",
+ "duplicate_failed": "İş akışı kopyalanamadı. Lütfen tekrar deneyin.",
+ "duplicate_success": "İş akışı kopyalandı.",
+ "edit_blocked_active": "Değişiklik yapmak için iş akışını devre dışı bırakın.",
+ "email_attach_response_data_description": "E-posta yüküne tetikleyen anket yanıtını ekle.",
+ "email_attach_response_data_label": "Yanıt verisini ekle",
+ "email_body_label": "Gövde",
+ "email_body_placeholder": "Göndermek istediğin mesajı yaz…",
+ "email_body_required": "Gönderilecek mesajı ekleyin.",
+ "email_from_label": "Gönderen",
+ "email_include_hidden_fields_label": "Gizli alanları dahil et",
+ "email_include_variables_label": "Değişkenleri dahil et",
+ "email_needs_survey": "Önce tetikleyici adımında bir anket bağla. Alıcı ve mesaj seçenekleri anketin yanıtlarından gelir.",
+ "email_reply_to_label": "Yanıtla",
+ "email_set_up_trigger": "Tetikleyiciyi ayarla",
+ "email_subject_label": "Konu",
+ "email_subject_placeholder": "Anketi tamamladığın için teşekkürler",
+ "email_subject_required": "Bir konu satırı ekleyin.",
+ "email_to_label": "Gönder",
+ "email_to_placeholder": "ekip@ornek.com",
+ "email_to_required": "Bu e-postayı kimin alacağını seçin.",
+ "enable_blocked_unsaved_changes": "Son değişiklikleriniz kaydedilemediği için iş akışı etkinleştirilemedi.",
+ "enable_failed": "İş akışı etkinleştirilemedi.",
+ "enable_success": "İş akışı etkinleştirildi.",
+ "expand_inspector": "Denetçiyi genişlet",
+ "if_else": "Eğer / Değilse",
+ "if_else_summary": "İş akışını bir koşula göre dallandır.",
+ "inspector_unsupported_node": "Bu düğüm türü henüz bir yapılandırma formuna sahip değil.",
+ "load_failed": "İş akışı yüklenemedi.",
+ "name_required": "Lütfen bir ad girin.",
+ "no_results_description": "Aramayı veya filtreleri ayarlamayı dene.",
+ "no_results_title": "İş akışı bulunamadı",
+ "no_workflows_description": "Yanıtlar geldiğinde işlemleri otomatikleştirmek için ilk iş akışınızı oluşturun.",
+ "no_workflows_title": "Henüz iş akışı yok",
+ "node_actions": "Düğüm eylemleri",
+ "node_needs_email_content": "Alıcı ve içeriği ayarla",
+ "node_needs_survey": "Başlamak için bir anket seç",
+ "pan_mode": "Kaydırma modu",
+ "pointer_mode": "İşaretçi modu",
+ "read_only": "Salt okunur",
+ "relative_date": "{date}, {time}",
+ "relative_days_ago": "{count, plural, one {# gün önce} other {# gün önce}}, {time}",
+ "relative_today": "Bugün, {time}",
+ "relative_yesterday": "Dün, {time}",
+ "response_completed": "Yanıt tamamlandı",
+ "response_completed_description": "Birisi bir anket yanıtını tamamladığında çalışır.",
+ "save_failed": "İş akışı kaydedilemedi.",
+ "save_success": "İş akışı kaydedildi.",
+ "saving_changes": "Kaydediliyor…",
+ "search_by_workflow_name": "İş akışı adına göre ara",
+ "send_email": "E-posta gönder",
+ "send_email_description": "Bu iş akışı çalıştığında bir e-posta gönder.",
+ "send_email_summary": "{to} adresine e-posta gönder.",
+ "send_email_unconfigured": "E-posta alıcısını yapılandır.",
+ "trigger_ending_cards_label": "Bitiş kartları",
+ "trigger_ending_cards_none": "Bu ankette yapılandırılmış bitiş yok.",
+ "trigger_ending_cards_pick_survey": "Bitişlerini görmek için bir anket seç.",
+ "trigger_ending_cards_scope_all": "Tüm bitişler",
+ "trigger_ending_cards_scope_specific": "Belirli bitişler",
+ "trigger_ending_cards_select_at_least_one": "En az bir bitiş seç. Hiçbiri seçilmezse, her bitiş bu iş akışını tetikler.",
+ "trigger_summary_all_endings": "Herhangi bir anket yanıtında tetikle.",
+ "trigger_summary_ending_cards": "{count, plural, one {# bitiş kartında} other {# bitiş kartında}} tetikle.",
+ "trigger_survey_description": "Tamamlanan yanıtları bu iş akışını tetikleyecek anketi seç.",
+ "trigger_survey_empty": "Bu çalışma alanında henüz anket yok.",
+ "trigger_survey_label": "Anket",
+ "trigger_survey_placeholder": "Bir anket seç",
+ "triggers": "Tetikleyiciler",
+ "unarchive": "Arşivden çıkar",
+ "unarchive_failed": "İş akışı arşivden çıkarılamadı. Lütfen tekrar deneyin.",
+ "unarchive_success": "İş akışı arşivden çıkarıldı.",
+ "upgrade_prompt_description": "Tetikleyiciler, filtreler ve eylemlerle yanıt odaklı görevleri otomatikleştir.",
+ "upgrade_prompt_title": "İş Akışlarının kilidini açmak için yükselt",
+ "validation_failed": "İş akışı doğrulaması başarısız oldu.",
+ "validation_problem_fix_label": "Düzelt: {problem}",
+ "validation_problem_flow_invalid": "İş akışı adımları tek bir çalıştırılabilir akışa bağlı değil.",
+ "validation_problem_generic": "İş akışının bu kısmında bir yapılandırma sorunu var.",
+ "validation_problem_name_missing": "İş akışına bir isim ver.",
+ "validation_problem_step_incomplete": "E-posta adımının alıcısını, konusunu ve içeriğini doldur.",
+ "validation_problem_step_not_executable": "Bu adım türü henüz çalıştırılamıyor. İş akışını etkinleştirmeden önce kaldır.",
+ "validation_problem_trigger_ending_not_found": "Seçilen son, bağlı ankette artık mevcut değil.",
+ "validation_problem_trigger_missing": "İş akışını başlatmak için bir tetikleyici ekle.",
+ "validation_problem_trigger_not_connected": "Tetikleyiciden sonra bir adım bağla.",
+ "validation_problem_trigger_survey_unbound": "Tetikleyiciyi bu çalışma alanındaki bir ankete bağla.",
+ "validation_problems_count": "{count, plural, one {# sorun} other {# sorun}}",
+ "validation_problems_description": "İş akışının çalışabilmesi için bu sorunları çöz:",
+ "validation_problems_title": "Doğrulama sorunları",
+ "validation_status_valid": "Geçerli",
+ "zoom_in": "Yakınlaştır",
+ "zoom_out": "Uzaklaştır"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "Müşteri Çaba Skoru",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index 65f5f4350085..ee1b8c4cba03 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "我们已检查与 {email} 相关联的账户。如果没有,我们已为您创建一个。如果已有账户,则未作任何更改。请在下面登录以继续。"
},
"verification-requested": {
+ "email_not_configured_description": "此 Formbricks 实例未配置邮件服务器,因此无法发送验证链接。请联系您的管理员。",
+ "email_not_configured_title": "邮件未配置",
"invalid_email_address": "无效 的 电子 邮件 地址",
"invalid_token": "无效的 token ☹️",
"new_email_verification_success": "如果 地址 有效,验证 电子邮件 已发送。",
@@ -151,6 +155,7 @@
"accepted": "已接受",
"account": "账号",
"account_settings": "帐户设置",
+ "act": "行动",
"action": "操作",
"actions": "操作",
"actions_description": "代码 和 无代码 操作 用于 触发 拦截 调查 在 应用程序 和 网站 中。",
@@ -185,6 +190,7 @@
"archive": "归档",
"archived": "已归档",
"are_you_sure": "你 确定 吗?",
+ "attempt": "尝试",
"attributes": "属性",
"authorized_apps": "Authorized Apps",
"back": "返回",
@@ -193,6 +199,7 @@
"bottom_left": "右下",
"bottom_right": "右下",
"cancel": "取消",
+ "canceled": "已取消",
"centered_modal": "居中 模态",
"chart": "图表",
"charts": "图表",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(副本 {copyNumber})",
"e_commerce": "电子商务",
"edit": "编辑",
+ "editor": "编辑器",
"elements": "元素",
"email": "邮箱",
"enable": "启用",
+ "enabled": "已启用",
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
"enterprise_license": "企业 许可证",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
"error_rate_limit_title": "速率 限制 超过",
"expand_rows": "展开 行",
+ "failed": "失败",
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_workspaces": "加载工作区失败",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "筛选",
"finish": "完成",
+ "finished_at": "完成时间",
"first_name": "名字",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -310,6 +321,7 @@
"imprint": "印记",
"in_progress": "进行中",
"inactive_surveys": "不 活跃 调查",
+ "input": "输入",
"integration": "集成",
"integrations": "集成",
"invalid_date_with_value": "无效 日期: {value}",
@@ -350,6 +362,7 @@
"move_up": "上移",
"name": "名称",
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
+ "new_workflow": "新建工作流",
"next": "下一步",
"no": "否",
"no_actions_found": "未找到操作",
@@ -388,10 +401,12 @@
"other": "其他",
"other_filters": "其他筛选条件",
"other_placeholder": "其他占位符",
+ "output": "输出",
"overlay_color": "覆盖层颜色",
"overview": "概览",
"password": "密码",
"paused": "暂停",
+ "pending": "待处理",
"pending_downgrade": "等待降级",
"people_manager": "员工体验",
"person": "人员",
@@ -412,6 +427,7 @@
"question": "问题",
"question_id": "问题 ID",
"questions": "问题",
+ "queued": "排队中",
"quota": "配额",
"quotas": "配额",
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
@@ -424,15 +440,20 @@
"replace": "替换",
"report_survey": "报告调查",
"request_trial_license": "申请试用许可证",
+ "required": "必填",
"reset_to_default": "重置为 默认",
"resize": "调整大小",
"response": "响应",
+ "response_completed": "响应已完成",
"response_id": "响应 ID",
"responses": "反馈",
"restart": "重新启动",
"retry": "重试",
"role": "角色",
"row_n": "第 {n} 行",
+ "run_data": "运行数据",
+ "running": "运行中",
+ "runs": "运行",
"saas": "SaaS",
"sales": "销售",
"save": "保存",
@@ -468,12 +489,16 @@
"something_went_wrong": "出错了",
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
"sort_by": "排序 依据",
+ "sort_by_value": "排序 依据: {label}",
+ "started_at": "开始时间",
"status": "状态",
+ "steps": "步骤",
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
"string": "文本",
"styling": "样式",
"subheader": "副标题",
"submit": "提交",
+ "succeeded": "成功",
"summary": "概要",
"survey": "调查",
"survey_completed": "调查 完成",
@@ -506,8 +531,11 @@
"trial_expired": "您的试用期已过期",
"trial_one_day_remaining": "试用期还剩 1 天",
"trial_plan_badge": "{plan} 试用版",
+ "trigger": "触发器",
+ "trigger_payload": "触发负载",
"try_again": "再试一次",
"type": "类型",
+ "unarchive": "取消归档",
"undo": "撤销",
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
"update": "更新",
@@ -527,6 +555,7 @@
"verified_email": "已验证 电子邮件",
"video": "视频",
"view": "查看",
+ "view_workflow": "查看工作流",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我们无法验证您的许可证,因为许可证服务器无法访问。",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "周",
"welcome_card": "欢迎 卡片",
"whats_new": "最新动态",
+ "workflow_name": "工作流名称",
+ "workflow_runs": "工作流运行",
+ "workflows": "工作流",
"workspace": "工作区",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "未包括上传文件的链接 数据隐私原因",
"response_data": "响应数据",
"response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅",
- "response_finished_email_subject_with_email": "{personEmail} 刚刚完成了你的 {surveyName} 调查 ✅",
"schedule_your_meeting": "安排你的会议",
"select_a_date": "选择 日期",
"survey_response_finished_email_congrats": "恭喜,您收到了一份新的问卷回复!有人刚刚完成了您的问卷:{surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "双因素身份验证",
"comparison_row_unify_feedback": "统一所有来源的反馈",
"comparison_row_unlimited_seats": "无限席位",
+ "comparison_row_workflows": "工作流",
"comparison_row_workspaces": "工作区",
"comparison_section_all_plans": "所有套餐",
"comparison_section_basic_usage": "核心用量",
"comparison_section_pro_unlocks": "专业版功能",
"comparison_section_scale_unlocks": "企业版功能",
+ "confirm_hobby_downgrade_body": "你的免费 {plan} 试用将立即结束,并立即切换到 Hobby 套餐。",
+ "confirm_hobby_downgrade_description": "你可以随时再次升级。",
+ "confirm_hobby_downgrade_title": "立即切换到 Hobby 套餐?",
+ "confirm_trial_continue_body": "后续跟进、自定义链接以及 {plan} 中的所有功能 — 即刻解锁。 今日支付 {chargeNow},之后每{period} {fullPrice}(含税)。计费从今日开始。",
+ "confirm_trial_continue_body_fallback": "后续跟进、自定义链接以及 {plan} 中的所有功能 — 即刻解锁。 每{period} {fullPrice}(另加税费)。计费从今日开始。",
+ "confirm_trial_continue_description": "你可以随时再次更改套餐。",
+ "confirm_trial_continue_pay_now": "立即支付 {chargeNow}",
+ "confirm_trial_continue_pay_now_generic": "立即支付并解锁",
+ "confirm_trial_continue_title": "立即开始使用 {plan}?",
"confirm_upgrade_body": "你即将升级到 {plan} 套餐,价格为 {amount} {period}。当前计费周期的剩余时间将立即按比例收费,并在付款时计算任何适用的税费。",
"confirm_upgrade_body_with_charge": "你即将升级到 {plan} 套餐({period})。你将立即支付 {chargeNow},用于当前计费周期的剩余时间,并在付款时计算任何适用的税费。",
"confirm_upgrade_button": "确认升级",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "联系我们",
"contact_sales_description": "了解更多关于 Formbricks 企业版的信息,以及我们如何为你量身定制解决方案。",
"contact_sales_title": "联系销售",
- "continue_with_plan_after_trial": "试用期结束后继续使用专业版",
"current_plan_badge": "当前",
"current_plan_cta": "当前方案",
"custom_plan_description": "您的组织使用的是自定义计费设置。您仍然可以切换到下面的标准方案。",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "每月 5,000 次响应,采用动态定价",
"plan_scale_feature_security": "双因素认证和垃圾邮件防护",
"plan_scale_feature_semantic_analysis": "语义分析(AI)",
+ "plan_scale_feature_workflows": "工作流",
"plan_scale_feature_workspaces": "5 个工作区",
"plan_selection_description": "比较 Hobby、Pro 和 Scale 套餐,然后直接从 Formbricks 切换套餐。",
"plan_selection_title": "选择您的套餐",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "在周期结束时切换",
"switch_plan_now": "立即切换套餐",
"this_includes": "包含以下内容",
- "trial_alert_description": "添加支付方式以继续使用所有功能。",
+ "trial_alert_description": "试用期间,后续跟进和自定义链接等部分功能仍处于锁定状态。立即升级即可解锁所有功能。",
"trial_already_used": "该邮箱地址已使用过免费试用。请升级至付费计划。",
"trial_cancels_automatically": "你的试用将于 {date} 自动取消。",
"trial_ending_add_payment_method": "添加付款方式",
"trial_ending_description": "试用结束后,你将失去在专业版中设置的所有功能:",
"trial_ending_title": "{count, plural, other {试用期仅剩 # 天}}",
- "trial_payment_method_added_description": "一切就绪!试用期结束后,您的专业版计划将自动继续。",
"trial_warning_200_description": "你已收集了 200 份回复。一旦达到 250 份,你的调查将停止接受新回复,直到 30 天试用期结束。",
"trial_warning_200_title": "你已收集了回复限额的 80%",
"trial_warning_250_description": "你已收集了 250 条回复。从现在起,你的调查问卷将不再接受新的回复,直到 30 天试用期结束。",
"trial_warning_250_title": "你已达到限额",
- "trial_warning_add_payment_method": "添加付款方式",
+ "trial_warning_add_payment_method": "解锁全部功能",
"trial_warning_remind_me_later": "稍后提醒我",
"unlimited_responses": "无限反馈",
"unlimited_workspaces": "无限工作区",
+ "unlock_all_plan_features": "解锁所有 {plan} 功能",
"upgrade": "升级",
"upgrade_checkout_pending": "正在设置你的套餐…",
"upgrade_checkout_success": "你现在使用的是 {plan} 套餐。",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "附加响应数据",
"follow_ups_modal_action_body_label": "正文",
"follow_ups_modal_action_body_placeholder": "电子邮件正文",
+ "follow_ups_modal_action_email_already_added": "此邮箱已添加",
"follow_ups_modal_action_email_content": "电子邮件 内容",
+ "follow_ups_modal_action_email_input_placeholder": "输入邮箱并按空格键",
+ "follow_ups_modal_action_email_invalid": "请输入有效的邮箱地址",
"follow_ups_modal_action_email_settings": "邮件设置",
"follow_ups_modal_action_from_description": "发送邮件的电子邮箱地址",
"follow_ups_modal_action_from_label": "从",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "受访者 完成 调查",
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_new": "新的跟进",
+ "follow_ups_workflows_alert_title": "需要更多灵活性?使用工作流自动化后续跟进及更多操作。",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未连接",
"four_points": "4 分",
"heading": "标题",
@@ -4134,6 +4179,119 @@
"value_number": "值(数量)",
"value_text": "值(文本)"
},
+ "workflows": {
+ "add_action": "添加操作",
+ "add_trigger": "添加触发器",
+ "add_trigger_description": "选择启动此工作流的方式。",
+ "all_changes_saved": "所有更改已保存",
+ "alphabetical": "按字母顺序",
+ "archive_confirm_body": "归档后将禁用此工作流程并停止运行。你可以稍后再取消归档。",
+ "archive_confirm_title": "归档工作流程?",
+ "archive_failed": "工作流归档失败。请重试。",
+ "archive_success": "工作流已归档。",
+ "archive_workflow": "归档工作流",
+ "archive_workflow_confirmation": "确定要归档“{name}”吗?你可以稍后恢复它。",
+ "archive_workflow_description": "归档后,工作流会从列表中隐藏。你可以随时恢复。",
+ "auto_layout": "自动布局",
+ "autosave_failed": "保存失败",
+ "autosave_failed_tooltip": "无法保存你的最新更改。请检查网络连接并重试。",
+ "autosave_failed_tooltip_rejected": "无法保存你的最新更改:{detail}",
+ "collapse_inspector": "收起检查器",
+ "create_failed": "创建工作流失败。请重试。",
+ "delete_failed": "删除工作流失败。请重试。",
+ "delete_success": "工作流已删除。",
+ "delete_workflow_confirmation": "这将永久删除“{name}”及其运行历史。",
+ "disable_failed": "无法禁用该工作流。",
+ "disable_success": "工作流已禁用。",
+ "duplicate_failed": "复制工作流失败。请重试。",
+ "duplicate_success": "工作流已复制。",
+ "edit_blocked_active": "请停用工作流以进行更改。",
+ "email_attach_response_data_description": "在电子邮件负载中包含触发调查的响应数据。",
+ "email_attach_response_data_label": "附加响应数据",
+ "email_body_label": "正文",
+ "email_body_placeholder": "输入你想发送的消息…",
+ "email_body_required": "添加要发送的消息。",
+ "email_from_label": "发件人",
+ "email_include_hidden_fields_label": "包含隐藏字段",
+ "email_include_variables_label": "包含变量",
+ "email_needs_survey": "请先在触发器步骤中连接一个调查问卷。收件人和消息选项将来自调查问卷的回答。",
+ "email_reply_to_label": "回复至",
+ "email_set_up_trigger": "设置触发器",
+ "email_subject_label": "主题",
+ "email_subject_placeholder": "感谢您完成调查",
+ "email_subject_required": "添加主题行。",
+ "email_to_label": "发送至",
+ "email_to_placeholder": "team@example.com",
+ "email_to_required": "选择应接收此邮件的收件人。",
+ "enable_blocked_unsaved_changes": "无法保存你的最新更改,因此工作流未启用。",
+ "enable_failed": "无法启用工作流。",
+ "enable_success": "工作流已启用。",
+ "expand_inspector": "展开检查器",
+ "if_else": "条件分支",
+ "if_else_summary": "根据条件分支工作流。",
+ "inspector_unsupported_node": "此节点类型暂无配置表单。",
+ "load_failed": "无法加载工作流。",
+ "name_required": "请输入名称。",
+ "no_results_description": "试试调整你的搜索或筛选条件。",
+ "no_results_title": "未找到工作流",
+ "no_workflows_description": "创建你的第一个工作流,让回复自动化。",
+ "no_workflows_title": "暂无工作流",
+ "node_actions": "节点操作",
+ "node_needs_email_content": "设置收件人和内容",
+ "node_needs_survey": "选择一个调查问卷以开始",
+ "pan_mode": "平移模式",
+ "pointer_mode": "指针模式",
+ "read_only": "只读",
+ "relative_date": "{date} {time}",
+ "relative_days_ago": "{count, plural, other {# 天前}} {time}",
+ "relative_today": "今天 {time}",
+ "relative_yesterday": "昨天 {time}",
+ "response_completed": "回复已完成",
+ "response_completed_description": "当有人完成调查问卷回复时运行。",
+ "save_failed": "无法保存工作流。",
+ "save_success": "工作流已保存。",
+ "saving_changes": "保存中…",
+ "search_by_workflow_name": "按工作流名称搜索",
+ "send_email": "发送邮件",
+ "send_email_description": "当此工作流运行时发送一封邮件。",
+ "send_email_summary": "向 {to} 发送邮件。",
+ "send_email_unconfigured": "配置邮件收件人。",
+ "trigger_ending_cards_label": "结束卡片",
+ "trigger_ending_cards_none": "此调查问卷尚未配置结束页面。",
+ "trigger_ending_cards_pick_survey": "选择一个调查问卷以查看其结束页面。",
+ "trigger_ending_cards_scope_all": "所有结束页面",
+ "trigger_ending_cards_scope_specific": "特定结束页面",
+ "trigger_ending_cards_select_at_least_one": "至少选择一个结束页面。如果未选择任何结束页面,则每个结束页面都会触发此工作流。",
+ "trigger_summary_all_endings": "在任何调查问卷响应时触发。",
+ "trigger_summary_ending_cards": "在 {count, plural, other {# 个结束卡片}}时触发。",
+ "trigger_survey_description": "选择完成回复后触发此工作流的调查问卷。",
+ "trigger_survey_empty": "此工作区中还没有调查问卷。",
+ "trigger_survey_label": "调查问卷",
+ "trigger_survey_placeholder": "选择调查问卷",
+ "triggers": "触发器",
+ "unarchive": "取消归档",
+ "unarchive_failed": "取消归档工作流失败。请重试。",
+ "unarchive_success": "工作流已取消归档。",
+ "upgrade_prompt_description": "通过触发器、筛选器和操作自动执行响应驱动的任务。",
+ "upgrade_prompt_title": "升级以解锁工作流功能",
+ "validation_failed": "工作流验证失败。",
+ "validation_problem_fix_label": "修复:{problem}",
+ "validation_problem_flow_invalid": "工作流步骤未连接成单个可运行的流程。",
+ "validation_problem_generic": "工作流的这部分存在配置问题。",
+ "validation_problem_name_missing": "为工作流命名。",
+ "validation_problem_step_incomplete": "填写邮件步骤的收件人、主题和正文。",
+ "validation_problem_step_not_executable": "此步骤类型暂时无法运行。请在启用工作流之前移除它。",
+ "validation_problem_trigger_ending_not_found": "所选结束页在关联的问卷中已不存在。",
+ "validation_problem_trigger_missing": "添加触发器以启动工作流。",
+ "validation_problem_trigger_not_connected": "在触发器后连接一个步骤。",
+ "validation_problem_trigger_survey_unbound": "将触发器连接到此工作区中的调查问卷。",
+ "validation_problems_count": "{count, plural, other {# 个问题}}",
+ "validation_problems_description": "修复这些问题后工作流才能运行:",
+ "validation_problems_title": "验证问题",
+ "validation_status_valid": "有效",
+ "zoom_in": "放大",
+ "zoom_out": "缩小"
+ },
"xm-templates": {
"ces": "客户努力评分",
"ces_description": "客户费力度分数",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 7544ec518c6e..4d0c98dd955e 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -98,7 +98,9 @@
"openid": "Identify you with OpenID Connect",
"profile": "View your profile",
"surveys_read": "Read surveys",
- "surveys_write": "Create, update, and delete surveys"
+ "surveys_write": "Create, update, and delete surveys",
+ "workflows_read": "Read workflows",
+ "workflows_write": "Create, update, and delete workflows"
},
"unknown_client": "Unknown client"
},
@@ -126,6 +128,8 @@
"user_successfully_created_info": "我們已檢查與 {email} 相關聯的帳戶。如果不存在,我們已為您建立一個。如果帳戶已存在,則未進行任何更改。請在下方登入以繼續。"
},
"verification-requested": {
+ "email_not_configured_description": "此 Formbricks 實例未設定電子郵件伺服器,因此無法發送驗證連結。請聯絡您的管理員。",
+ "email_not_configured_title": "電子郵件未設定",
"invalid_email_address": "無效的電子郵件地址",
"invalid_token": "無效的權杖 ☹️",
"new_email_verification_success": "如果地址有效,驗證電子郵件已發送。",
@@ -151,6 +155,7 @@
"accepted": "已接受",
"account": "帳戶",
"account_settings": "帳戶設定",
+ "act": "行動",
"action": "操作",
"actions": "操作",
"actions_description": "程式碼動作與無程式碼動作可用於在應用程式與網站上觸發截取式問卷。",
@@ -185,6 +190,7 @@
"archive": "封存",
"archived": "已封存",
"are_you_sure": "您確定嗎?",
+ "attempt": "嘗試",
"attributes": "屬性",
"authorized_apps": "Authorized Apps",
"back": "返回",
@@ -193,6 +199,7 @@
"bottom_left": "左下",
"bottom_right": "右下",
"cancel": "取消",
+ "canceled": "已取消",
"centered_modal": "置中彈窗",
"chart": "圖表",
"charts": "圖表",
@@ -265,9 +272,11 @@
"duplicate_copy_number": "(複製 {copyNumber})",
"e_commerce": "電子商務",
"edit": "編輯",
+ "editor": "編輯器",
"elements": "元素",
"email": "電子郵件",
"enable": "啟用",
+ "enabled": "已啟用",
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
"enterprise_license": "企業授權",
@@ -278,6 +287,7 @@
"error_rate_limit_description": "已達到最大請求次數。請稍後再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
+ "failed": "失敗",
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_workspaces": "載入工作區失敗",
@@ -288,6 +298,7 @@
"file_upload_service_unavailable": "檔案上傳服務無法使用",
"filter": "篩選",
"finish": "完成",
+ "finished_at": "完成時間",
"first_name": "名字",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -310,6 +321,7 @@
"imprint": "出版資訊",
"in_progress": "進行中",
"inactive_surveys": "停用中的問卷",
+ "input": "輸入",
"integration": "整合",
"integrations": "整合",
"invalid_date_with_value": "無效日期: {value}",
@@ -350,6 +362,7 @@
"move_up": "上移",
"name": "名稱",
"new_version_available": "Formbricks {version} 已推出。立即升級!",
+ "new_workflow": "新增工作流程",
"next": "下一步",
"no": "否",
"no_actions_found": "找不到動作",
@@ -388,10 +401,12 @@
"other": "其他",
"other_filters": "其他篩選條件",
"other_placeholder": "其他預設文字",
+ "output": "輸出",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
"password": "密碼",
"paused": "已暫停",
+ "pending": "待處理",
"pending_downgrade": "等待降級",
"people_manager": "員工體驗",
"person": "人員",
@@ -412,6 +427,7 @@
"question": "問題",
"question_id": "問題 ID",
"questions": "問題",
+ "queued": "佇列中",
"quota": "配額",
"quotas": "配額",
"quotas_description": "限制符合特定條件的參與者所能提交的回應數量。",
@@ -424,15 +440,20 @@
"replace": "取代",
"report_survey": "報告問卷",
"request_trial_license": "請求試用授權",
+ "required": "必填",
"reset_to_default": "重設為預設值",
"resize": "調整大小",
"response": "回應",
+ "response_completed": "回應已完成",
"response_id": "回應 ID",
"responses": "回應",
"restart": "重新開始",
"retry": "重試",
"role": "角色",
"row_n": "列 {n}",
+ "run_data": "執行資料",
+ "running": "執行中",
+ "runs": "執行",
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
@@ -468,12 +489,16 @@
"something_went_wrong": "發生錯誤",
"something_went_wrong_please_try_again": "發生錯誤。請再試一次。",
"sort_by": "排序方式",
+ "sort_by_value": "排序方式: {label}",
+ "started_at": "開始時間",
"status": "狀態",
+ "steps": "步驟",
"storage_not_configured": "檔案儲存未設定,上傳可能會失敗",
"string": "文字",
"styling": "樣式設定",
"subheader": "副標題",
"submit": "提交",
+ "succeeded": "成功",
"summary": "摘要",
"survey": "問卷",
"survey_completed": "問卷已完成。",
@@ -506,8 +531,11 @@
"trial_expired": "您的試用期已結束",
"trial_one_day_remaining": "試用期剩餘 1 天",
"trial_plan_badge": "{plan} 試用版",
+ "trigger": "觸發器",
+ "trigger_payload": "觸發資料",
"try_again": "再試一次",
"type": "類型",
+ "unarchive": "取消封存",
"undo": "復原",
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
"update": "更新",
@@ -527,6 +555,7 @@
"verified_email": "已驗證的電子郵件",
"video": "影片",
"view": "檢視",
+ "view_workflow": "檢視工作流程",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我們無法驗證您的授權,因為授權伺服器無法連線。",
"webhook": "Webhook",
@@ -536,6 +565,9 @@
"weeks": "週",
"welcome_card": "歡迎卡片",
"whats_new": "最新消息",
+ "workflow_name": "工作流程名稱",
+ "workflow_runs": "工作流程執行",
+ "workflows": "工作流程",
"workspace": "工作區",
"workspace_created_successfully": "工作區已成功建立",
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
@@ -601,7 +633,6 @@
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
"response_data": "回應資料",
"response_finished_email_subject": "{surveyName} 的回應已完成 ✅",
- "response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅",
"schedule_your_meeting": "安排你的會議",
"select_a_date": "選擇日期",
"survey_response_finished_email_congrats": "恭喜,您收到了新的問卷回應!有人剛完成您的問卷:{surveyName}",
@@ -2455,11 +2486,21 @@
"comparison_row_two_factor_auth": "雙重驗證",
"comparison_row_unify_feedback": "整合所有來源的意見回饋",
"comparison_row_unlimited_seats": "無限制席次",
+ "comparison_row_workflows": "工作流程",
"comparison_row_workspaces": "工作區",
"comparison_section_all_plans": "所有方案",
"comparison_section_basic_usage": "核心用量",
"comparison_section_pro_unlocks": "Pro 方案解鎖功能",
"comparison_section_scale_unlocks": "Scale 方案解鎖功能",
+ "confirm_hobby_downgrade_body": "你的免費 {plan} 試用將立即結束,並立刻切換至 Hobby 方案。",
+ "confirm_hobby_downgrade_description": "你隨時可以再次升級。",
+ "confirm_hobby_downgrade_title": "現在切換至 Hobby 方案?",
+ "confirm_trial_continue_body": "追蹤功能、自訂連結以及 {plan} 中的所有功能——立即解鎖。 今天收費 {chargeNow},之後每{period} {fullPrice},含稅。計費從今天開始。",
+ "confirm_trial_continue_body_fallback": "追蹤功能、自訂連結以及 {plan} 中的所有功能——立即解鎖。 每{period} {fullPrice},另加稅金。計費從今天開始。",
+ "confirm_trial_continue_description": "你隨時可以再次變更方案。",
+ "confirm_trial_continue_pay_now": "立即支付 {chargeNow}",
+ "confirm_trial_continue_pay_now_generic": "立即付款並解鎖",
+ "confirm_trial_continue_title": "現在開始使用 {plan}?",
"confirm_upgrade_body": "你即將升級至 {plan} 方案,費用為 {amount} {period}。系統會立即按比例收取本計費週期剩餘時間的費用,並在付款時計算所有適用的稅金。",
"confirm_upgrade_body_with_charge": "你即將升級至 {plan} 方案({period})。系統將立即向你收取 {chargeNow},作為本計費週期剩餘時間的費用,並在付款時計算所有適用的稅金。",
"confirm_upgrade_button": "確認升級",
@@ -2469,7 +2510,6 @@
"contact_sales_cta": "與我們聊聊",
"contact_sales_description": "了解更多關於 Formbricks 企業版的資訊,以及我們如何為您量身打造解決方案。",
"contact_sales_title": "聯絡業務",
- "continue_with_plan_after_trial": "試用結束後繼續使用專業版",
"current_plan_badge": "目前",
"current_plan_cta": "目前方案",
"custom_plan_description": "您的組織使用自訂計費設定。您仍可切換至下方的標準方案。",
@@ -2541,6 +2581,7 @@
"plan_scale_feature_responses": "每月 5,000 次回應,採用動態定價",
"plan_scale_feature_security": "雙因素驗證與垃圾訊息防護",
"plan_scale_feature_semantic_analysis": "語義分析(AI)",
+ "plan_scale_feature_workflows": "工作流程",
"plan_scale_feature_workspaces": "5 個工作區",
"plan_selection_description": "比較 Hobby、Pro 和 Scale 方案,然後直接在 Formbricks 中切換方案。",
"plan_selection_title": "選擇您的方案",
@@ -2568,21 +2609,21 @@
"switch_at_period_end": "週期結束時切換",
"switch_plan_now": "立即切換方案",
"this_includes": "包含內容",
- "trial_alert_description": "新增付款方式以繼續使用所有功能。",
+ "trial_alert_description": "試用期間部分功能(如追蹤功能和自訂連結)仍處於鎖定狀態。立即升級以解鎖所有功能。",
"trial_already_used": "此電子郵件地址已使用過免費試用。請改為升級至付費方案。",
"trial_cancels_automatically": "你的試用將於 {date} 自動取消。",
"trial_ending_add_payment_method": "新增付款方式",
"trial_ending_description": "試用期結束後,您將無法使用在 Pro 方案中設定的所有功能:",
"trial_ending_title": "{count, plural, other {試用期只剩 # 天}}",
- "trial_payment_method_added_description": "一切就緒!試用期結束後,您的 Pro 方案將自動繼續。",
"trial_warning_200_description": "你已經收集了 200 份回覆。一旦達到 250 份,你的問卷調查將停止接受新回覆,直到 30 天期限結束。",
"trial_warning_200_title": "您已收集了回覆上限的 80%",
"trial_warning_250_description": "您已收集了 250 份回覆。從現在起,在 30 天期限結束前,您的問卷將不再接受新的回覆。",
"trial_warning_250_title": "您已達到上限",
- "trial_warning_add_payment_method": "新增付款方式",
+ "trial_warning_add_payment_method": "解鎖所有功能",
"trial_warning_remind_me_later": "稍後提醒我",
"unlimited_responses": "無限回應",
"unlimited_workspaces": "無限工作區",
+ "unlock_all_plan_features": "解鎖所有 {plan} 功能",
"upgrade": "升級",
"upgrade_checkout_pending": "正在設定你的方案…",
"upgrade_checkout_success": "你現在使用的是 {plan} 方案。",
@@ -3165,7 +3206,10 @@
"follow_ups_modal_action_attach_response_data_label": "附加 response data",
"follow_ups_modal_action_body_label": "內文",
"follow_ups_modal_action_body_placeholder": "電子郵件內文",
+ "follow_ups_modal_action_email_already_added": "此電子郵件地址已新增",
"follow_ups_modal_action_email_content": "電子郵件內容",
+ "follow_ups_modal_action_email_input_placeholder": "輸入電子郵件地址並按空白鍵",
+ "follow_ups_modal_action_email_invalid": "請輸入有效的電子郵件地址",
"follow_ups_modal_action_email_settings": "電子郵件設定",
"follow_ups_modal_action_from_description": "傳送電子郵件的電子郵件地址",
"follow_ups_modal_action_from_label": "寄件者",
@@ -3193,6 +3237,7 @@
"follow_ups_modal_trigger_type_response": "回應者完成問卷",
"follow_ups_modal_updated_successfull_toast": "後續動作已更新,待你儲存問卷後一併保存。",
"follow_ups_new": "新增後續追蹤",
+ "follow_ups_workflows_alert_title": "需要更多彈性嗎?使用工作流程自動化後續追蹤及更多功能。",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未連線",
"four_points": "4 分",
"heading": "標題",
@@ -4134,6 +4179,119 @@
"value_number": "值(數量)",
"value_text": "值(文字)"
},
+ "workflows": {
+ "add_action": "新增動作",
+ "add_trigger": "新增觸發條件",
+ "add_trigger_description": "選擇啟動此工作流程的條件。",
+ "all_changes_saved": "已儲存所有變更",
+ "alphabetical": "字母順序",
+ "archive_confirm_body": "封存會停用此工作流程並停止執行。你可以稍後再解除封存。",
+ "archive_confirm_title": "封存工作流程?",
+ "archive_failed": "無法封存工作流程,請再試一次。",
+ "archive_success": "工作流程已封存。",
+ "archive_workflow": "封存工作流程",
+ "archive_workflow_confirmation": "確定要封存「{name}」嗎?你之後可以還原它。",
+ "archive_workflow_description": "封存會將工作流程從列表中隱藏,你之後可以還原它。",
+ "auto_layout": "自動排版",
+ "autosave_failed": "儲存失敗",
+ "autosave_failed_tooltip": "無法儲存你的最新變更。請檢查你的連線並再試一次。",
+ "autosave_failed_tooltip_rejected": "無法儲存你的最新變更:{detail}",
+ "collapse_inspector": "收合檢查器",
+ "create_failed": "無法建立工作流程,請再試一次。",
+ "delete_failed": "無法刪除工作流程,請再試一次。",
+ "delete_success": "工作流程已刪除。",
+ "delete_workflow_confirmation": "這將永久刪除「{name}」及其執行記錄。",
+ "disable_failed": "無法停用工作流程。",
+ "disable_success": "工作流程已停用。",
+ "duplicate_failed": "無法複製工作流程,請再試一次。",
+ "duplicate_success": "工作流程已複製。",
+ "edit_blocked_active": "請停用工作流程以進行變更。",
+ "email_attach_response_data_description": "在電子郵件內容中包含觸發的調查回應。",
+ "email_attach_response_data_label": "附加回應資料",
+ "email_body_label": "內容",
+ "email_body_placeholder": "撰寫你想傳送的訊息…",
+ "email_body_required": "新增要傳送的訊息。",
+ "email_from_label": "寄件人",
+ "email_include_hidden_fields_label": "包含隱藏欄位",
+ "email_include_variables_label": "包含變數",
+ "email_needs_survey": "請先在觸發步驟中連接問卷。收件人和訊息選項來自問卷的回答內容。",
+ "email_reply_to_label": "回覆至",
+ "email_set_up_trigger": "設定觸發條件",
+ "email_subject_label": "主旨",
+ "email_subject_placeholder": "感謝你完成問卷",
+ "email_subject_required": "新增主旨。",
+ "email_to_label": "傳送至",
+ "email_to_placeholder": "team@example.com",
+ "email_to_required": "選擇誰應該收到這封電子郵件。",
+ "enable_blocked_unsaved_changes": "無法儲存你的最新變更,因此工作流程未啟用。",
+ "enable_failed": "無法啟用工作流程。",
+ "enable_success": "工作流程已啟用。",
+ "expand_inspector": "展開檢查器",
+ "if_else": "如果 / 否則",
+ "if_else_summary": "根據條件分支工作流程。",
+ "inspector_unsupported_node": "此節點類型尚未提供設定表單。",
+ "load_failed": "無法載入工作流程。",
+ "name_required": "請輸入名稱。",
+ "no_results_description": "試著調整你的搜尋條件或篩選器。",
+ "no_results_title": "找不到工作流程",
+ "no_workflows_description": "建立你的第一個工作流程,在收到回應時自動執行動作。",
+ "no_workflows_title": "尚無工作流程",
+ "node_actions": "節點動作",
+ "node_needs_email_content": "設定收件人與內容",
+ "node_needs_survey": "選擇一份問卷以開始",
+ "pan_mode": "平移模式",
+ "pointer_mode": "指標模式",
+ "read_only": "唯讀",
+ "relative_date": "{date} {time}",
+ "relative_days_ago": "{count, plural, other {# 天前}} {time}",
+ "relative_today": "今天 {time}",
+ "relative_yesterday": "昨天 {time}",
+ "response_completed": "回應已完成",
+ "response_completed_description": "當有人完成問卷回覆時執行。",
+ "save_failed": "無法儲存工作流程。",
+ "save_success": "工作流程已儲存。",
+ "saving_changes": "儲存中…",
+ "search_by_workflow_name": "依工作流程名稱搜尋",
+ "send_email": "傳送電子郵件",
+ "send_email_description": "在此工作流程執行時發送電子郵件。",
+ "send_email_summary": "傳送電子郵件給 {to}。",
+ "send_email_unconfigured": "設定電子郵件收件人。",
+ "trigger_ending_cards_label": "結束卡片",
+ "trigger_ending_cards_none": "此問卷沒有設定任何結束畫面。",
+ "trigger_ending_cards_pick_survey": "請選擇一個問卷以查看其結束畫面。",
+ "trigger_ending_cards_scope_all": "所有結束畫面",
+ "trigger_ending_cards_scope_specific": "特定結束畫面",
+ "trigger_ending_cards_select_at_least_one": "請至少選擇一個結束畫面。若未選擇任何結束畫面,則每個結束畫面都會觸發此工作流程。",
+ "trigger_summary_all_endings": "在任何問卷回應時觸發。",
+ "trigger_summary_ending_cards": "在 {count, plural, other {# 個結束卡片}}時觸發。",
+ "trigger_survey_description": "請選擇要用來觸發此工作流程的問卷(當有完成的回覆時)。",
+ "trigger_survey_empty": "此工作區尚無問卷。",
+ "trigger_survey_label": "問卷",
+ "trigger_survey_placeholder": "選擇問卷",
+ "triggers": "觸發條件",
+ "unarchive": "取消封存",
+ "unarchive_failed": "無法取消封存工作流程,請再試一次。",
+ "unarchive_success": "工作流程已取消封存。",
+ "upgrade_prompt_description": "透過觸發條件、篩選器和動作,自動化回應驅動的任務。",
+ "upgrade_prompt_title": "升級以解鎖工作流程",
+ "validation_failed": "工作流程驗證失敗。",
+ "validation_problem_fix_label": "修正:{problem}",
+ "validation_problem_flow_invalid": "工作流程步驟未連接成單一可執行流程。",
+ "validation_problem_generic": "工作流程的這個部分有設定問題。",
+ "validation_problem_name_missing": "請為工作流程命名。",
+ "validation_problem_step_incomplete": "請填寫電子郵件步驟的收件人、主旨和內容。",
+ "validation_problem_step_not_executable": "此步驟類型尚無法執行。請在啟用工作流程前將其移除。",
+ "validation_problem_trigger_ending_not_found": "所選的結尾在已連接的問卷中已不存在。",
+ "validation_problem_trigger_missing": "請新增觸發條件以啟動工作流程。",
+ "validation_problem_trigger_not_connected": "請在觸發條件後連接步驟。",
+ "validation_problem_trigger_survey_unbound": "請將觸發條件連接至此工作區中的問卷。",
+ "validation_problems_count": "{count, plural, other {# 個問題}}",
+ "validation_problems_description": "請修正這些問題後才能執行工作流程:",
+ "validation_problems_title": "驗證問題",
+ "validation_status_valid": "有效",
+ "zoom_in": "放大",
+ "zoom_out": "縮小"
+ },
"xm-templates": {
"ces": "CES",
"ces_description": "客戶費力度(CES)",
diff --git a/apps/web/modules/auth/components/back-to-login-button.tsx b/apps/web/modules/auth/components/back-to-login-button.tsx
index bad0066810b5..09b76074a642 100644
--- a/apps/web/modules/auth/components/back-to-login-button.tsx
+++ b/apps/web/modules/auth/components/back-to-login-button.tsx
@@ -2,11 +2,19 @@ import Link from "next/link";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
-export const BackToLoginButton = async () => {
+/**
+ * `callbackUrl` originates in a search param, so callers pass it through `resolveAuthCallbackUrl`
+ * (origin-allowlisted against WEBAPP_URL) before it reaches this href. `/auth/login` validates it again
+ * with the same helper before redirecting, so that is the layer that actually closes an open redirect —
+ * validating here keeps the rendered link from advertising a target login would silently drop.
+ * Omitted by every caller that has nothing to return the user to.
+ */
+export const BackToLoginButton = async ({ callbackUrl }: Readonly<{ callbackUrl?: string | null }>) => {
const t = await getTranslate();
+ const href = callbackUrl ? `/auth/login?callbackUrl=${encodeURIComponent(callbackUrl)}` : "/auth/login";
return (
diff --git a/apps/web/modules/auth/lib/auth-email-verification.integration.test.ts b/apps/web/modules/auth/lib/auth-email-verification.integration.test.ts
index 9ffdc6bc8490..282bbfffda04 100644
--- a/apps/web/modules/auth/lib/auth-email-verification.integration.test.ts
+++ b/apps/web/modules/auth/lib/auth-email-verification.integration.test.ts
@@ -25,9 +25,6 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
});
// Spy capturePostHogEvent (the other afterEmailVerification side effect) without hitting PostHog.
-vi.mock("@/lib/posthog", () => ({
- capturePostHogEvent: vi.fn(),
-}));
/**
* Integration coverage for email verification + password reset (ENG-1054) against a real Postgres.
diff --git a/apps/web/modules/auth/lib/auth.ts b/apps/web/modules/auth/lib/auth.ts
index fd4db268363a..854d504c35ae 100644
--- a/apps/web/modules/auth/lib/auth.ts
+++ b/apps/web/modules/auth/lib/auth.ts
@@ -120,12 +120,20 @@ export const auth = betterAuth({
// app-wide static import chain (only loaded when a reset is actually sent).
sendResetPassword: async ({ user, url }) => {
const { sendPasswordResetLinkEmail } = await import("@/modules/email");
- await sendPasswordResetLinkEmail({
+ // Same falsy-return trap as sendVerificationEmail below (ENG-2091): `sendEmail` returns false
+ // without throwing when SMTP isn't configured, and Better Auth ignores the return value — so a
+ // reset that never went out would leave no trace at all. Throwing makes it attributable; the
+ // caller (forgot-password/actions.ts) already catches and still answers generically, so the
+ // enumeration-safe response is unchanged.
+ const sent = await sendPasswordResetLinkEmail({
email: user.email,
locale: await getUserLocale(user.id),
verifyLink: url,
linkValidityInMinutes: PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
});
+ if (!sent) {
+ throw new Error("Password reset email was not sent (mailer reported no delivery)");
+ }
},
// After a successful reset, send the security notification (parity with the retired
// completePasswordReset) and audit it. Better Auth already revoked sessions
@@ -157,13 +165,37 @@ export const auth = betterAuth({
// before ownership is proven; also enumeration-safe).
autoSignInAfterVerification: true,
expiresIn: 60 * 60, // 1 hour
+ // ENG-2091: a failed send must never present as a successful one. Two ways it can hide:
+ // 1. a THROW — on the sign-up path Better Auth calls this through `runInBackgroundOrAwait`, whose
+ // catch only logs, so sign-up still resolves 200. (The resend endpoint awaits it directly and
+ // does propagate, which is why rethrowing below is what makes THAT path truthful.)
+ // 2. a FALSY RETURN — `sendEmail` returns false without throwing when SMTP isn't configured, and
+ // Better Auth ignores the return value entirely. Silent on every path.
+ // So: treat both as failures and make them attributable — log with the user id, and rethrow so the
+ // paths that DO propagate (resend, sign-in resend) fail loudly instead of claiming success.
+ // Deliberately NOT surfaced in the sign-up response: that response must not vary by what happened to
+ // one address, or it becomes an account-existence oracle (ENG-2099). The verification-requested
+ // screen derives its "nothing was sent" state from IS_SMTP_CONFIGURED instead.
sendVerificationEmail: async ({ user, url }) => {
const { sendVerificationLinkEmail } = await import("@/modules/email");
- await sendVerificationLinkEmail({
- email: user.email,
- locale: await getUserLocale(user.id),
- verifyLink: url,
- });
+ try {
+ const sent = await sendVerificationLinkEmail({
+ email: user.email,
+ locale: await getUserLocale(user.id),
+ verifyLink: url,
+ });
+ if (!sent) {
+ throw new Error("Verification email was not sent (mailer reported no delivery)");
+ }
+ } catch (error) {
+ // Domain only — never the address, the token, or the verify URL (all three are sensitive and
+ // the URL grants account access).
+ logger.error(
+ { error, userId: user.id, emailDomain: user.email.split("@")[1] },
+ "Failed to send verification email"
+ );
+ throw error;
+ }
},
// Re-home the "token" provider's Brevo-on-first-verification side effect (better-auth-email-verification.ts).
afterEmailVerification: createBrevoCustomerAfterEmailVerification,
diff --git a/apps/web/modules/auth/lib/better-auth-observability.test.ts b/apps/web/modules/auth/lib/better-auth-observability.test.ts
index 6ae182d45d26..0b106f29ea40 100644
--- a/apps/web/modules/auth/lib/better-auth-observability.test.ts
+++ b/apps/web/modules/auth/lib/better-auth-observability.test.ts
@@ -10,6 +10,7 @@ import {
auditPasswordReset,
betterAuthLogger,
getSignInAuthMethod,
+ redactEmailsInLogMessage,
signInAuditDatabaseHook,
} from "./better-auth-observability";
import { finalizeSuccessfulSignIn } from "./sign-in-tracking";
@@ -49,6 +50,37 @@ vi.mock("./utils", () => ({
shouldLogAuthFailure: vi.fn(),
}));
+describe("redactEmailsInLogMessage (ENG-2091)", () => {
+ test("strips the local part from Better Auth's duplicate-sign-up message, keeping the domain", () => {
+ // The real message from better-auth's sign-up.mjs, logged at `info` on every duplicate sign-up.
+ expect(
+ redactEmailsInLogMessage("Sign-up attempt for existing email: alice.smith+tag@corporate.example.com")
+ ).toBe("Sign-up attempt for existing email: [redacted]@corporate.example.com");
+ });
+
+ test("redacts every address in a message, not just the first", () => {
+ expect(redactEmailsInLogMessage("linking a@x.com to b@y.co.uk")).toBe(
+ "linking [redacted]@x.com to [redacted]@y.co.uk"
+ );
+ });
+
+ // The pattern is bounded and its classes exclude the delimiter they end on, so a long run with no
+ // `@` cannot cause super-linear backtracking. Kept as a test so a future "simplification" back to an
+ // enumerated local-part class (which would include `.` and reintroduce the ambiguity) shows up here.
+ test("stays fast on a long address-free run", () => {
+ const started = performance.now();
+ expect(redactEmailsInLogMessage(`${"a.b!c#d$e%f&g'".repeat(4000)}!`)).toContain("a.b!c#d");
+ expect(performance.now() - started).toBeLessThan(1000);
+ });
+
+ test("leaves messages without an address untouched and passes non-strings through", () => {
+ expect(redactEmailsInLogMessage("Failed to run background task:")).toBe("Failed to run background task:");
+ const err = new Error("boom");
+ expect(redactEmailsInLogMessage(err)).toBe(err);
+ expect(redactEmailsInLogMessage(undefined)).toBeUndefined();
+ });
+});
+
describe("getSignInAuthMethod (signedIn audit allow-list)", () => {
test.each([
["/sign-in/email", "password"],
diff --git a/apps/web/modules/auth/lib/better-auth-observability.ts b/apps/web/modules/auth/lib/better-auth-observability.ts
index c05395d78b2c..4b12e3b3bcba 100644
--- a/apps/web/modules/auth/lib/better-auth-observability.ts
+++ b/apps/web/modules/auth/lib/better-auth-observability.ts
@@ -101,6 +101,29 @@ export const signInAuditDatabaseHook: NonNullable<
},
};
+/**
+ * Better Auth embeds user email addresses in some log messages — `sign-up.mjs` logs
+ * `Sign-up attempt for existing email: ` on every duplicate sign-up, at `info`. Today
+ * `level: "warn"` suppresses that one, so nothing leaks; but the level is exactly what someone would
+ * raise while debugging a sign-up problem, and doing so would start writing customer addresses into
+ * our logs. Redacting here rather than relying on the level means raising it stays safe. The domain is
+ * kept — it is the part that carries diagnostic value. (ENG-2091)
+ *
+ * Only the logger needs this: since ENG-2037 (below) Sentry receives a real `Error` object or nothing,
+ * never a string built from `message`.
+ */
+/**
+ * Local part is a negated class that EXCLUDES `@`, so the run can only end where the `@` actually is —
+ * the engine has no ambiguous split to backtrack through. Domain labels likewise exclude `.`. Both are
+ * length-bounded (RFC 5321 limits: 64 for the local part, 63 per label), which caps the work per start
+ * position regardless of input. An enumerated local-part class that included `.` would be
+ * super-linear on a long run with no `@` in it.
+ */
+const EMAIL_IN_MESSAGE = /[^\s@]{1,64}@([\w-]{1,63}(?:\.[\w-]{1,63}){1,8})/g;
+
+export const redactEmailsInLogMessage = (message: unknown): unknown =>
+ typeof message === "string" ? message.replace(EMAIL_IN_MESSAGE, "[redacted]@$1") : message;
+
/**
* Route Better Auth's logger to @formbricks/logger and capture GENUINE internal faults to Sentry in
* production — replaces auth.ts's placeholder logger (and the route's Sentry.captureException on auth
@@ -121,12 +144,15 @@ export const signInAuditDatabaseHook: NonNullable<
* logger, so handled rejections remain visible in logs — they just don't page via Sentry.
*/
export const betterAuthLogger: NonNullable = {
+ // Kept at "warn" so Better Auth's own info/debug chatter stays out of production logs. Raising it is
+ // now safe from a PII standpoint — see redactEmailsInLogMessage above.
level: "warn",
disableColors: true,
log: (level, message, ...args) => {
const contextLogger = logger.withContext({ source: "better-auth" });
+ const safeMessage = redactEmailsInLogMessage(message);
if (level === "error") {
- contextLogger.error(message);
+ contextLogger.error(safeMessage);
if (SENTRY_DSN && IS_PRODUCTION) {
// BA usually passes the Error as a trailing arg, but a couple of sites pass it as `message`.
const cause = [...args, message].find((arg): arg is Error => arg instanceof Error);
@@ -137,9 +163,9 @@ export const betterAuthLogger: NonNullable = {
}
}
} else if (level === "warn") {
- contextLogger.warn(message);
+ contextLogger.warn(safeMessage);
} else {
- contextLogger.info(message);
+ contextLogger.info(safeMessage);
}
},
};
diff --git a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts
index 710c04d5e2e8..e5d2454b4ccc 100644
--- a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts
+++ b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts
@@ -137,7 +137,13 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape
expect(response.status).toBe(200);
expect(body.scope?.split(" ")).toEqual(
- expect.arrayContaining(["surveys:read", "surveys:write", "offline_access"])
+ expect.arrayContaining([
+ "surveys:read",
+ "surveys:write",
+ "workflows:read",
+ "workflows:write",
+ "offline_access",
+ ])
);
});
diff --git a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts
index 40a678459e94..f2bedb351186 100644
--- a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts
+++ b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts
@@ -20,19 +20,19 @@ export const getMcpOauthProviderOptions = (): TOauthProviderOptions => ({
validAudiences: [getMcpResourceUrl()],
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
- // Register MCP clients with read + write by default so the consent screen offers write and the
- // write tools are reachable (clients derive their DCR/authorize scopes from what we advertise, and
- // the plugin validates authorize against the client's registered scopes). Granting the write scope
- // is safe: actual write access is still enforced downstream by the user's workspace permissions.
- // Derived from the shared list rather than repeated: a scope added there must reach default client
- // registration too, or clients would be told about a scope they can't register for.
+ // Register MCP clients with the full advertised scope set by default so the consent screen offers
+ // write and the write tools are reachable (clients derive their DCR/authorize scopes from what we
+ // advertise, and the plugin validates authorize against the client's registered scopes). Granting
+ // write is safe: actual write access is still enforced downstream by the user's workspace
+ // permissions. Spread the single source of truth so the defaults can't drift from MCP_OAUTH_SCOPES.
clientRegistrationDefaultScopes: [...MCP_OAUTH_SCOPES],
accessTokenExpiresIn: 15 * 60,
refreshTokenExpiresIn: 30 * 24 * 60 * 60,
- scopeExpirations: {
- "surveys:write": "15m",
- "feedbackRecords:write": "15m",
- },
+ // Every write scope gets the 15-minute step-up expiry, derived from the scope list so a new
+ // `:write` scope inherits it automatically (no separate hand-edit to keep in sync).
+ scopeExpirations: Object.fromEntries(
+ MCP_OAUTH_SCOPES.filter((scope) => scope.endsWith(":write")).map((scope) => [scope, "15m"])
+ ),
// Store opaque access-token and refresh-token lookup values as hashes. JWT access tokens are
// stateless and bounded by the short 15-minute lifetime above.
storeTokens: "hashed",
diff --git a/apps/web/modules/auth/lib/oauth-client-metadata.test.ts b/apps/web/modules/auth/lib/oauth-client-metadata.test.ts
index 7b0e788d2d69..68cc0a9d54a7 100644
--- a/apps/web/modules/auth/lib/oauth-client-metadata.test.ts
+++ b/apps/web/modules/auth/lib/oauth-client-metadata.test.ts
@@ -32,6 +32,8 @@ describe("OAuth client metadata helpers", () => {
expect(getOAuthScopeLabel("offline_access", t)).toBe("translated:auth.oauth.scopes.offline_access");
expect(getOAuthScopeLabel("surveys:read", t)).toBe("translated:auth.oauth.scopes.surveys_read");
expect(getOAuthScopeLabel("surveys:write", t)).toBe("translated:auth.oauth.scopes.surveys_write");
+ expect(getOAuthScopeLabel("workflows:read", t)).toBe("translated:auth.oauth.scopes.workflows_read");
+ expect(getOAuthScopeLabel("workflows:write", t)).toBe("translated:auth.oauth.scopes.workflows_write");
expect(getOAuthScopeLabel("feedbackRecords:read", t)).toBe(
"translated:auth.oauth.scopes.feedback_records_read"
);
diff --git a/apps/web/modules/auth/lib/oauth-client-metadata.ts b/apps/web/modules/auth/lib/oauth-client-metadata.ts
index f33b7815fd3b..2aac8a372458 100644
--- a/apps/web/modules/auth/lib/oauth-client-metadata.ts
+++ b/apps/web/modules/auth/lib/oauth-client-metadata.ts
@@ -44,6 +44,10 @@ export const getOAuthScopeLabel = (scope: string, t: (key: string) => string): s
return t("auth.oauth.scopes.surveys_read");
case "surveys:write":
return t("auth.oauth.scopes.surveys_write");
+ case "workflows:read":
+ return t("auth.oauth.scopes.workflows_read");
+ case "workflows:write":
+ return t("auth.oauth.scopes.workflows_write");
case "feedbackRecords:read":
return t("auth.oauth.scopes.feedback_records_read");
case "feedbackRecords:write":
diff --git a/apps/web/modules/auth/lib/oauth-urls.ts b/apps/web/modules/auth/lib/oauth-urls.ts
index d35d11ef1f7e..27814c613d7f 100644
--- a/apps/web/modules/auth/lib/oauth-urls.ts
+++ b/apps/web/modules/auth/lib/oauth-urls.ts
@@ -47,6 +47,8 @@ export const MCP_OAUTH_SCOPES = [
"offline_access",
"surveys:read",
"surveys:write",
+ "workflows:read",
+ "workflows:write",
"feedbackRecords:read",
"feedbackRecords:write",
] as const;
@@ -54,6 +56,8 @@ export const MCP_OAUTH_SCOPES = [
export const MCP_RESOURCE_SCOPES = [
"surveys:read",
"surveys:write",
+ "workflows:read",
+ "workflows:write",
"feedbackRecords:read",
"feedbackRecords:write",
] as const;
diff --git a/apps/web/modules/auth/lib/verification-links.test.ts b/apps/web/modules/auth/lib/verification-links.test.ts
index e7bdfd20fc61..08ea49f2830a 100644
--- a/apps/web/modules/auth/lib/verification-links.test.ts
+++ b/apps/web/modules/auth/lib/verification-links.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vitest";
-import { buildVerificationLinks, buildVerificationRequestedPath } from "./verification-links";
+import {
+ buildSignupWithoutVerificationSuccessPath,
+ buildVerificationLinks,
+ buildVerificationRequestedPath,
+} from "./verification-links";
const WEBAPP_URL = "http://localhost:3000";
@@ -33,6 +37,44 @@ describe("verification link helpers", () => {
);
});
+ // ENG-2099: nothing about what happened to one address may ride along in this path. The
+ // verification-requested page decides its "we couldn't send it" copy from IS_SMTP_CONFIGURED, which is
+ // the same for every visitor — a per-sign-up flag here would have made the URL an account-existence
+ // signal, since a send is only ever attempted for an address that was actually created.
+ test("carries nothing beyond the token, callback URL, and purpose", () => {
+ const path = buildVerificationRequestedPath({
+ token: "abc123",
+ callbackUrl: "http://localhost:3000/invite?token=invite-token",
+ purpose: "sso_recovery",
+ });
+
+ expect([...new URL(path, WEBAPP_URL).searchParams.keys()].toSorted()).toEqual([
+ "callbackUrl",
+ "purpose",
+ "token",
+ ]);
+ });
+
+ // ENG-2091: this is the EMAIL_VERIFICATION_DISABLED=1 landing page — the self-hosted default. It has
+ // to carry the invite callback for the same reason the verification path does, or the log-in button on
+ // it drops an invited visitor at the app root with the invite unreachable.
+ test("builds a no-verification success path that preserves the callback URL", () => {
+ expect(
+ buildSignupWithoutVerificationSuccessPath({
+ token: "abc123",
+ callbackUrl: "http://localhost:3000/invite?token=invite-token",
+ })
+ ).toBe(
+ "/auth/signup-without-verification-success?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Finvite%3Ftoken%3Dinvite-token"
+ );
+ });
+
+ test("omits the callback URL from the no-verification success path when there is none", () => {
+ expect(buildSignupWithoutVerificationSuccessPath({ token: "abc123" })).toBe(
+ "/auth/signup-without-verification-success?token=abc123"
+ );
+ });
+
test("builds absolute verification links that preserve a valid callback URL", () => {
expect(
buildVerificationLinks({
diff --git a/apps/web/modules/auth/lib/verification-links.ts b/apps/web/modules/auth/lib/verification-links.ts
index 55009a06b4c9..5d01e4b80b89 100644
--- a/apps/web/modules/auth/lib/verification-links.ts
+++ b/apps/web/modules/auth/lib/verification-links.ts
@@ -28,6 +28,36 @@ export const buildVerificationRequestedPath = ({
return `${verificationRequestedUrl.pathname}${verificationRequestedUrl.search}`;
};
+/**
+ * Where sign-up lands when EMAIL_VERIFICATION_DISABLED=1 — the DEFAULT for self-hosted (.env.example
+ * and docker-compose both ship it) and what CI runs.
+ *
+ * It carries `callbackUrl` for the same reason the verification-requested path does: an invited visitor
+ * whose address already has an account gets no email and nothing was created for them, so the log-in
+ * link on that screen is their only way back to the invite. Without the callback it drops them at the
+ * app root and the invite has to be reopened from the original mail (ENG-2091, raised by @Dhruwang and
+ * @BhagyaAmarasinghe in review).
+ *
+ * Present for every invited visitor, never conditional on whether the account exists — that would make
+ * the URL an account-existence signal (ENG-2099).
+ */
+export const buildSignupWithoutVerificationSuccessPath = ({
+ token,
+ callbackUrl,
+}: {
+ token: string;
+ callbackUrl?: string | null;
+}): string => {
+ const successUrl = new URL("/auth/signup-without-verification-success", RELATIVE_URL_BASE);
+ successUrl.searchParams.set("token", token);
+
+ if (callbackUrl) {
+ successUrl.searchParams.set("callbackUrl", callbackUrl);
+ }
+
+ return `${successUrl.pathname}${successUrl.search}`;
+};
+
export const buildVerificationLinks = ({
token,
webAppUrl,
diff --git a/apps/web/modules/auth/login/components/login-form.tsx b/apps/web/modules/auth/login/components/login-form.tsx
index 2c6965704a96..1d6bb40366bc 100644
--- a/apps/web/modules/auth/login/components/login-form.tsx
+++ b/apps/web/modules/auth/login/components/login-form.tsx
@@ -72,7 +72,7 @@ export const LoginForm = ({
inviteToken,
resolvedCallbackPath,
resolvedCallbackUrl,
-}: LoginFormProps) => {
+}: Readonly) => {
const router = useRouter();
const searchParams = useSearchParams();
const emailRef = useRef(null);
diff --git a/apps/web/modules/auth/login/page.tsx b/apps/web/modules/auth/login/page.tsx
index 65578eabcb04..f1696baf756c 100644
--- a/apps/web/modules/auth/login/page.tsx
+++ b/apps/web/modules/auth/login/page.tsx
@@ -32,9 +32,9 @@ export const metadata: Metadata = {
export const LoginPage = async ({
searchParams: searchParamsProps,
-}: {
+}: Readonly<{
searchParams: Promise>;
-}) => {
+}>) => {
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled, searchParams] = await Promise.all([
getIsMultiOrgEnabled(),
getIsSsoEnabled(),
diff --git a/apps/web/modules/auth/signup-without-verification-success/page.tsx b/apps/web/modules/auth/signup-without-verification-success/page.tsx
index a4ef3d540e98..7abbbf93ae71 100644
--- a/apps/web/modules/auth/signup-without-verification-success/page.tsx
+++ b/apps/web/modules/auth/signup-without-verification-success/page.tsx
@@ -1,16 +1,26 @@
import { logger } from "@formbricks/logger";
+import { WEBAPP_URL } from "@/lib/constants";
import { getEmailFromEmailToken } from "@/lib/jwt";
import { getTranslate } from "@/lingodotdev/server";
import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
+import { resolveAuthCallbackUrl } from "@/modules/auth/lib/callback-url";
export const SignupWithoutVerificationSuccessPage = async ({
searchParams,
}: Readonly<{
- searchParams: Promise<{ token?: string | string[] }>;
+ searchParams: Promise<{ token?: string | string[]; callbackUrl?: string | string[] }>;
}>) => {
const t = await getTranslate();
- const { token } = await searchParams;
+ const { token, callbackUrl } = await searchParams;
+ // For an invite sign-up this is `/invite?token=…`, so the log-in button below returns the visitor to
+ // the invite instead of the app root. It matters most for an invited address that already has an
+ // account: nothing was created and no email is coming, so that button is their only way forward
+ // (ENG-2091). Validated against WEBAPP_URL — it comes from a search param.
+ const resolvedCallbackUrl = resolveAuthCallbackUrl({
+ searchParamCallbackUrl: callbackUrl,
+ webAppUrl: WEBAPP_URL,
+ });
let email: string;
try {
@@ -25,7 +35,7 @@ export const SignupWithoutVerificationSuccessPage = async ({
-
+
);
};
diff --git a/apps/web/modules/auth/signup/actions.test.ts b/apps/web/modules/auth/signup/actions.test.ts
index 9232eef8f2d6..498b69a8088c 100644
--- a/apps/web/modules/auth/signup/actions.test.ts
+++ b/apps/web/modules/auth/signup/actions.test.ts
@@ -7,10 +7,14 @@ import {
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
+import { capturePostHogEvent } from "@/lib/posthog";
import { getUserByEmail } from "@/lib/user/service";
+import { AuditLoggingCtx } from "@/lib/utils/action-client/types/context";
import { auth } from "@/modules/auth/lib/auth";
+import { updateUser } from "@/modules/auth/lib/user";
import { getInvite, resolveInviteMatch } from "@/modules/auth/signup/lib/invite";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
+import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
import { createUserAction } from "./actions";
@@ -107,7 +111,9 @@ describe("createUserAction — signup verification email callbackURL", () => {
// so an uninvited sign-up arrives with an empty string rather than undefined.
const baseInput = { name: "Ada", email: "Ada@Example.com", password: "Password123!", inviteToken: "" };
- const newCtx = () => ({ auditLoggingCtx: { organizationId: "", userId: "" } });
+ const newCtx = (): { auditLoggingCtx: AuditLoggingCtx } => ({
+ auditLoggingCtx: { organizationId: "", userId: "", ipAddress: UNKNOWN_DATA },
+ });
beforeEach(() => {
vi.resetAllMocks();
@@ -118,6 +124,9 @@ describe("createUserAction — signup verification email callbackURL", () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(getUserByEmail).mockResolvedValue(createdUser as never);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
+ // Real Better Auth resolves with the user it wrote; the action compares that id against the
+ // persisted row to tell a creation from a duplicate (ENG-2091). Matching ids => "created".
+ vi.mocked(auth.api.signUpEmail).mockResolvedValue({ user: createdUser } as never);
});
afterEach(() => {
@@ -132,6 +141,18 @@ describe("createUserAction — signup verification email callbackURL", () => {
});
});
+ // The other half of the suppression guard below: a REAL creation must still be audited. Without this,
+ // a bug that set the flag unconditionally would silence every `created` event and no test would notice.
+ test("audits a real creation — the suppression flag is not set on the created path", async () => {
+ const ctx = newCtx();
+
+ const result = await createUserAction({ ctx, parsedInput: baseInput } as never);
+
+ expect(result).toEqual({ success: true });
+ expect(ctx.auditLoggingCtx.suppressEvent).toBeUndefined();
+ expect(ctx.auditLoggingCtx.userId).toBe(createdUser.id);
+ });
+
test("does not point the verification callback at /invite for invite signups (ENG-1527)", async () => {
vi.mocked(resolveInviteMatch).mockResolvedValue("valid");
vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "invite-1", email: "ada@example.com" } as never);
@@ -201,9 +222,36 @@ describe("createUserAction — signup verification email callbackURL", () => {
).rejects.toThrow(INVITE_TOKEN_INVALID_ERROR_CODE);
expect(createMembership).not.toHaveBeenCalled();
+ // Rejected before the account is written, so a bad token leaves no orphaned user behind.
+ expect(auth.api.signUpEmail).not.toHaveBeenCalled();
}
);
+ // ENG-2091: with requireEmailVerification + autoSignIn:false, Better Auth does NOT throw on a
+ // duplicate — it returns 200 with a SYNTHETIC user (generated id, nothing written). This suite used
+ // to mock a rejection, so it asserted a branch that cannot execute in production and stayed green
+ // while the real path ran every side effect against the pre-existing account. The real contract is
+ // pinned against the live framework in signup-duplicate-email.integration.test.ts.
+ test("treats a synthetic-user response as already-existed, without post-creation side effects", async () => {
+ vi.mocked(auth.api.signUpEmail).mockResolvedValue({
+ user: { ...createdUser, id: "synthetic-generated-id" },
+ } as never);
+ vi.mocked(getUserByEmail).mockResolvedValue(createdUser as never);
+
+ const ctx = newCtx();
+ const result = await createUserAction({ ctx, parsedInput: baseInput } as never);
+
+ expect(result).toEqual({ success: true }); // same response as a real signup
+ expect(subscribeUserToMailingList).not.toHaveBeenCalled();
+ expect(updateUser).not.toHaveBeenCalled(); // no locale write on someone else's account
+ expect(capturePostHogEvent).not.toHaveBeenCalled();
+ // No `created` audit attribution for an account that was not created (S1) — and no `created` event
+ // at all: withAuditLogging cannot tell this branch apart from a real creation, so the handler has to
+ // say so explicitly or a false creation record is written.
+ expect(ctx.auditLoggingCtx.userId).toBe("");
+ expect(ctx.auditLoggingCtx.suppressEvent).toBe(true);
+ });
+
// Regression: signup/page.tsx requires a valid invite once public sign-up is closed, but the action
// itself enforced nothing — so anyone could POST it and create an account on a closed instance.
describe("closed instance policy", () => {
@@ -253,7 +301,9 @@ describe("createUserAction — signup verification email callbackURL", () => {
});
});
- test("treats a duplicate email as already-existed without post-creation side effects", async () => {
+ // The catch branch is still live: flipping EMAIL_VERIFICATION_DISABLED / autoSignIn makes Better Auth
+ // throw USER_ALREADY_EXISTS instead of answering synthetically, so both signals must classify.
+ test("treats a thrown duplicate as already-existed too", async () => {
vi.mocked(auth.api.signUpEmail).mockRejectedValue(new Error("user already exists"));
vi.mocked(getUserByEmail).mockResolvedValue(createdUser as never);
@@ -279,7 +329,9 @@ describe("createUserAction — personal email domain block (Cloud)", () => {
password: "Password123!",
inviteToken: "",
};
- const newCtx = () => ({ auditLoggingCtx: { organizationId: "", userId: "" } });
+ const newCtx = (): { auditLoggingCtx: AuditLoggingCtx } => ({
+ auditLoggingCtx: { organizationId: "", userId: "", ipAddress: UNKNOWN_DATA },
+ });
beforeEach(() => {
vi.resetAllMocks();
@@ -290,6 +342,9 @@ describe("createUserAction — personal email domain block (Cloud)", () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(getUserByEmail).mockResolvedValue(createdUser as never);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
+ // Real Better Auth resolves with the user it wrote; the action compares that id against the
+ // persisted row to tell a creation from a duplicate (ENG-2091). Matching ids => "created".
+ vi.mocked(auth.api.signUpEmail).mockResolvedValue({ user: createdUser } as never);
});
afterEach(() => {
diff --git a/apps/web/modules/auth/signup/actions.ts b/apps/web/modules/auth/signup/actions.ts
index ca3681d03b29..e9ab46e49800 100644
--- a/apps/web/modules/auth/signup/actions.ts
+++ b/apps/web/modules/auth/signup/actions.ts
@@ -98,14 +98,36 @@ async function verifyTurnstileIfConfigured(turnstileToken: string | undefined):
}
}
+/**
+ * Whether THIS request created the account, as a discriminated union rather than a boolean flag.
+ *
+ * Everything downstream of sign-up — invite acceptance, organization creation, the mailing-list
+ * subscription, the analytics identify, the `created` audit event — may only run for `"created"`.
+ * `createUserAction` is unauthenticated, so running any of it against an account the caller has not
+ * proven they own is an authorization defect (ENG-2091). Encoding that in the type keeps the guard
+ * from being dropped by a later refactor: `handlePostUserCreation` accepts only the `"created"`
+ * variant, so misuse is a compile error rather than a silent privilege hole.
+ */
+type TSignUpOutcome =
+ | { status: "created"; user: TCreatedUser }
+ // Carries no user on purpose. Nothing downstream may act on a pre-existing account, so not handing
+ // one out is the cheapest way to keep it that way — there is no object to accidentally thread into a
+ // side effect later.
+ | { status: "already_existed" };
+
async function signUpUserSafely(
email: string,
name: string,
password: string,
userLocale: z.infer | undefined
-): Promise<{ user: TCreatedUser | undefined; userAlreadyExisted: boolean }> {
+): Promise {
const normalizedEmail = email.toLowerCase();
+ // Assigned on every path that does not throw; `undefined` is only in the type because TS cannot see
+ // that across the try/catch. If it ever were undefined — Better Auth changing its response shape —
+ // the id comparison below fails and the request is treated as already-existed, so no side effect
+ // runs against an account we cannot attribute. Degrading that way is the safe direction.
+ let signedUpUserId: string | undefined;
try {
// Better Auth-native signup: creates the User + a bcrypt credential Account (via the password hook
// in auth.ts) and, when verification is enabled, sends Better Auth's verification email (sendOnSignUp;
@@ -114,7 +136,10 @@ async function signUpUserSafely(
// verification link is clicked /invite would render "Invite Not Found" (ENG-1527) — the verified,
// already-provisioned user lands on the app home instead. Replaces the manual hash + createUser + the
// legacy verification-token email.
- await auth.api.signUpEmail({ body: { email: normalizedEmail, password, name } });
+ const signUpResult = await auth.api.signUpEmail({
+ body: { email: normalizedEmail, password, name },
+ });
+ signedUpUserId = signUpResult.user.id;
} catch (error) {
// A breached password is rejected before any user is created — surface it as an expected error
// with a stable code the sign-up form maps to a localized message (not an enumeration signal).
@@ -122,26 +147,41 @@ async function signUpUserSafely(
throw new InvalidInputError(PASSWORD_COMPROMISED_ERROR_CODE);
}
// Enumeration-safe: a duplicate email resolves to "already existed", not a surfaced error.
- const existing = await getUserByEmail(normalizedEmail);
- if (existing) {
- return { user: existing, userAlreadyExisted: true };
+ // Reachable only when Better Auth is configured to THROW on a duplicate — see the id check below.
+ if (await getUserByEmail(normalizedEmail)) {
+ return { status: "already_existed" };
}
throw error;
}
- let user = await getUserByEmail(normalizedEmail);
+ const user = await getUserByEmail(normalizedEmail);
if (!user) {
// signUpEmail succeeded but the row can't be loaded — an invariant violation. Fail loud rather
// than returning { success: true } with no user, which would skip org creation / invite acceptance.
throw new UnknownError("Failed to load user after signup");
}
- // signUpEmail can't carry the chosen locale (not a Better Auth field), so apply it afterwards.
+
+ // ENG-2091: a duplicate email does NOT throw here. Better Auth takes an enumeration-safe branch
+ // whenever `emailAndPassword.requireEmailVerification || autoSignIn === false` — auth.ts sets both —
+ // which hashes the password for timing parity, then returns HTTP 200 carrying a SYNTHETIC user: a
+ // freshly generated id, no row written, no credential linked and (because it returns before the send)
+ // no verification email. So the id it hands back, not a thrown error, is what distinguishes the two:
+ // a synthetic id is never in the database. Do not "simplify" this to the catch above — that branch
+ // only fires under a configuration that makes Better Auth throw USER_ALREADY_EXISTS instead, which
+ // is why both signals are kept.
+ if (user.id !== signedUpUserId) {
+ return { status: "already_existed" };
+ }
+
+ // signUpEmail can't carry the chosen locale (not a Better Auth field), so apply it afterwards — only
+ // for an account this request created. Applying it on the already-existed path would let an
+ // anonymous caller rewrite an existing user's locale by POSTing their address at sign-up.
if (userLocale && user.locale !== userLocale) {
await updateUser(user.id, { locale: userLocale });
- user = { ...user, locale: userLocale };
+ return { status: "created", user: { ...user, locale: userLocale } };
}
- return { user, userAlreadyExisted: false };
+ return { status: "created", user };
}
async function handleInviteAcceptance(
@@ -154,6 +194,11 @@ async function handleInviteAcceptance(
// being created, anyone holding an invite link — they get forwarded, pasted into tickets, and land in
// referrer logs — could redeem it with an arbitrary address and take the invited role, which may be
// manager or owner. `resolveInviteMatch` also enforces the invite's expiry, which this path skipped.
+ //
+ // Deliberately re-checked here even though `createUserAction` already rejected an invalid token
+ // before creating the user: this is the function that performs the grant, so the check belongs
+ // beside it and keeps holding if another caller ever appears. The cost is one extra HMAC verify —
+ // the invite lookup behind it is request-cached, so no second query.
const inviteMatch = await resolveInviteMatch(inviteToken, user.email);
if (inviteMatch !== "valid") {
logger.warn({ inviteMatch }, "Rejected invite acceptance during sign-up");
@@ -278,9 +323,13 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
+/**
+ * Provisioning that must only ever follow a real account creation. Takes the `"created"` outcome
+ * rather than a bare user so an `"already_existed"` sign-up cannot reach it (ENG-2091).
+ */
async function handlePostUserCreation(
ctx: ActionClientCtx,
- user: TCreatedUser,
+ { user }: Extract,
inviteToken: string | undefined
): Promise {
if (inviteToken) {
@@ -344,7 +393,7 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
// The domain policy passed, so mark the request scope: user.create.before uses this to tell a
// sign-up that went through this action apart from a direct POST to Better Auth's native
// /sign-up/email endpoint (which bypasses the action and is re-checked in the hook).
- const { user, userAlreadyExisted } = await runWithSignupRequestContext(() => {
+ const outcome = await runWithSignupRequestContext(() => {
markSignupDomainAllowed();
return signUpUserSafely(
parsedInput.email,
@@ -354,8 +403,12 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
);
});
- if (!userAlreadyExisted && user) {
- await handlePostUserCreation(ctx, user, inviteToken);
+ // Everything below is provisioning + analytics for a NEW account. On "already_existed" the response
+ // is deliberately identical (enumeration-safe) but nothing runs: this endpoint is unauthenticated,
+ // so the caller has proven nothing about an account that already exists (ENG-2091).
+ if (outcome.status === "created") {
+ const { user } = outcome;
+ await handlePostUserCreation(ctx, outcome, inviteToken);
await subscribeUserToMailingList({
email: user.email,
@@ -398,13 +451,27 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
// Best-effort; the short cookie lifetime is the backstop.
}
}
- }
- if (user) {
+ // Inside the "created" branch: an audit record claiming a user was created must only exist when
+ // one was. Previously this ran unconditionally, so a duplicate sign-up wrote a `created` event
+ // attributed to the PRE-EXISTING user and carrying their object (ENG-2091 / S1).
ctx.auditLoggingCtx.userId = user.id;
ctx.auditLoggingCtx.newObject = user;
+ } else {
+ // No account was created, so no `created` event may be written. Attribution alone is not enough:
+ // `withAuditLogging` wraps the whole action with a fixed action name and cannot see which branch
+ // ran, so without this it still logged a SUCCESSFUL `created` for an UNKNOWN_DATA target — a false
+ // creation record on audit-enabled deployments (raised by @BhagyaAmarasinghe in review).
+ //
+ // The response stays byte-identical either way (ENG-2099) — this changes only what we record, and
+ // only on the success path, so a genuine failure is still audited.
+ ctx.auditLoggingCtx.suppressEvent = true;
}
+ // Deliberately invariant: the response never varies with whether the address already had an
+ // account, nor with what happened to the verification email. Both would make it a lookup
+ // (ENG-2099). The verification-requested screen the form lands on is phrased conditionally and
+ // carries a log-in link, so the existing-account visitor still has a way out.
return {
success: true,
};
diff --git a/apps/web/modules/auth/signup/components/signup-form.tsx b/apps/web/modules/auth/signup/components/signup-form.tsx
index 1065e1e6ed9c..98ae9c77ac43 100644
--- a/apps/web/modules/auth/signup/components/signup-form.tsx
+++ b/apps/web/modules/auth/signup/components/signup-form.tsx
@@ -17,7 +17,10 @@ import {
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { buildAttributionQuerySuffix } from "@/modules/auth/lib/attribution";
-import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links";
+import {
+ buildSignupWithoutVerificationSuccessPath,
+ buildVerificationRequestedPath,
+} from "@/modules/auth/lib/verification-links";
import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
@@ -75,7 +78,7 @@ export const SignupForm = ({
isTurnstileConfigured,
turnstileSiteKey,
isFormbricksCloud,
-}: SignupFormProps) => {
+}: Readonly) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
const { t } = useTranslation();
@@ -124,6 +127,38 @@ export const SignupForm = ({
resolver: zodResolver(ZSignupInput),
});
+ /**
+ * Map a failed `createUserAction` to where the user should see it: the two field-level rejections go
+ * under the input that caused them, everything else is a toast. Each stable error code exists so the
+ * server can be specific without the message itself being an enumeration signal.
+ */
+ const surfaceSignupError = (errorMessage: string) => {
+ switch (errorMessage) {
+ case SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE:
+ form.setError("email", { type: "manual", message: t("auth.signup.company_email_required") });
+ return;
+ case PASSWORD_COMPROMISED_ERROR_CODE:
+ form.setError("password", { type: "manual", message: t("auth.password_compromised") });
+ return;
+ case INVITE_TOKEN_INVALID_ERROR_CODE:
+ // Reachable when the invite expires or is revoked between this page rendering and the form being
+ // submitted. Reuses the existing invite copy rather than naming the specific reason, matching the
+ // server, which returns one code for expired / revoked / wrong-address so it cannot be used to
+ // probe which invites exist.
+ toast.error(t("auth.invite.invite_not_found_description"));
+ return;
+ default:
+ // SIGNUP_DISABLED_ERROR_CODE lands here (#8681). CodeRabbit is right that a real user can see
+ // it — sign-up can be open when this page renders and closed before submit, the same
+ // render-then-revoke race that makes the invite branch above user-facing — so it should be
+ // translated rather than shown as a raw code. Deferred there because adding an en-US string
+ // needs the 14 target locales populated too, and doing it without that reddens
+ // `scan-translations`; tracked as a follow-up. (This PR hand-writes such strings with their
+ // i18n.lock checksums, so that route is open to whoever picks the follow-up up.)
+ toast.error(errorMessage);
+ }
+ };
+
const handleSubmit = async (data: TSignupInput) => {
try {
if (isTurnstileConfigured && !turnstileToken) {
@@ -151,30 +186,7 @@ export const SignupForm = ({
if (!createUserResponse?.data) {
resetTurnstileIfConfigured();
-
- const errorMessage = getFormattedErrorMessage(createUserResponse);
- // Personal-email block: surface under the email field rather than as a toast.
- if (errorMessage === SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE) {
- form.setError("email", { type: "manual", message: t("auth.signup.company_email_required") });
- } else if (errorMessage === PASSWORD_COMPROMISED_ERROR_CODE) {
- // Breached password: surface under the password field with a clear, actionable message.
- form.setError("password", { type: "manual", message: t("auth.password_compromised") });
- } else if (errorMessage === INVITE_TOKEN_INVALID_ERROR_CODE) {
- // Reachable when the invite expires or is revoked between this page rendering and the form
- // being submitted. Reuses the existing invite copy rather than naming the specific reason,
- // matching the server, which returns one code for expired / revoked / wrong-address so it
- // cannot be used to probe which invites exist.
- toast.error(t("auth.invite.invite_not_found_description"));
- } else {
- // SIGNUP_DISABLED_ERROR_CODE lands here. CodeRabbit is right that a real user can see it —
- // sign-up can be open when this page renders and closed before submit, the same
- // render-then-revoke race that makes the invite branch above user-facing — so it should be
- // translated rather than shown as a raw code. Deferred, not declined: the fix needs a new
- // en-US string plus a Lingo run to populate the 14 target locales, and adding the key without
- // that run fails `scan-translations` (incomplete translations + lockfile out of sync). Doing
- // it here would redden the translation gate on a release-critical PR; tracked for follow-up.
- toast.error(errorMessage);
- }
+ surfaceSignupError(getFormattedErrorMessage(createUserResponse));
return;
}
@@ -189,12 +201,13 @@ export const SignupForm = ({
return;
}
+ // Both branches carry the invite callback. The verification-disabled branch is the default for
+ // self-hosted, so omitting it there left invited users with an existing account unable to reach
+ // the invite from the screen they land on (ENG-2091, raised in review).
+ const callbackUrl = inviteToken ? returnToUrl : undefined;
const url = emailVerificationDisabled
- ? `/auth/signup-without-verification-success?token=${token}`
- : buildVerificationRequestedPath({
- token,
- callbackUrl: inviteToken ? returnToUrl : undefined,
- });
+ ? buildSignupWithoutVerificationSuccessPath({ token, callbackUrl })
+ : buildVerificationRequestedPath({ token, callbackUrl });
router.push(url);
} catch (e: any) {
diff --git a/apps/web/modules/auth/signup/signup-duplicate-email.integration.test.ts b/apps/web/modules/auth/signup/signup-duplicate-email.integration.test.ts
new file mode 100644
index 000000000000..42502e6dd281
--- /dev/null
+++ b/apps/web/modules/auth/signup/signup-duplicate-email.integration.test.ts
@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { resetDb } from "@/integration/reset-db";
+import { auth } from "@/modules/auth/lib/auth";
+import { sendVerificationLinkEmail } from "@/modules/email";
+
+/**
+ * Pins Better Auth's duplicate-email contract, which `signUpUserSafely` depends on (ENG-2091).
+ *
+ * Because `emailAndPassword.requireEmailVerification || autoSignIn === false` (auth.ts sets both),
+ * Better Auth answers a duplicate email with an enumeration-safe HTTP 200 carrying a SYNTHETIC user —
+ * it does NOT throw. The sign-up action reads that synthetic id to tell "created" from
+ * "already existed", so this contract has to be asserted against the real framework: the unit suite
+ * previously mocked `signUpEmail` into rejecting, which asserted a branch that cannot execute.
+ */
+
+beforeEach(async () => {
+ await resetDb();
+ vi.clearAllMocks();
+});
+
+const EMAIL = "invitee@corporate-example.com";
+const PASSWORD = "Passw0rd!";
+
+describe("Better Auth duplicate-email sign-up (real Postgres)", () => {
+ test("a first-time sign-up persists the returned user and sends one verification email", async () => {
+ const result = await auth.api.signUpEmail({
+ body: { email: EMAIL, password: PASSWORD, name: "Invitee" },
+ });
+
+ const persisted = await prisma.user.findUnique({ where: { email: EMAIL }, select: { id: true } });
+ // The id Better Auth returns IS the persisted row — this is the signal the action relies on.
+ expect(result.user.id).toBe(persisted?.id);
+ expect(sendVerificationLinkEmail).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(sendVerificationLinkEmail).mock.calls[0][0].email).toBe(EMAIL);
+ });
+
+ test("a duplicate resolves with a synthetic user, sends nothing, and writes nothing", async () => {
+ await auth.api.signUpEmail({ body: { email: EMAIL, password: PASSWORD, name: "Invitee" } });
+ const realUser = await prisma.user.findUnique({ where: { email: EMAIL }, select: { id: true } });
+ vi.clearAllMocks();
+
+ // Deliberately NOT wrapped in expect().rejects — the whole point is that it resolves.
+ const result = await auth.api.signUpEmail({
+ body: { email: EMAIL, password: "TotallyDifferent1!", name: "Someone Else" },
+ });
+
+ // Synthetic: a generated id that is not in the database.
+ expect(result.user.id).toBeTruthy();
+ expect(result.user.id).not.toBe(realUser?.id);
+ expect(await prisma.user.count({ where: { id: result.user.id } })).toBe(0);
+
+ // No email goes out on this path, which is why the "we sent you a link" screen was a lie.
+ expect(sendVerificationLinkEmail).not.toHaveBeenCalled();
+
+ // Nothing was written: still one user, one credential account, original password still valid.
+ expect(await prisma.user.count({ where: { email: EMAIL } })).toBe(1);
+ expect(await prisma.account.count({ where: { userId: realUser?.id } })).toBe(1);
+ const signIn = await auth.api.signInEmail({
+ body: { email: EMAIL, password: PASSWORD },
+ asResponse: true,
+ });
+ expect(signIn.status).toBe(200);
+ });
+});
diff --git a/apps/web/modules/auth/signup/signup-invite.integration.test.ts b/apps/web/modules/auth/signup/signup-invite.integration.test.ts
new file mode 100644
index 000000000000..a6c666d85b5e
--- /dev/null
+++ b/apps/web/modules/auth/signup/signup-invite.integration.test.ts
@@ -0,0 +1,279 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { resetDb } from "@/integration/reset-db";
+import { createInviteToken } from "@/lib/jwt";
+import { capturePostHogEvent, identifyPostHogPerson } from "@/lib/posthog";
+import { createUserAction } from "@/modules/auth/signup/actions";
+import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
+import { sendInviteAcceptedEmail, sendVerificationLinkEmail } from "@/modules/email";
+
+/**
+ * Invite sign-up at the ACTION boundary: the real `createUserAction` + real Better Auth + real
+ * Postgres, driven exactly the way the sign-up form drives it (name/email/password + inviteToken).
+ *
+ * The second test is the ENG-2091 regression: because Better Auth answers a duplicate email with a
+ * synthetic HTTP 200 instead of throwing, the action used to classify it as a fresh creation and run
+ * every post-creation side effect against the pre-existing account.
+ */
+
+// The action reads/deletes the attribution cookie; there is no Next request scope under vitest.
+vi.mock("next/headers", () => ({
+ cookies: vi.fn(async () => ({ get: () => undefined, delete: () => undefined })),
+ headers: vi.fn(async () => new Headers()),
+}));
+
+/**
+ * Cloud-shaped instance: public sign-up open and multi-org licensed. That is the environment ENG-2091
+ * was reported in, and stating it here does two things. It satisfies the closed-instance gate added in
+ * #8681 — these are the only uninvited sign-ups in the suite, and the DB is not empty, so the
+ * fresh-instance branch that lets them through elsewhere does not apply. And it makes the
+ * organization assertions meaningful: with multi-org off, `handleOrganizationCreation` returns early
+ * and "no organization was created" would pass whether or not the fix is present.
+ */
+vi.mock("@/lib/constants", async (importOriginal) => ({
+ ...(await importOriginal()),
+ SIGNUP_ENABLED: true,
+}));
+
+vi.mock("@/modules/ee/license-check/lib/utils", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getIsMultiOrgEnabled: vi.fn(async () => true),
+}));
+
+vi.mock("@/modules/ee/mailing/lib/mailing-subscription", () => ({
+ subscribeUserToMailingList: vi.fn(async () => undefined),
+}));
+
+vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, queueAuditEventBackground: vi.fn(async () => undefined) };
+});
+
+const INVITED_EMAIL = "invitee@corporate-example.com";
+const PASSWORD = "Passw0rd!";
+
+const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * Org + inviter + an Invite row for INVITED_EMAIL; returns the token the invite email carries.
+ *
+ * `expiresAt` is the row's expiry only — the JWT is always minted fresh with its own 7-day `exp`, so
+ * an `expiresAt` in the past produces a structurally valid token whose invite has lapsed. That is the
+ * only way to exercise the DB-side expiry check independently of the token's own.
+ */
+const seedInvite = async ({
+ role = "member",
+ expiresAt = new Date(Date.now() + WEEK_IN_MS),
+}: { role?: "member" | "manager" | "owner"; expiresAt?: Date } = {}): Promise<{
+ inviteToken: string;
+ organizationId: string;
+}> => {
+ const organization = await prisma.organization.create({ data: { name: "Corporate Example" } });
+ const inviter = await prisma.user.create({
+ data: { name: "Inviter", email: "admin@corporate-example.com" },
+ });
+ const invite = await prisma.invite.create({
+ data: {
+ email: INVITED_EMAIL,
+ organizationId: organization.id,
+ creatorId: inviter.id,
+ role,
+ expiresAt,
+ },
+ });
+ return {
+ inviteToken: createInviteToken(invite.id, invite.email, { expiresIn: "7d" }),
+ organizationId: organization.id,
+ };
+};
+
+beforeEach(async () => {
+ await resetDb();
+ vi.clearAllMocks();
+});
+
+describe("ENG-2091: accepting an invite via sign-up", () => {
+ test("brand-new invitee: verification email sent, invite accepted", async () => {
+ const { inviteToken, organizationId } = await seedInvite();
+
+ const result = await createUserAction({
+ name: "Invitee",
+ email: INVITED_EMAIL,
+ password: PASSWORD,
+ inviteToken,
+ });
+
+ expect(result?.data).toEqual({ success: true });
+ expect(sendVerificationLinkEmail).toHaveBeenCalledTimes(1);
+
+ const user = await prisma.user.findUnique({ where: { email: INVITED_EMAIL }, select: { id: true } });
+ expect(await prisma.membership.count({ where: { organizationId, userId: user?.id } })).toBe(1);
+ expect(await prisma.invite.count({ where: { email: INVITED_EMAIL } })).toBe(0);
+ });
+
+ test("invitee who ALREADY has an account: no side effects touch it, invite survives", async () => {
+ const { inviteToken, organizationId } = await seedInvite();
+ // The invitee already signed up for Formbricks earlier with the same corporate address.
+ const existing = await prisma.user.create({
+ data: {
+ name: "Invitee",
+ email: INVITED_EMAIL,
+ locale: "de-DE",
+ password: "not-a-real-hash-fixture",
+ },
+ });
+ vi.clearAllMocks();
+
+ const result = await createUserAction({
+ name: "Someone Else",
+ email: INVITED_EMAIL,
+ password: "SomeOtherPassword1!",
+ userLocale: "en-US",
+ inviteToken,
+ });
+
+ // ENG-2099: the response must be indistinguishable from a brand-new address — an earlier version
+ // routed this case to login, which turned the invite flow into an account-existence lookup. The
+ // verification-requested screen it lands on says "if there is an account associated with …" and
+ // carries an unconditional log-in link, so this visitor still has a way out.
+ expect(result?.data).toEqual({ success: true });
+ // No verification email is sent, because no account was created.
+ expect(sendVerificationLinkEmail).not.toHaveBeenCalled();
+
+ // Nothing may touch the pre-existing account: this endpoint is unauthenticated and the caller has
+ // proven nothing about it. The invite must survive so logging in can still accept it.
+ expect(await prisma.membership.count({ where: { organizationId, userId: existing.id } })).toBe(0);
+ expect(await prisma.invite.count({ where: { email: INVITED_EMAIL } })).toBe(1);
+ expect(sendInviteAcceptedEmail).not.toHaveBeenCalled();
+ expect(subscribeUserToMailingList).not.toHaveBeenCalled();
+ expect(capturePostHogEvent).not.toHaveBeenCalled();
+ expect(identifyPostHogPerson).not.toHaveBeenCalled();
+
+ // Profile fields stay untouched — name and locale are attacker-supplied on this path.
+ const after = await prisma.user.findUnique({ where: { id: existing.id } });
+ expect(after?.name).toBe("Invitee");
+ expect(after?.locale).toBe("de-DE");
+ // The existing credential is untouched (no password overwrite).
+ expect(after?.password).toBe("not-a-real-hash-fixture");
+ });
+
+ /**
+ * ENG-2099: the invite sign-up response must not be usable as an account-existence lookup. Asserted
+ * directly rather than inferred from the two tests above — anyone who can send an invite can run this
+ * comparison, so a future change that reintroduces a differential (an extra response field, a
+ * different status, or an error) has to fail here.
+ *
+ * Crossed with the mailer state on purpose. The first version of this fix returned a distinct
+ * `verification_send_failed` step, which reads as address-independent — but Better Auth only attempts a
+ * send for an address it actually created, so during a mail outage that step appeared for a fresh
+ * address and never for one already taken. Comparing existence under a HEALTHY mailer, as this test
+ * originally did, could never see that; the sibling `signup-verification-send` file pins the other
+ * axis. Neither covers the corner on its own.
+ */
+ const MAILER_STATES = [
+ { name: "healthy mailer", mailerReturns: true },
+ { name: "mail outage", mailerReturns: false },
+ ];
+
+ test.each(MAILER_STATES)(
+ "returns a byte-identical response whether or not the address exists ($name)",
+ async ({ mailerReturns }) => {
+ const signUp = async (seedExistingAccount: boolean) => {
+ await resetDb();
+ const { inviteToken } = await seedInvite();
+ if (seedExistingAccount) {
+ await prisma.user.create({
+ data: { name: "Invitee", email: INVITED_EMAIL, password: "not-a-real-hash-fixture" },
+ });
+ }
+ vi.mocked(sendVerificationLinkEmail).mockResolvedValue(mailerReturns);
+ const result = await createUserAction({
+ name: "Invitee",
+ email: INVITED_EMAIL,
+ password: PASSWORD,
+ inviteToken,
+ });
+ return { data: result?.data, serverError: result?.serverError };
+ };
+
+ expect(await signUp(true)).toEqual(await signUp(false));
+ }
+ );
+
+ // ENG-2071: an invite binds one address to one role in one organization. Before this, only the
+ // signature was checked, so anyone holding a forwarded invite link could redeem it with an address
+ // of their own and take the invited role — up to owner.
+ test("refuses an invite redeemed with a different address, and creates nothing", async () => {
+ const { inviteToken, organizationId } = await seedInvite(); // invite is for INVITED_EMAIL
+ const attackerEmail = "attacker@other-example.com";
+ vi.clearAllMocks();
+
+ const result = await createUserAction({
+ name: "Attacker",
+ email: attackerEmail,
+ password: "AttackerPassword1!",
+ inviteToken,
+ });
+
+ // Rejected with the stable code the form maps to the generic invite copy — one code for
+ // expired / revoked / wrong-address, so it can't be used to probe which invites exist.
+ expect(result?.data).toBeUndefined();
+ expect(result?.serverError).toContain("invite_token_invalid");
+
+ // Nothing was written: no account for the attacker, no membership, and the invite survives for
+ // its real recipient.
+ expect(await prisma.user.count({ where: { email: attackerEmail } })).toBe(0);
+ expect(await prisma.membership.count({ where: { organizationId } })).toBe(0);
+ expect(await prisma.invite.count({ where: { email: INVITED_EMAIL } })).toBe(1);
+ expect(sendVerificationLinkEmail).not.toHaveBeenCalled();
+ expect(sendInviteAcceptedEmail).not.toHaveBeenCalled();
+ });
+
+ test("refuses an expired invite even though the token signature is still valid", async () => {
+ const { inviteToken, organizationId } = await seedInvite({
+ role: "owner",
+ expiresAt: new Date(Date.now() - 60_000), // lapsed a minute ago
+ });
+ vi.clearAllMocks();
+
+ const result = await createUserAction({
+ name: "Invitee",
+ email: INVITED_EMAIL,
+ password: PASSWORD,
+ inviteToken,
+ });
+
+ expect(result?.data).toBeUndefined();
+ expect(result?.serverError).toContain("invite_token_invalid");
+ expect(await prisma.user.count({ where: { email: INVITED_EMAIL } })).toBe(0);
+ expect(await prisma.membership.count({ where: { organizationId } })).toBe(0);
+ });
+});
+
+describe("plain sign-up (no invite) with an address that already exists", () => {
+ test("creates no organization and no membership on the existing account", async () => {
+ const existing = await prisma.user.create({
+ data: { name: "Someone", email: "victim@corporate-example.com", password: "not-a-real-hash-fixture" },
+ });
+ const orgsBefore = await prisma.organization.count();
+
+ const result = await createUserAction({
+ name: "Attacker",
+ email: "victim@corporate-example.com",
+ password: "AttackerPassword1!",
+ });
+
+ // No invite, so nothing is disclosed: the response is byte-identical to a real new sign-up, and
+ // the user lands on the generic "confirm your email" screen (whose copy carries a generic
+ // "already have an account? log in" line). This is the enumeration-safety boundary.
+ expect(result?.data).toEqual({ success: true });
+ expect(sendVerificationLinkEmail).not.toHaveBeenCalled();
+ // On Formbricks Cloud getIsMultiOrgEnabled() is true, so before the fix this branch created an
+ // organization + owner membership on someone else's account, once per request.
+ expect({
+ orgs: await prisma.organization.count(),
+ memberships: await prisma.membership.count({ where: { userId: existing.id } }),
+ }).toEqual({ orgs: orgsBefore, memberships: 0 });
+ expect(subscribeUserToMailingList).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/modules/auth/signup/signup-sso-existing-account.integration.test.ts b/apps/web/modules/auth/signup/signup-sso-existing-account.integration.test.ts
new file mode 100644
index 000000000000..7ddfe74b1f49
--- /dev/null
+++ b/apps/web/modules/auth/signup/signup-sso-existing-account.integration.test.ts
@@ -0,0 +1,144 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { resetDb } from "@/integration/reset-db";
+import { createInviteToken } from "@/lib/jwt";
+import { createUserAction } from "@/modules/auth/signup/actions";
+import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
+import { sendInviteAcceptedEmail, sendVerificationLinkEmail } from "@/modules/email";
+
+/**
+ * The reported ENG-2091 user signs in with Azure, so the account they already had is SSO-only: an
+ * `identityProvider` of `azuread` and NO credential account, therefore no password to verify against.
+ *
+ * That matters because the duplicate detection this fix relies on reads the id Better Auth returns from
+ * `signUpEmail`. The rest of the suite exercises credential accounts; this file pins the SSO-only shape,
+ * which reaches `signUpEmail`'s duplicate branch by a different route (the user row exists but has no
+ * `credential` account row).
+ */
+
+vi.mock("next/headers", () => ({
+ cookies: vi.fn(async () => ({ get: () => undefined, delete: () => undefined })),
+ headers: vi.fn(async () => new Headers()),
+}));
+
+/**
+ * Cloud-shaped instance: public sign-up open and multi-org licensed. That is the environment ENG-2091
+ * was reported in, and stating it here does two things. It satisfies the closed-instance gate added in
+ * #8681 — these are the only uninvited sign-ups in the suite, and the DB is not empty, so the
+ * fresh-instance branch that lets them through elsewhere does not apply. And it makes the
+ * organization assertions meaningful: with multi-org off, `handleOrganizationCreation` returns early
+ * and "no organization was created" would pass whether or not the fix is present.
+ */
+vi.mock("@/lib/constants", async (importOriginal) => ({
+ ...(await importOriginal()),
+ SIGNUP_ENABLED: true,
+}));
+
+vi.mock("@/modules/ee/license-check/lib/utils", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getIsMultiOrgEnabled: vi.fn(async () => true),
+}));
+
+vi.mock("@/modules/ee/mailing/lib/mailing-subscription", () => ({
+ subscribeUserToMailingList: vi.fn(async () => undefined),
+}));
+
+vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, queueAuditEventBackground: vi.fn(async () => undefined) };
+});
+
+const SSO_EMAIL = "azure.person@corporate-example.com";
+
+/** An SSO-provisioned user: verified by the IdP, no credential account, so no password exists. */
+const seedSsoUser = async () =>
+ prisma.user.create({
+ data: {
+ name: "Azure Person",
+ email: SSO_EMAIL,
+ emailVerified: true,
+ identityProvider: "azuread",
+ identityProviderAccountId: "azure-object-id-123",
+ },
+ });
+
+const seedInviteFor = async (email: string) => {
+ const organization = await prisma.organization.create({ data: { name: "Corporate Example" } });
+ const inviter = await prisma.user.create({
+ data: { name: "Inviter", email: "owner@corporate-example.com" },
+ });
+ const invite = await prisma.invite.create({
+ data: {
+ email,
+ organizationId: organization.id,
+ creatorId: inviter.id,
+ role: "member",
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ },
+ });
+ return {
+ inviteToken: createInviteToken(invite.id, invite.email, { expiresIn: "7d" }),
+ organizationId: organization.id,
+ };
+};
+
+beforeEach(async () => {
+ await resetDb();
+ vi.clearAllMocks();
+});
+
+describe("invite sign-up when the existing account is SSO-only (real Postgres)", () => {
+ test("is detected as already-existing, and nothing is written to the SSO account", async () => {
+ const ssoUser = await seedSsoUser();
+ const { inviteToken, organizationId } = await seedInviteFor(SSO_EMAIL);
+ // No credential account for this user — the precondition that distinguishes this from the
+ // password-account cases covered elsewhere.
+ expect(await prisma.account.count({ where: { userId: ssoUser.id } })).toBe(0);
+ vi.clearAllMocks();
+
+ const result = await createUserAction({
+ name: "Azure Person",
+ email: SSO_EMAIL,
+ password: "SomePassword1!",
+ inviteToken,
+ });
+
+ // Same indistinguishable response as the credential case (ENG-2099).
+ expect(result?.data).toEqual({ success: true });
+ expect(sendVerificationLinkEmail).not.toHaveBeenCalled();
+
+ // Critically: no credential account may be created for an SSO-only user by an unauthenticated
+ // caller — that would attach a password to an account the caller does not own.
+ expect(await prisma.account.count({ where: { userId: ssoUser.id } })).toBe(0);
+
+ // And none of the post-creation side effects touch the account.
+ expect(await prisma.membership.count({ where: { organizationId, userId: ssoUser.id } })).toBe(0);
+ expect(await prisma.invite.count({ where: { email: SSO_EMAIL } })).toBe(1);
+ expect(sendInviteAcceptedEmail).not.toHaveBeenCalled();
+ expect(subscribeUserToMailingList).not.toHaveBeenCalled();
+
+ // The IdP-attested verification state and provider are untouched.
+ const after = await prisma.user.findUnique({ where: { id: ssoUser.id } });
+ expect(after?.emailVerified).toBe(true);
+ expect(after?.identityProvider).toBe("azuread");
+ expect(after?.password).toBeNull();
+ });
+
+ test("a plain sign-up with an SSO address creates nothing either", async () => {
+ const ssoUser = await seedSsoUser();
+ const orgsBefore = await prisma.organization.count();
+ vi.clearAllMocks();
+
+ const result = await createUserAction({
+ name: "Impersonator",
+ email: SSO_EMAIL,
+ password: "AttackerPassword1!",
+ });
+
+ // Enumeration-safe: no invite, so the generic screen — identical to a brand-new address.
+ expect(result?.data).toEqual({ success: true });
+ expect(await prisma.account.count({ where: { userId: ssoUser.id } })).toBe(0);
+ expect(await prisma.organization.count()).toBe(orgsBefore);
+ expect(await prisma.membership.count({ where: { userId: ssoUser.id } })).toBe(0);
+ });
+});
diff --git a/apps/web/modules/auth/signup/signup-verification-send.integration.test.ts b/apps/web/modules/auth/signup/signup-verification-send.integration.test.ts
new file mode 100644
index 000000000000..5b20018cbf30
--- /dev/null
+++ b/apps/web/modules/auth/signup/signup-verification-send.integration.test.ts
@@ -0,0 +1,90 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { resetDb } from "@/integration/reset-db";
+import { createUserAction } from "@/modules/auth/signup/actions";
+import { sendVerificationLinkEmail } from "@/modules/email";
+
+/**
+ * What a failed verification-email send may and may not do to sign-up (ENG-2091 / ENG-2099).
+ *
+ * MAY NOT: change the response. Better Auth only attempts the send for an address it actually created,
+ * so any send outcome reaching the caller would answer "did this address already have an account?" —
+ * and anyone who can send an invite could ask. The failure is logged and reported to Sentry instead
+ * (auth.ts `sendVerificationEmail`), and the verification-requested screen derives its "nothing was
+ * sent" copy from IS_SMTP_CONFIGURED, which does not depend on the address.
+ *
+ * MUST: still create the account. Better Auth calls the callback through `runInBackgroundOrAwait`,
+ * whose catch only logs, so sign-up resolves 200 whatever we throw — the user has a real account and
+ * needs the resend path, not a second sign-up.
+ */
+
+vi.mock("next/headers", () => ({
+ cookies: vi.fn(async () => ({ get: () => undefined, delete: () => undefined })),
+ headers: vi.fn(async () => new Headers()),
+}));
+
+vi.mock("@/modules/ee/mailing/lib/mailing-subscription", () => ({
+ subscribeUserToMailingList: vi.fn(async () => undefined),
+}));
+
+vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, queueAuditEventBackground: vi.fn(async () => undefined) };
+});
+
+const EMAIL = "newcomer@corporate-example.com";
+const signUp = () => createUserAction({ name: "Newcomer", email: EMAIL, password: "Passw0rd!" });
+
+/** The three mailer states the callback has to survive, including both silent-failure modes. */
+const MAILER_STATES = [
+ { name: "healthy", arrange: () => vi.mocked(sendVerificationLinkEmail).mockResolvedValue(true) },
+ {
+ name: "throwing (SMTP unreachable)",
+ arrange: () =>
+ vi.mocked(sendVerificationLinkEmail).mockRejectedValue(new Error("smtp connection refused")),
+ },
+ {
+ // sendEmail returns false without throwing when SMTP isn't configured, and Better Auth ignores the
+ // return value entirely — so this mode is silent on every path unless we check it.
+ name: "returning false (SMTP unconfigured)",
+ arrange: () => vi.mocked(sendVerificationLinkEmail).mockResolvedValue(false),
+ },
+];
+
+beforeEach(async () => {
+ await resetDb();
+ vi.clearAllMocks();
+ vi.mocked(sendVerificationLinkEmail).mockResolvedValue(true);
+});
+
+describe("verification email send failures during sign-up (real Postgres)", () => {
+ test.each(MAILER_STATES)("a $name mailer still creates the account", async ({ arrange }) => {
+ arrange();
+
+ const result = await signUp();
+
+ // Asserting success here pins the behaviour against a future Better Auth version that starts
+ // propagating the throw instead of swallowing it — that would leave the account created but the
+ // sign-up looking failed, driving the user to retry into a duplicate.
+ expect(result?.data).toEqual({ success: true });
+ expect(await prisma.user.count({ where: { email: EMAIL } })).toBe(1);
+ });
+
+ // The ENG-2099 invariant, asserted directly: a mail outage must not be visible in the response.
+ // Without this, "we couldn't send it" answers as reliably as "this address is taken" — the outage
+ // shows up for a fresh address and never for one that already has an account.
+ test("returns a byte-identical response in every mailer state", async () => {
+ const responses: unknown[] = [];
+ for (const { arrange } of MAILER_STATES) {
+ await resetDb();
+ vi.clearAllMocks();
+ arrange();
+ const result = await signUp();
+ responses.push({ data: result?.data, serverError: result?.serverError });
+ }
+
+ for (const response of responses) {
+ expect(response).toEqual(responses[0]);
+ }
+ });
+});
diff --git a/apps/web/modules/auth/verification-requested/page.tsx b/apps/web/modules/auth/verification-requested/page.tsx
index 517049ff2c55..68bbf1c6f1ec 100644
--- a/apps/web/modules/auth/verification-requested/page.tsx
+++ b/apps/web/modules/auth/verification-requested/page.tsx
@@ -1,18 +1,20 @@
+import Link from "next/link";
import { logger } from "@formbricks/logger";
import { ZUserEmail } from "@formbricks/types/user";
-import { WEBAPP_URL } from "@/lib/constants";
+import { IS_SMTP_CONFIGURED, WEBAPP_URL } from "@/lib/constants";
import { getEmailFromEmailToken } from "@/lib/jwt";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { resolveAuthCallbackUrl } from "@/modules/auth/lib/callback-url";
import { RequestVerificationEmail } from "@/modules/auth/verification-requested/components/request-verification-email";
import { VerificationMessage } from "@/modules/auth/verification-requested/components/verification-message";
+import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
export const VerificationRequestedPage = async ({
searchParams,
-}: {
+}: Readonly<{
searchParams: Promise<{ token: string; callbackUrl?: string | string[] }>;
-}) => {
+}>) => {
const t = await getTranslate();
const params = await searchParams;
const { token, callbackUrl } = params;
@@ -20,6 +22,25 @@ export const VerificationRequestedPage = async ({
searchParamCallbackUrl: callbackUrl,
webAppUrl: WEBAPP_URL,
});
+ // No mailer configured means nothing was sent and nothing ever will be, so say so rather than
+ // pointing the visitor at an inbox (ENG-2091). Derived from server config, NOT from what happened to
+ // this request: a per-request outcome would only be knowable for an address we just created, which
+ // would make this screen differ by whether the account already existed (ENG-2099). A transient send
+ // failure on a configured mailer is logged and reported to Sentry instead, and the resend button
+ // below surfaces it directly — that endpoint propagates the error rather than swallowing it.
+ //
+ // The copy therefore talks about the instance, never about this visitor's account: on an instance with
+ // no mailer this renders for everyone, including someone whose address already had an account and for
+ // whom nothing was created. The resend button is hidden in that state too — it cannot work — leaving
+ // the log-in link as the only offered action.
+ const mailerNotConfigured = !IS_SMTP_CONFIGURED;
+ // Carry the callback (for an invite sign-up, `/invite?token=…`) into the log-in link below, so a
+ // visitor who already has an account can log in and land straight back on the invite. Present for
+ // every invited visitor, not just those with an account — the link must not vary with that
+ // (ENG-2099), which is why it isn't conditional on anything.
+ const loginHref = resolvedCallbackUrl
+ ? `/auth/login?callbackUrl=${encodeURIComponent(resolvedCallbackUrl)}`
+ : "/auth/login";
try {
const email = getEmailFromEmailToken(token);
const parsedEmail = ZUserEmail.safeParse(email);
@@ -30,14 +51,47 @@ export const VerificationRequestedPage = async ({
- {t("auth.verification-requested.you_didnt_receive_an_email_or_your_link_expired")}
+ {/*
+ Hidden when there is no mailer: resending cannot work, so offering it would contradict the
+ message above telling the visitor to contact their administrator. Gated on server config,
+ which is the same for every visitor, so this does not reintroduce a differential (ENG-2099).
+ */}
+ {!mailerNotConfigured && (
+ <>
+
+ >
+ )}
+ {/*
+ Every visitor sees this, including one whose address already has an account — for them no
+ email is coming and the resend button above no-ops, so this link is the way out. It is
+ deliberately unconditional: making it depend on whether the account exists would turn this
+ page into an account-existence lookup (ENG-2099). Neither case is told anything untrue,
+ because both messages above are careful not to assert that an account was created — the
+ usual one is conditional ("if there is an account associated with …") and the no-mailer one
+ only talks about the instance.
+ */}
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-add-trigger-picker.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-add-trigger-picker.tsx
new file mode 100644
index 000000000000..7cce6e96d3b6
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/canvas/workflow-add-trigger-picker.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { type LucideIcon, PlusIcon, ZapIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { type TWorkflowTriggerType, WORKFLOW_TRIGGERS } from "@formbricks/workflows";
+import { cn } from "@/lib/cn";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/modules/ui/components/dropdown-menu";
+import { CATEGORY_CHIP_CLASS_NAMES } from "./workflow-canvas-node";
+
+interface WorkflowAddTriggerPickerProps {
+ onSelect: (triggerType: TWorkflowTriggerType) => void;
+}
+
+interface TriggerOption {
+ triggerType: TWorkflowTriggerType;
+ icon: LucideIcon;
+ label: string;
+ description: string;
+}
+
+// The empty-canvas starting point: a card styled like the canvas nodes whose click opens a
+// popover listing the available triggers. New drafts have no nodes, so this is the only
+// affordance on the canvas until a trigger is chosen.
+export const WorkflowAddTriggerPicker = ({ onSelect }: Readonly) => {
+ const { t } = useTranslation();
+
+ const options: TriggerOption[] = [
+ {
+ triggerType: WORKFLOW_TRIGGERS.RESPONSE_COMPLETED,
+ icon: ZapIcon,
+ label: t("workspace.workflows.response_completed"),
+ description: t("workspace.workflows.response_completed_description"),
+ },
+ ];
+
+ return (
+
+
+
+
+
+ {t("workspace.workflows.triggers")}
+
+ {options.map((option) => (
+ onSelect(option.triggerType)}>
+
+
+
+
+ {option.label}
+ {option.description}
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-canvas-node.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas-node.tsx
new file mode 100644
index 000000000000..852899c68585
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas-node.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
+import { useAtomValue, useSetAtom } from "jotai";
+import {
+ GitBranchIcon,
+ type LucideIcon,
+ MailIcon,
+ MoreVerticalIcon,
+ PlusIcon,
+ Trash2Icon,
+ TriangleAlertIcon,
+ ZapIcon,
+} from "lucide-react";
+import { memo } from "react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import {
+ type TWorkflowNodeData,
+ type TWorkflowNodeIcon,
+ appendSendEmailAfterNodeAtom,
+ canMutateCanvasAtom,
+ deleteWorkflowNodeAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { Button } from "@/modules/ui/components/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/modules/ui/components/dropdown-menu";
+
+const NODE_ICONS: Record = {
+ trigger: ZapIcon,
+ ifElse: GitBranchIcon,
+ email: MailIcon,
+};
+
+// Shared with the add-trigger / add-action pickers so selector icons carry the same category
+// colors as the canvas cards.
+export const CATEGORY_CHIP_CLASS_NAMES: Record = {
+ trigger: "bg-indigo-500 text-white",
+ flow: "bg-purple-500 text-white",
+ action: "bg-green-600 text-white",
+};
+
+const HANDLE_CLASS_NAMES = "!h-0 !w-0 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0";
+
+export const WorkflowCanvasNode = memo(
+ ({ id, data, selected }: Readonly>>) => {
+ const { t } = useTranslation();
+ const deleteNode = useSetAtom(deleteWorkflowNodeAtom);
+ const appendSendEmail = useSetAtom(appendSendEmailAfterNodeAtom);
+ const canMutate = useAtomValue(canMutateCanvasAtom);
+ const Icon = NODE_ICONS[data.icon];
+ const isTrigger = data.category === "trigger";
+
+ return (
+
+
+
+
+
+
+
+
+ {data.title}
+
+ {data.issue ? (
+ // The reason travels with the highlight — replaces the summary so the user never
+ // has to guess why a card is flagged.
+
+
+ {data.issue.label}
+
+ ) : (
+ {data.summary}
+ )}
+
+ {isTrigger && data.isLeaf && canMutate && (
+ // Only the trigger gets an inline `+` — workflows are currently capped at one action
+ // after the trigger, so neither the trailing send_email card nor mid-chain edges
+ // should advertise the affordance. The `+` opens an action picker instead of
+ // appending a default node, so the user consciously chooses each step.
+
+ );
+ }
+);
+
+WorkflowCanvasNode.displayName = "WorkflowCanvasNode";
diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.css b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.css
new file mode 100644
index 000000000000..a677ddd9ce7a
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.css
@@ -0,0 +1,20 @@
+/* Cursor and hit-testing overrides on top of @xyflow/react/dist/style.css, scoped via the
+ .workflow-canvas class on the ReactFlow root. Selectors repeat xyflow's own classes to win
+ on specificity regardless of stylesheet order. */
+
+/* Pointer mode: nodes are primarily click targets (a click opens the config panel), so show
+ the default arrow instead of xyflow's grab/pointer, and only switch to grabbing mid-drag. */
+.workflow-canvas .react-flow__node.selectable,
+.workflow-canvas .react-flow__node.draggable {
+ cursor: default;
+}
+
+.workflow-canvas .react-flow__node.draggable.dragging {
+ cursor: grabbing;
+}
+
+/* Pan mode: nodes are inert, so let pointer events fall through to the pane. Panning then
+ works even when the drag starts on a node, and the pane's grab cursor shows everywhere. */
+.workflow-canvas.pan-mode .react-flow__node {
+ pointer-events: none;
+}
diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.tsx
new file mode 100644
index 000000000000..f5aa611091bf
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/canvas/workflow-canvas.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import {
+ Background,
+ BackgroundVariant,
+ type EdgeTypes,
+ type Node,
+ type NodeTypes,
+ type OnNodesChange,
+ ReactFlow,
+ ReactFlowProvider,
+ applyNodeChanges,
+ useReactFlow,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import { useAtomValue, useSetAtom } from "jotai";
+import { PanelLeftIcon, PanelRightOpenIcon } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import {
+ WORKFLOW_CANVAS_NODE_TYPE,
+ WORKFLOW_CANVAS_SNAP_GRID,
+ reorganizeWorkflowDefinition,
+ snapWorkflowNodePosition,
+ updateNodePosition,
+ workflowDefinitionToFlowEdges,
+ workflowDefinitionToFlowNodes,
+} from "@/modules/ee/workflows/lib/definition-to-flow";
+import {
+ type TWorkflowNodeData,
+ addWorkflowTriggerAtom,
+ closeWorkflowNodeConfigModalAtom,
+ hasBoundTriggerSurveyAtom,
+ isCanvasLockedAtom,
+ isWorkflowInspectorCollapsedAtom,
+ isWorkflowNodeConfigModalOpenAtom,
+ isWorkflowSnapToCanvasEnabledAtom,
+ openWorkflowNodeConfigModalAtom,
+ setWorkflowDefinitionAtom,
+ toggleWorkflowInspectorAtom,
+ workflowAtom,
+ workflowDefinitionAtom,
+ workflowFlowNodesAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { Button } from "@/modules/ui/components/button";
+import { AddButtonEdge } from "./add-button-edge";
+import { CanvasControls } from "./canvas-controls";
+import { WorkflowAddTriggerPicker } from "./workflow-add-trigger-picker";
+import { WorkflowCanvasNode } from "./workflow-canvas-node";
+import "./workflow-canvas.css";
+import { WorkflowValidationStatus } from "./workflow-validation-status";
+
+const NODE_TYPES: NodeTypes = {
+ [WORKFLOW_CANVAS_NODE_TYPE]: WorkflowCanvasNode,
+};
+
+const EDGE_TYPES: EdgeTypes = {
+ addButton: AddButtonEdge,
+};
+
+// The canvas is the page's main action — let fitView scale small flows past the former 0.85 cap,
+// which rendered a fresh two-node workflow noticeably small. 2x proved too big; 1.15 is three
+// zoom-out steps down from it (RF's zoomIn/zoomOut step is 1.2x, and 2 / 1.2^3 ≈ 1.157).
+const WORKFLOW_CANVAS_MAX_ZOOM = 1.15;
+
+// The inspector column animates its width over 150ms (see workflow-inspector-panel.tsx); refit
+// only after the canvas has its final size, with a small buffer.
+const INSPECTOR_RESIZE_SETTLE_MS = 170;
+// Near-instant pan: the refit should feel like part of the sidebar toggle, not a second animation.
+const INSPECTOR_REFIT_PAN_MS = INSPECTOR_RESIZE_SETTLE_MS / 4;
+
+interface WorkflowCanvasProps {
+ isEditable: boolean;
+}
+
+const WorkflowCanvasContent = ({ isEditable }: Readonly) => {
+ const { t } = useTranslation();
+ const workflow = useAtomValue(workflowAtom);
+ const definition = useAtomValue(workflowDefinitionAtom);
+ const flowNodes = useAtomValue(workflowFlowNodesAtom);
+ const isSnapToCanvasEnabled = useAtomValue(isWorkflowSnapToCanvasEnabledAtom);
+ const isLocked = useAtomValue(isCanvasLockedAtom);
+ const setLocked = useSetAtom(isCanvasLockedAtom);
+ const isInspectorCollapsed = useAtomValue(isWorkflowInspectorCollapsedAtom);
+ const isNodeConfigOpen = useAtomValue(isWorkflowNodeConfigModalOpenAtom);
+ const toggleInspector = useSetAtom(toggleWorkflowInspectorAtom);
+ const closeNodeConfig = useSetAtom(closeWorkflowNodeConfigModalAtom);
+ const setDefinition = useSetAtom(setWorkflowDefinitionAtom);
+ const setFlowNodes = useSetAtom(workflowFlowNodesAtom);
+ const openNodeConfigModal = useSetAtom(openWorkflowNodeConfigModalAtom);
+ const addTrigger = useSetAtom(addWorkflowTriggerAtom);
+ const { fitView } = useReactFlow();
+ // `isEditable` (canEditDefinition) is the API-side gate. The drag/pointer mode toggle is the
+ // user-driven gate layered on top: even when permissions allow editing, the canvas stays
+ // non-mutable until the user switches to pointer mode.
+ const canMutate = isEditable && !isLocked;
+ // Shared by the picker overlay and the validation chip: while the centered add-trigger picker
+ // is the canvas's main event, the chip would only restate it ("1 problem: no trigger"), so the
+ // two are mutually exclusive by construction.
+ const isTriggerPickerVisible = Boolean(definition && !definition.trigger && isEditable);
+
+ // Shared flag owned by the builder page (server context OR workspace survey-list membership),
+ // so a just-picked survey clears the node's setup flag immediately.
+ const hasBoundSurvey = useAtomValue(hasBoundTriggerSurveyAtom);
+ // Unloaded state defaults to draft so a fresh page never flashes red before the workflow lands.
+ const isDraft = workflow ? workflow.status === "draft" : true;
+
+ const derivedFlowNodes = useMemo(
+ () => (definition ? workflowDefinitionToFlowNodes(definition, t, { hasBoundSurvey, isDraft }) : []),
+ [definition, t, hasBoundSurvey, isDraft]
+ );
+ const flowEdges = useMemo(
+ () => (definition ? workflowDefinitionToFlowEdges(definition) : []),
+ [definition]
+ );
+
+ // Keep ReactFlow's nodes in sync with the projected definition while preserving the user's
+ // current selection — recomputing from scratch would lose it on every definition edit.
+ useEffect(() => {
+ setFlowNodes((currentNodes) => {
+ const currentNodesById = new Map(currentNodes.map((node) => [node.id, node]));
+
+ return derivedFlowNodes.map((node) => ({
+ ...node,
+ selected: currentNodesById.get(node.id)?.selected ?? node.selected,
+ }));
+ });
+ }, [derivedFlowNodes, setFlowNodes]);
+
+ const handleNodesChange: OnNodesChange> = useCallback(
+ (changes) => setFlowNodes((currentNodes) => applyNodeChanges(changes, currentNodes)),
+ [setFlowNodes]
+ );
+
+ const handleNodeDragStop = useCallback(
+ (node: Node) => {
+ if (!canMutate) return;
+
+ const position = isSnapToCanvasEnabled ? snapWorkflowNodePosition(node.position) : node.position;
+ setDefinition((currentDefinition) =>
+ currentDefinition ? updateNodePosition(currentDefinition, node.id, position) : currentDefinition
+ );
+ },
+ [canMutate, isSnapToCanvasEnabled, setDefinition]
+ );
+
+ // Opening/closing the inspector resizes the canvas by 360px, which can push nodes behind the
+ // panel edge. Refit the VIEWPORT once the width transition settles — fitView only pans/zooms;
+ // node coordinates are untouched.
+ const isInspectorVisible = isNodeConfigOpen && !isInspectorCollapsed;
+ const previousInspectorVisibleRef = useRef(isInspectorVisible);
+ useEffect(() => {
+ if (previousInspectorVisibleRef.current === isInspectorVisible) return;
+ previousInspectorVisibleRef.current = isInspectorVisible;
+ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const timeoutHandle = setTimeout(() => {
+ void fitView({
+ padding: 0.25,
+ maxZoom: WORKFLOW_CANVAS_MAX_ZOOM,
+ minZoom: 0.4,
+ duration: prefersReducedMotion ? 0 : INSPECTOR_REFIT_PAN_MS,
+ });
+ }, INSPECTOR_RESIZE_SETTLE_MS / 2);
+ return () => clearTimeout(timeoutHandle);
+ }, [isInspectorVisible, fitView]);
+
+ const handleAutoLayout = useCallback(() => {
+ if (!canMutate) return;
+ setDefinition((currentDefinition) =>
+ currentDefinition ? reorganizeWorkflowDefinition(currentDefinition) : currentDefinition
+ );
+ // Skip the animated recenter for users who ask for reduced motion (read at call time —
+ // no reactivity needed for a click handler).
+ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ // Defer one frame so the new node positions render before RF recenters the viewport.
+ requestAnimationFrame(() =>
+ fitView({
+ padding: 0.25,
+ maxZoom: WORKFLOW_CANVAS_MAX_ZOOM,
+ minZoom: 0.4,
+ duration: prefersReducedMotion ? 0 : 300,
+ })
+ );
+ }, [canMutate, setDefinition, fitView]);
+
+ // Pan mode is the pan/browse tool: nodes are fully inert (no click, selection, or drag), so
+ // switching into it also clears any leftover selection. Pointer mode is the select/inspect
+ // tool and is available to everyone — mutations stay gated by status/permissions (canMutate).
+ const handlePanMode = () => {
+ setLocked(true);
+ setFlowNodes((currentNodes) =>
+ currentNodes.map((node) => (node.selected ? { ...node, selected: false } : node))
+ );
+ };
+
+ const handlePointerMode = () => setLocked(false);
+
+ return (
+
+ {/* The inspector only ever shows a node's config now, so the collapse toggle is only
+ offered while one is open. */}
+ {isNodeConfigOpen ? (
+
+
+
+ ) : null}
+ handleNodeDragStop(node)}
+ onNodeClick={(_event, node) => {
+ if (!isLocked) openNodeConfigModal(node.id);
+ }}
+ // Clicking empty canvas deselects (ReactFlow) and dismisses the node inspector — with
+ // Settings gone this is the natural way out of a node's config view.
+ onPaneClick={() => closeNodeConfig()}
+ // Mode-dependent cursor + hit-testing rules live in workflow-canvas.css, keyed off
+ // these classes (Tailwind can't express `.react-flow__node` — underscores in arbitrary
+ // selectors turn into spaces).
+ className={cn("workflow-canvas bg-slate-50", isLocked && "pan-mode")}
+ fitView
+ fitViewOptions={{ padding: 0.25, maxZoom: WORKFLOW_CANVAS_MAX_ZOOM, minZoom: 0.4 }}
+ defaultViewport={{ x: 0, y: 0, zoom: WORKFLOW_CANVAS_MAX_ZOOM }}
+ nodesDraggable={canMutate}
+ nodesConnectable={false}
+ snapGrid={WORKFLOW_CANVAS_SNAP_GRID}
+ snapToGrid={isSnapToCanvasEnabled}
+ proOptions={{ hideAttribution: true }}
+ elementsSelectable={!isLocked}
+ nodesFocusable={!isLocked}>
+ {/* Same dot grid the pre-ReactFlow mockup used: radial-gradient(#cbd5e1 1px) on an 18px grid. */}
+
+
+ {/* Empty drafts start with no nodes: the centered picker is how the trigger gets added.
+ Gated on status-based editability (not the layout lock) — adding nodes is a content
+ edit, same as the node config forms. */}
+ {isTriggerPickerVisible && (
+
+
+
+
+
+ )}
+
+ {/* Always-on live validation state (replaces the former manual "Validate" dry-run button):
+ a passive badge while valid, a button listing the problems while not. Suppressed while
+ the add-trigger picker is up so a fresh draft isn't greeted with a problem count. */}
+ {!isTriggerPickerVisible && }
+
+ );
+};
+
+export const WorkflowCanvas = (props: Readonly) => (
+
+
+
+);
diff --git a/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx b/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx
new file mode 100644
index 000000000000..64bf21132d5f
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/canvas/workflow-validation-status.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { CheckIcon, TriangleAlertIcon } from "lucide-react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker";
+import {
+ deriveTriggerEndingProblems,
+ workflowAtom,
+ workflowDefinitionAtom,
+ workflowValidationProblemsAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { WorkflowValidationProblemsDialog } from "../workflow-validation-problems-dialog";
+
+// Same pill geometry as the Badge component (which only renders plain text, hence hand-rolled
+// here: both states carry an icon and the invalid one is a real button).
+const PILL_CLASS_NAME =
+ "inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium";
+
+/**
+ * Always-on validation state for the loaded workflow, floating in the canvas's bottom-right
+ * corner (the canvas controls own bottom-center). Valid renders as a passive badge; problems
+ * render as a real button that opens the problems dialog. There is no manual trigger — the
+ * problem list recomputes live on every edit via workflowValidationProblemsAtom.
+ */
+export const WorkflowValidationStatus = () => {
+ const { t } = useTranslation();
+ const workflow = useAtomValue(workflowAtom);
+ const definition = useAtomValue(workflowDefinitionAtom);
+ const atomProblems = useAtomValue(workflowValidationProblemsAtom);
+ const [isProblemsDialogOpen, setIsProblemsDialogOpen] = useState(false);
+
+ // The ending-cards check is the one readiness rule that needs server data (the bound survey's
+ // current endings), so it is merged here at the component level — query results stay in the
+ // TanStack cache, never mirrored into Jotai. Consequence: the header's Enable gate
+ // (workflowValidityAtom.isReady) deliberately excludes ending problems; the server's enable
+ // pre-flight still rejects stale endings.
+ const trigger = definition?.trigger ?? null;
+ const endingsQuery = useWorkflowSurveyEndings(trigger?.config.surveyId ?? null);
+ // Only a successfully RESOLVED endings list may flag a problem. While the query is loading,
+ // disabled (no trigger/survey), or errored (e.g. a read-only viewer's 403, an unbound survey's
+ // 404), the check is skipped silently — unknown must never render as an error.
+ const endingProblems =
+ trigger && endingsQuery.isSuccess
+ ? deriveTriggerEndingProblems(
+ trigger.config.endingCardIds,
+ endingsQuery.endings.map((ending) => ending.id)
+ )
+ : [];
+ const problems = [...atomProblems, ...endingProblems];
+
+ // Nothing to report until the editor is hydrated.
+ if (!workflow) return null;
+
+ return (
+ // Polite live region so validity flips are announced without interrupting the user's editing.
+
+ {problems.length === 0 ? (
+ // Deliberately quiet: valid is the steady state, so the pill matches the neutral canvas
+ // chrome and only the check carries a soft green accent — attention belongs to problems.
+
+
+ {t("workspace.workflows.validation_status_valid")}
+
+ ) : (
+ // Problems while drafting are unfinished setup, not failure — amber (the Badge "warning"
+ // tokens), never red, and "problems" (the dialog's own word), never "errors".
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx
new file mode 100644
index 000000000000..06cbecb27fdc
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import { useAtomValue, useSetAtom } from "jotai";
+import { ArrowRightIcon } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ type TWorkflowSendEmailActionNode,
+ type TWorkflowSendEmailContentField,
+ getBlankSendEmailContentFields,
+} from "@formbricks/workflows";
+import { WorkflowEmailRecipientField } from "@/modules/ee/workflows/components/inspector/workflow-email-recipient-field";
+import {
+ WorkflowFieldError,
+ WorkflowFieldLabel,
+} from "@/modules/ee/workflows/components/inspector/workflow-field";
+import { useWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/components/workflow-email-authoring-context";
+import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus";
+import { resolveBoundTriggerSurvey } from "@/modules/ee/workflows/lib/bound-survey";
+import { openWorkflowNodeConfigModalAtom, workflowDefinitionAtom } from "@/modules/ee/workflows/state/editor";
+import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
+import {
+ type EmailSendToOption,
+ buildEmailSendToOptions,
+} from "@/modules/survey/follow-ups/lib/email-send-to-options";
+import { Button } from "@/modules/ui/components/button";
+import { Editor } from "@/modules/ui/components/editor";
+import { Input } from "@/modules/ui/components/input";
+import { Label } from "@/modules/ui/components/label";
+import { Switch } from "@/modules/ui/components/switch";
+
+interface WorkflowEmailActionFormProps {
+ node: TWorkflowSendEmailActionNode;
+ isEditable: boolean;
+ onChange: (next: TWorkflowSendEmailActionNode) => void;
+}
+
+// The internal "default language" slot recall/headline resolution uses when no language is selected.
+const DEFAULT_LANGUAGE_CODE = "default";
+
+const HTML_TAG_PATTERN = /<[a-z][\s\S]*>/i;
+
+const escapeHtml = (value: string): string =>
+ value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+
+// The recall Editor loads its initial value as HTML (root can only hold block nodes). Recall-token
+// bodies are already `
…
` HTML and pass through untouched; legacy plain-text bodies (e.g. the
+// seed's "Hi there…") are escaped and wrapped in paragraphs so Lexical doesn't crash on a bare text
+// node ("Only element or decorator nodes can be inserted to the root node").
+const toEditorHtml = (body: string): string => {
+ if (!body) return "";
+ if (HTML_TAG_PATTERN.test(body)) return body;
+ return body
+ .split(/\n{2,}/)
+ .map((paragraph) => `
${escapeHtml(paragraph).replaceAll("\n", " ")}
`)
+ .join("");
+};
+
+// The DOM ids the focus jump targets. `body` has no input of its own — the Lexical editor owns a
+// contenteditable inside its wrapper — so it is focused through a ref instead.
+const FIELD_INPUT_IDS: Record, string> = {
+ to: "workflow-email-to",
+ subject: "workflow-email-subject",
+};
+
+export const WorkflowEmailActionForm = ({
+ node,
+ isEditable,
+ onChange,
+}: Readonly) => {
+ const { t } = useTranslation();
+ const authoringContext = useWorkflowEmailAuthoringContext();
+ const definition = useAtomValue(workflowDefinitionAtom);
+ const openNodeConfigModal = useSetAtom(openWorkflowNodeConfigModalAtom);
+ const [firstRender, setFirstRender] = useState(true);
+
+ // Which required fields may show their error. A freshly added node stays clean until the user
+ // has actually engaged with a field and left it empty (or arrived here from the problems
+ // dialog); flagging an untouched brand-new node would paint three errors on open.
+ const [touchedFields, setTouchedFields] = useState>>(
+ {}
+ );
+ const markTouched = useCallback(
+ (...fields: TWorkflowSendEmailContentField[]) =>
+ setTouchedFields((current) =>
+ fields.every((field) => current[field])
+ ? current
+ : { ...current, ...Object.fromEntries(fields.map((field) => [field, true as const])) }
+ ),
+ []
+ );
+
+ const bodyWrapperRef = useRef(null);
+
+ // Stable identity: EditorContentChecker re-registers its update listener whenever this changes.
+ // Non-empty is what earns the body its "touched" flag, so typing-then-clearing shows the error
+ // while a node that arrived empty stays quiet.
+ const handleBodyEmptyChange = useCallback(
+ (isEmpty: boolean) => {
+ if (!isEmpty) markTouched("body");
+ },
+ [markTouched]
+ );
+
+ const updateConfig = (next: Partial) =>
+ onChange({ ...node, config: { ...node.config, ...next } });
+
+ const invalidFields = new Set(
+ getBlankSendEmailContentFields(node.config).filter((field) => touchedFields[field])
+ );
+
+ // Arriving from the validation problems dialog: reveal every missing field on this node (the
+ // point of the jump is to answer "which field is wrong") and focus the one it pointed at.
+ useWorkflowNodeFieldFocus({
+ nodeId: node.id,
+ onRequest: () => markTouched(...getBlankSendEmailContentFields(node.config)),
+ resolveElement: (field) =>
+ field === "body"
+ ? bodyWrapperRef.current?.querySelector('[contenteditable="true"]')
+ : document.getElementById(FIELD_INPUT_IDS[field as keyof typeof FIELD_INPUT_IDS]),
+ });
+
+ const triggerSurveyId = definition?.trigger?.type === "trigger" ? definition.trigger.config.surveyId : null;
+ const survey = resolveBoundTriggerSurvey(authoringContext, definition);
+
+ // Clear the recipient + body when the trigger's bound survey changes: `config.to` is an element/
+ // hidden-field id and `config.body` holds recall tokens, both of which dangle against the previous
+ // survey's elements. Mirrors how the trigger form clears `endingCardIds` on survey change. Skips the
+ // initial mount so loading an existing node never wipes its saved values.
+ const previousTriggerSurveyId = useRef(triggerSurveyId);
+ useEffect(() => {
+ if (previousTriggerSurveyId.current === triggerSurveyId) return;
+ previousTriggerSurveyId.current = triggerSurveyId;
+ if (node.config.to === "" && node.config.body === "") return;
+ updateConfig({ to: "", body: "" });
+ // These two were wiped out from under the user, so their errors are exactly what they need to
+ // see — no interaction required to earn them.
+ markTouched("to", "body");
+ // updateConfig/node are intentionally omitted: this reacts to the survey id changing, not to each
+ // keystroke in to/body (which would clear them mid-edit).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [triggerSurveyId]);
+
+ const emailSendToOptions: EmailSendToOption[] = useMemo(() => {
+ if (!survey || !authoringContext) return [];
+ return buildEmailSendToOptions({
+ survey,
+ teamMemberDetails: authoringContext.teamMemberDetails,
+ userEmail: authoringContext.userEmail,
+ selectedLanguageCode: DEFAULT_LANGUAGE_CODE,
+ t,
+ });
+ }, [survey, authoringContext, t]);
+
+ // Without a resolvable bound survey there is nothing meaningful to author — the recipient
+ // options and recall body both come from the survey. Point the user at the trigger instead of
+ // rendering degraded plain inputs (and the seed's placeholder values).
+ if (!survey) {
+ return (
+
+
{t("workspace.workflows.email_needs_survey")}
+ {definition?.trigger ? (
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ updateConfig({ to: value })}
+ // A picked recipient can't be un-picked, so closing the dropdown without choosing is this
+ // control's only "touched while still empty" moment.
+ onClose={() => markTouched("to")}
+ />
+
+ {/* From (read-only) */}
+
+ {/* The real deployment sender (MAIL_FROM), not `config.from` — parity with Follow-Ups, which
+ always sends from MAIL_FROM. `config.from` is a vestigial seed value, never the send sender. */}
+ {authoringContext?.mailFrom ?? node.config.from}
+
+
+ {/* Body (recall editor) */}
+ {/* The editor defaults to a 2-line min-height (48px); a 4-line body (24px line-height)
+ better matches the amount of content an email body usually holds. */}
+
+
+ {t("workspace.workflows.email_body_label")}
+
+ toEditorHtml(node.config.body)}
+ setText={(value: string) => updateConfig({ body: value })}
+ // The editor is the authority on its own emptiness: its serialized value keeps the
+ // enclosing `
` even when blank, so "had content at some point" is the only reliable
+ // signal that the user has engaged with this field.
+ onEmptyChange={handleBodyEmptyChange}
+ isInvalid={invalidFields.has("body")}
+ ariaDescribedBy={invalidFields.has("body") ? "workflow-email-body-error" : undefined}
+ firstRender={firstRender}
+ setFirstRender={setFirstRender}
+ editable={isEditable}
+ placeholder={t("workspace.workflows.email_body_placeholder")}
+ localSurvey={survey}
+ elementId={node.id}
+ selectedLanguageCode={DEFAULT_LANGUAGE_CODE}
+ />
+ {invalidFields.has("body") ? (
+
+ {t("workspace.workflows.email_body_required")}
+
+ ) : null}
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-field.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-field.tsx
new file mode 100644
index 000000000000..fedf09a733e9
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/inspector/workflow-field.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import { Label } from "@/modules/ui/components/label";
+
+/**
+ * Label + inline error pair for the inspector's config forms. The dashboard's `Form` set
+ * (`FormLabel`/`FormError`) is bound to a react-hook-form context, but the inspector has no form
+ * submit at all — validity is derived from the workflow definition and autosaved — so these mirror
+ * its visual contract (`text-red-500` label, `text-error` message) without the RHF dependency.
+ */
+
+interface WorkflowFieldLabelProps {
+ htmlFor?: string;
+ /**
+ * Set when the control can't be reached by `htmlFor` — the rich-text editor owns a
+ * contenteditable, so it points its own `aria-labelledby` at this id instead.
+ */
+ id?: string;
+ /** Renders the required marker; the accessible name carries the word, not the glyph. */
+ isRequired?: boolean;
+ isInvalid?: boolean;
+ children: ReactNode;
+}
+
+export const WorkflowFieldLabel = ({
+ htmlFor,
+ id,
+ isRequired,
+ isInvalid,
+ children,
+}: Readonly) => {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+};
+
+interface WorkflowFieldErrorProps {
+ /** Referenced by the control's `aria-describedby` so the message is announced with it. */
+ id: string;
+ children: ReactNode;
+}
+
+export const WorkflowFieldError = ({ id, children }: Readonly) => (
+
+ {children}
+
+);
diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-inspector-panel.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-inspector-panel.tsx
new file mode 100644
index 000000000000..c573d102fab7
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/inspector/workflow-inspector-panel.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { cn } from "@/lib/cn";
+import { WorkflowNodeConfigPanel } from "@/modules/ee/workflows/components/inspector/workflow-node-config-panel";
+import {
+ isWorkflowInspectorCollapsedAtom,
+ isWorkflowNodeConfigModalOpenAtom,
+} from "@/modules/ee/workflows/state/editor";
+
+interface WorkflowInspectorPanelProps {
+ isEditingNode: boolean;
+}
+
+// The inspector's only content is the selected node's config: workflow name lives in the
+// editable page title and lifecycle actions in the header dropdown, so with no node open the
+// column collapses away entirely.
+export const WorkflowInspectorPanel = ({ isEditingNode }: Readonly) => {
+ const isCollapsed = useAtomValue(isWorkflowInspectorCollapsedAtom);
+ const isNodeConfigOpen = useAtomValue(isWorkflowNodeConfigModalOpenAtom);
+ const isVisible = isNodeConfigOpen && !isCollapsed;
+
+ return (
+ // Stretches to the editor row's height (no `self-start`) so the panel inside can fill it and
+ // scroll its own content. No bottom padding either: both would make this column taller than the
+ // canvas beside it and push the page into overflow.
+
+ {/* Only mount the fixed-width content while the panel is open. Left mounted when collapsed,
+ this 360px block lays out off-screen to the right and — even inside the `w-0`
+ `overflow-hidden` column — inflates the document's scroll width, producing a phantom
+ horizontal scrollbar (most visible after collapsing the main nav). The content is already
+ hidden (opacity-0) while collapsed, so gating the mount changes nothing visible. */}
+ {isVisible && (
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx
new file mode 100644
index 000000000000..62183dd2cc7b
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useAtomValue, useSetAtom } from "jotai";
+import { useTranslation } from "react-i18next";
+import type { TWorkflowDefinition, TWorkflowNode } from "@formbricks/workflows";
+import { getNodeRegistryEntry } from "@/modules/ee/workflows/lib/node-registry";
+import {
+ selectedWorkflowNodeIdAtom,
+ setWorkflowDefinitionAtom,
+ workflowDefinitionAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { Alert, AlertDescription } from "@/modules/ui/components/alert";
+
+interface WorkflowNodeConfigPanelProps {
+ isEditable: boolean;
+}
+
+const findSelectedNode = (
+ definition: TWorkflowDefinition | null,
+ selectedNodeId: string | null
+): TWorkflowNode | null => {
+ if (!definition || !selectedNodeId) return null;
+ if (definition.trigger?.id === selectedNodeId) return definition.trigger;
+ return definition.nodes.find((node) => node.id === selectedNodeId) ?? null;
+};
+
+const replaceNode = (definition: TWorkflowDefinition, node: TWorkflowNode): TWorkflowDefinition => {
+ if (node.type === "trigger" && node.id === definition.trigger?.id) {
+ return { ...definition, trigger: node };
+ }
+
+ if (node.type === "trigger") {
+ return definition;
+ }
+
+ return {
+ ...definition,
+ nodes: definition.nodes.map((existingNode) => (existingNode.id === node.id ? node : existingNode)),
+ };
+};
+
+/**
+ * Renders inside the inspector aside (replaces the workflow-level sections while a node is being
+ * configured). Every form change writes straight into the definition atom, so the canvas node
+ * (title, summary, issue flag) and the whole-workflow validity update live; persistence is owned
+ * by the page-level autosave. The workflow Settings view is reached via the canvas cog, not a
+ * Back arrow — the two views are siblings, not a hierarchy.
+ */
+export const WorkflowNodeConfigPanel = ({ isEditable }: Readonly) => {
+ const { t } = useTranslation();
+ const definition = useAtomValue(workflowDefinitionAtom);
+ const selectedNodeId = useAtomValue(selectedWorkflowNodeIdAtom);
+ const setDefinition = useSetAtom(setWorkflowDefinitionAtom);
+
+ const selectedNode = findSelectedNode(definition, selectedNodeId);
+ if (!selectedNode || !definition) return null;
+
+ const registryEntry = getNodeRegistryEntry(selectedNode);
+ const ConfigForm = registryEntry.ConfigForm;
+
+ const handleChange = (nextNode: TWorkflowNode) => {
+ if (!isEditable) return;
+ setDefinition((currentDefinition) =>
+ currentDefinition ? replaceNode(currentDefinition, nextNode) : currentDefinition
+ );
+ };
+
+ return (
+ // Fills the editor row's height and scrolls its fields internally, so a config form longer than
+ // the row can't grow the page. A page-level scrollbar would be useless here anyway: the canvas
+ // beside it clips rather than scrolls, so scrolling the page only reveals blank space. `min-h-0`
+ // is what allows the shrink; without it the flex item would be floored at its content height.
+ // The header sits outside the scrolling area and stays put.
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx
new file mode 100644
index 000000000000..a04823ce73ec
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/inspector/workflow-trigger-form.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { useAtomValue, useSetAtom } from "jotai";
+import { useParams } from "next/navigation";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { TWorkflowResponseCompletedTriggerNode } from "@formbricks/workflows";
+import { cn } from "@/lib/cn";
+import {
+ WorkflowFieldError,
+ WorkflowFieldLabel,
+} from "@/modules/ee/workflows/components/inspector/workflow-field";
+import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus";
+import { reconcileEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards";
+import {
+ useWorkflowSurveyEndings,
+ useWorkflowSurveyOptions,
+} from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker";
+import {
+ hasBoundTriggerSurveyAtom,
+ prunedTriggerEndingCardIdsAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { Checkbox } from "@/modules/ui/components/checkbox";
+import { Label } from "@/modules/ui/components/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/modules/ui/components/select";
+
+interface WorkflowTriggerFormProps {
+ node: TWorkflowResponseCompletedTriggerNode;
+ isEditable: boolean;
+ onChange: (next: TWorkflowResponseCompletedTriggerNode) => void;
+}
+
+// "all" = empty `endingCardIds` (match any ending, including future ones); "specific" = checkbox list.
+type TEndingScope = "all" | "specific";
+
+export const WorkflowTriggerForm = ({ node, isEditable, onChange }: Readonly) => {
+ const { t } = useTranslation();
+ const params = useParams<{ workspaceId: string }>();
+ const workspaceId = params?.workspaceId ?? "";
+ const surveyOptionsQuery = useWorkflowSurveyOptions(workspaceId);
+ const triggerSurveyId = node.config.surveyId || null;
+ const endingsQuery = useWorkflowSurveyEndings(triggerSurveyId);
+ const hasBoundSurvey = useAtomValue(hasBoundTriggerSurveyAtom);
+
+ // An unbound survey restated inline, so a jump from the problems dialog lands on a control that
+ // visibly says what's wrong instead of a picker that looks fine. No "touched" gating (unlike the
+ // email step's blank fields): the stored config points at a survey that cannot be resolved, which
+ // is never a normal mid-edit state.
+ //
+ // Stale ending ids deliberately get NO inline error here: they are reconciled out of the config
+ // before this form can observe them (see useReconcileTriggerEndingCards), and the widening that
+ // prune causes is already reported by the every-ending notice below.
+ const isSurveyInvalid = !hasBoundSurvey;
+
+ // Jump target for the trigger's problems, raised by the validation problems dialog: an unbound
+ // survey goes to the picker, an ending problem to the scope select it belongs to.
+ useWorkflowNodeFieldFocus({
+ nodeId: node.id,
+ resolveElement: (field) =>
+ document.getElementById(
+ field === "endingCardIds" ? "workflow-trigger-ending-scope" : "workflow-trigger-survey"
+ ),
+ });
+
+ // Only an authority once the query has SETTLED for the current survey; null until then, so a
+ // pending fetch never reads as "no endings" and the stored ids are taken at face value.
+ const surveyEndingIds =
+ endingsQuery.isSuccess && endingsQuery.resolvedSurveyId === triggerSurveyId
+ ? endingsQuery.endings.map((ending) => ending.id)
+ : null;
+ // Open in "specific" scope when ids are set OR the builder page pruned this trigger's picks: the
+ // user chose specific endings that are now gone, so ask for a fresh pick instead of showing the
+ // widened "all endings" state. Atom (not a prop) since it's trigger-specific.
+ const prunedEndingCardIds = useAtomValue(prunedTriggerEndingCardIdsAtom);
+ const setPrunedEndingCardIds = useSetAtom(prunedTriggerEndingCardIdsAtom);
+ const [endingScope, setEndingScope] = useState(
+ node.config.endingCardIds.length > 0 || prunedEndingCardIds.length > 0 ? "specific" : "all"
+ );
+
+ const handleSurveyChange = (surveyId: string) => {
+ // Clear ending selection when survey changes — ids belong to the previous survey's endings.
+ setEndingScope("all");
+ // Pruned ids belonged to the previous survey, so the prompt they raised is moot.
+ setPrunedEndingCardIds([]);
+ onChange({
+ ...node,
+ config: { ...node.config, surveyId, endingCardIds: [] },
+ });
+ };
+
+ const handleScopeChange = (scope: TEndingScope) => {
+ setEndingScope(scope);
+ // Any deliberate scope pick answers the "your endings were pruned, choose again" prompt, so
+ // stop replaying it. Cleared for BOTH directions and unconditionally: after a full prune
+ // `endingCardIds` is already empty, so picking "all" writes nothing — leaving the atom set
+ // would make the form snap back to "specific" on its next remount and discard that choice.
+ setPrunedEndingCardIds([]);
+ if (scope === "all" && node.config.endingCardIds.length > 0) {
+ onChange({ ...node, config: { ...node.config, endingCardIds: [] } });
+ }
+ };
+
+ const toggleEnding = (endingId: string, checked: boolean) => {
+ // Reconcile before applying the click so ids from deleted endings can't ride along (that
+ // appending is what produced the phantom "trigger on 2 ending cards" after picking one).
+ const current = surveyEndingIds
+ ? reconcileEndingCardIds(node.config.endingCardIds, surveyEndingIds).endingCardIds
+ : node.config.endingCardIds;
+ const next = checked
+ ? Array.from(new Set([...current, endingId]))
+ : current.filter((id) => id !== endingId);
+ onChange({ ...node, config: { ...node.config, endingCardIds: next } });
+ };
+
+ // An empty `endingCardIds` means "every ending fires this workflow", which is a widening whenever
+ // the user's intent is — or was, before the prune — a specific set. Keyed off the pruned atom too
+ // so it still shows when the prune emptied the list and the survey has no endings left to check.
+ const showFiresOnEveryEndingNotice =
+ node.config.endingCardIds.length === 0 && (endingScope === "specific" || prunedEndingCardIds.length > 0);
+
+ const renderEndingChoices = () => {
+ if (!node.config.surveyId) {
+ return (
+
;
+ }
+ // No endings on the survey: say so in place of the checkbox list, but keep the scope select and
+ // the every-ending notice below reachable. Returning early here hid both, so a prune that
+ // emptied the selection widened the trigger to every response with nothing on screen saying so
+ // (the validity pill has nothing to flag either — the stale ids are gone by then).
+ const renderEndingSelection = () => {
+ if (endingsQuery.endings.length === 0) {
+ return
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/runs/workflow-run-detail-drawer.tsx b/apps/web/modules/ee/workflows/components/runs/workflow-run-detail-drawer.tsx
new file mode 100644
index 000000000000..ac5bcb2a8274
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/runs/workflow-run-detail-drawer.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { SquareArrowOutUpRight } from "lucide-react";
+import Link from "next/link";
+import { useTranslation } from "react-i18next";
+import { getWorkflowTriggerTypeLabel } from "@/modules/ee/workflows/lib/display";
+import { type TWorkflowRunListItem } from "@/modules/ee/workflows/types";
+import { Button } from "@/modules/ui/components/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/modules/ui/components/sheet";
+import { useWorkflowRun } from "../../hooks/use-workflow-run";
+import { RunJsonSection } from "./run-json-section";
+import { RunStepsBody } from "./run-steps-body";
+import { RunSummarySection } from "./run-summary-section";
+
+interface WorkflowRunDetailDrawerProps {
+ run: TWorkflowRunListItem | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const WorkflowRunDetailDrawer = ({
+ run,
+ open,
+ onOpenChange,
+}: Readonly) => {
+ const { t, i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
+
+ // The list row gives an instant header + summary; the full run (step logs, trigger payload, run
+ // data) is fetched on demand and only while the drawer is open.
+ const {
+ data: detail,
+ isLoading,
+ isError,
+ error,
+ } = useWorkflowRun({ runId: run?.id ?? null, enabled: open });
+
+ return (
+
+
+
+ {run?.workflowName ?? t("common.workflow_runs")}
+ {run ? getWorkflowTriggerTypeLabel(run.triggerType, t) : null}
+ {run ? (
+
+ ) : null}
+
+
+ {run ? (
+
+ {/* Prefer fetched detail once loaded so the summary can't show stale list values while
+ the step timeline below shows fresh data; fall back to the list row before it resolves. */}
+
+
+
+
{t("common.steps")}
+
+
+
+ {detail ? (
+ <>
+
+
+ >
+ ) : null}
+
+ ) : null}
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/runs/workflow-run-steps.tsx b/apps/web/modules/ee/workflows/components/runs/workflow-run-steps.tsx
new file mode 100644
index 000000000000..ba275090da03
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/runs/workflow-run-steps.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+import { getWorkflowRunLogStatusBadge } from "@/modules/ee/workflows/lib/display";
+import { type TWorkflowRunLog, formatStepDuration, hasKeys } from "@/modules/ee/workflows/lib/run-display";
+import { Badge } from "@/modules/ui/components/badge";
+import { RunJsonCode } from "./run-json-code";
+
+interface WorkflowRunStepsProps {
+ logs: TWorkflowRunLog[];
+}
+
+export const WorkflowRunSteps = ({ logs }: Readonly) => {
+ const { t } = useTranslation();
+
+ if (logs.length === 0) {
+ return
+ {/* A failed load-more keeps the already-loaded rows; surface the error inline so it
+ isn't swallowed, and let the same button retry the next page. */}
+ {isFetchNextPageError ? (
+
+ ) : null}
+
+ {
+ if (!nextOpen) {
+ setSelectedRunId(null);
+ }
+ }}
+ />
+ >
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-auto-save-indicator.tsx b/apps/web/modules/ee/workflows/components/workflow-auto-save-indicator.tsx
new file mode 100644
index 000000000000..250bbcfd9c18
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-auto-save-indicator.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import {
+ hasWorkflowSaveFailedAtom,
+ isWorkflowDirtyAtom,
+ isWorkflowSavingAtom,
+ workflowLastSavedAtAtom,
+ workflowSaveErrorAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { TooltipRenderer } from "@/modules/ui/components/tooltip";
+
+const SAVED_FLASH_MS = 3000;
+
+// Same palette as Badge's "success" / "error" / "gray" types, which is what the survey editor's
+// equivalent pill matches.
+const PILL_CLASSES = {
+ failed: "border-red-200 bg-red-100 text-red-800",
+ saved: "border-green-600 bg-green-50 text-green-800",
+ idle: "border-slate-200 bg-slate-100 text-slate-600",
+} as const;
+
+/**
+ * Autosave status pill. Reports the draft's actual state rather than the fact that autosave is
+ * armed: "Saving…" from the first keystroke until the debounced PATCH lands, then "All changes
+ * saved" — flashing green for a moment to acknowledge the save. It turns red for as long as a save
+ * is outstanding: autosaves are silent — they never toast — so the failed state is the only report
+ * the user gets, and it has to persist until a save actually lands rather than fade like a toast
+ * (ENG-1970). The caller hides the pill when autosave can't act at all (read-only, archived).
+ */
+export const WorkflowAutoSaveIndicator = () => {
+ const { t } = useTranslation();
+ const lastSavedAt = useAtomValue(workflowLastSavedAtAtom);
+ const hasFailed = useAtomValue(hasWorkflowSaveFailedAtom);
+ const saveError = useAtomValue(workflowSaveErrorAtom);
+ const isDirty = useAtomValue(isWorkflowDirtyAtom);
+ const isSaving = useAtomValue(isWorkflowSavingAtom);
+ const [showSaved, setShowSaved] = useState(false);
+
+ useEffect(() => {
+ if (!lastSavedAt) return;
+ setShowSaved(true);
+ const timer = setTimeout(() => setShowSaved(false), SAVED_FLASH_MS);
+ return () => clearTimeout(timer);
+ }, [lastSavedAt]);
+
+ // An outstanding failure outranks everything else: a save that succeeded three seconds ago is not
+ // the headline when the one after it didn't, and a draft waiting out the debounce behind a failed
+ // one has no business claiming it is on its way. Below that, dirty covers the debounce window
+ // before the request goes out, so the pill never reads "saved" while the user is still typing.
+ let state: keyof typeof PILL_CLASSES = "idle";
+ let label = t("workspace.workflows.all_changes_saved");
+ if (hasFailed) {
+ state = "failed";
+ label = t("workspace.workflows.autosave_failed");
+ } else if (isDirty || isSaving) {
+ label = t("workspace.workflows.saving_changes");
+ } else if (showSaved) {
+ state = "saved";
+ }
+
+ // A rejected draft has a reason worth quoting; an unreachable one has nothing to quote. The
+ // generic copy is deliberately not "we'll retry when you're back online": that only holds for a
+ // genuine disconnect, and the same "unreachable" bucket also catches 5xx, DNS and the mutation
+ // timeout, where no `online` event is ever coming and the promise would sit there unfulfilled
+ // (raised in review of ENG-1970).
+
+ const tooltipContent = saveError?.detail
+ ? t("workspace.workflows.autosave_failed_tooltip_rejected", { detail: saveError.detail })
+ : t("workspace.workflows.autosave_failed_tooltip");
+
+ return (
+
+ {/* A live region because this pill is the whole report: autosave never toasts, so a failure
+ that is only a colour change is a failure nobody is told about. Polite, not assertive —
+ it should not interrupt someone mid-edit. The detail rides along as screen-reader-only
+ text rather than an aria-label, so the announcement carries it too; the tooltip that
+ shows it visually is hover-only. */}
+
+ {label}
+ {hasFailed ? . {tooltipContent} : null}
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-editor-provider.tsx b/apps/web/modules/ee/workflows/components/workflow-editor-provider.tsx
new file mode 100644
index 000000000000..0317ce4c3e84
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-editor-provider.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { Provider as JotaiProvider } from "jotai";
+import type { ReactNode } from "react";
+
+interface WorkflowEditorProviderProps {
+ children: ReactNode;
+}
+
+export const WorkflowEditorProvider = ({ children }: Readonly) => (
+ {children}
+);
diff --git a/apps/web/modules/ee/workflows/components/workflow-email-authoring-context.tsx b/apps/web/modules/ee/workflows/components/workflow-email-authoring-context.tsx
new file mode 100644
index 000000000000..24f1984abd4f
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-email-authoring-context.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { type ReactNode, createContext, useContext } from "react";
+import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context";
+
+const WorkflowEmailAuthoringCtx = createContext(null);
+
+/**
+ * Provides the server-resolved survey/team/sender context to the workflow node inspector so the
+ * `send_email` form can render Follow-Ups-parity controls (recall body, recipient options) without
+ * re-fetching. Wraps the builder body; the trigger `surveyId` inside the definition atom is matched
+ * against `survey.id` by the consumer so stale (survey-switched) context degrades gracefully.
+ */
+export const WorkflowEmailAuthoringProvider = ({
+ value,
+ children,
+}: Readonly<{ value: TWorkflowEmailAuthoringContext; children: ReactNode }>) => (
+ {children}
+);
+
+/** Returns the workflow email authoring context, or `null` when rendered outside the provider. */
+export const useWorkflowEmailAuthoringContext = (): TWorkflowEmailAuthoringContext | null =>
+ useContext(WorkflowEmailAuthoringCtx);
diff --git a/apps/web/modules/ee/workflows/components/workflow-filter-dropdown.tsx b/apps/web/modules/ee/workflows/components/workflow-filter-dropdown.tsx
new file mode 100644
index 000000000000..b36b3accacfc
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-filter-dropdown.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { ChevronDownIcon } from "lucide-react";
+import { Fragment } from "react";
+import type { TWorkflowStatus } from "@formbricks/workflows";
+import { Checkbox } from "@/modules/ui/components/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/modules/ui/components/dropdown-menu";
+
+export interface TWorkflowStatusFilterOption {
+ label: string;
+ value: TWorkflowStatus;
+ /** Render a divider above this option, to set it apart from the ones before it. */
+ separatorBefore?: boolean;
+}
+
+interface WorkflowFilterDropdownProps {
+ title: string;
+ options: TWorkflowStatusFilterOption[];
+ selectedOptions: TWorkflowStatus[];
+ onToggleOption: (value: TWorkflowStatus) => void;
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const WorkflowFilterDropdown = ({
+ title,
+ options,
+ selectedOptions,
+ onToggleOption,
+ isOpen,
+ onOpenChange,
+}: Readonly) => {
+ const triggerClasses = `workflowFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
+ ${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900 hover:text-white"}`;
+
+ return (
+
+
+
+
+
+ {options.map((option) => (
+
+ {option.separatorBefore ? : null}
+ {
+ e.preventDefault();
+ onToggleOption(option.value);
+ }}>
+
+
+
{option.label}
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-header-cta.tsx b/apps/web/modules/ee/workflows/components/workflow-header-cta.tsx
new file mode 100644
index 000000000000..20c932c7d0e2
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-header-cta.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import {
+ ArchiveIcon,
+ ArchiveRestoreIcon,
+ ChevronDownIcon,
+ CirclePauseIcon,
+ CirclePlayIcon,
+ TrashIcon,
+} from "lucide-react";
+import { useRouter, useSelectedLayoutSegment } from "next/navigation";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
+import { WorkflowAutoSaveIndicator } from "@/modules/ee/workflows/components/workflow-auto-save-indicator";
+import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder";
+import { deleteWorkflow } from "@/modules/ee/workflows/lib/api-client";
+import { getWorkflowStatusBadge } from "@/modules/ee/workflows/lib/display";
+import {
+ hasWorkflowSaveFailedAtom,
+ workflowAtom,
+ workflowValidityAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { Button } from "@/modules/ui/components/button";
+import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
+import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/modules/ui/components/dropdown-menu";
+
+interface WorkflowHeaderCtaProps {
+ workflowId: string;
+ isReadOnly: boolean;
+}
+
+export const WorkflowHeaderCta = ({ workflowId, isReadOnly }: Readonly) => {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const segment = useSelectedLayoutSegment();
+ const workflow = useAtomValue(workflowAtom);
+ const validity = useAtomValue(workflowValidityAtom);
+ const hasSaveFailed = useAtomValue(hasWorkflowSaveFailedAtom);
+ const builder = useWorkflowBuilder({ workflowId, isReadOnly, loadOnMount: false });
+ const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ if (!workflow) return null;
+ // Only the edit tab gets the lifecycle controls — the runs tab is read-only. An unresolved save
+ // failure is the exception: the unmount flush can fail during the very tab switch that lands the
+ // user here, and the editor is no longer mounted to report it, so the pill has to follow them.
+ const isEditTab = segment === null;
+ if (!isEditTab && !hasSaveFailed) return null;
+
+ const isArchived = workflow.status === "archived";
+ const isActive = workflow.status === "enabled";
+ const isBusy = builder.isTransitioning || builder.isSaving || isDeleting;
+
+ const handleArchiveConfirm = async () => {
+ await builder.archive();
+ setIsArchiveModalOpen(false);
+ };
+
+ const handleDelete = async () => {
+ setIsDeleting(true);
+ try {
+ await deleteWorkflow(workflow.id);
+ toast.success(t("workspace.workflows.delete_success"));
+ router.push(`/workspaces/${workflow.workspaceId}/workflows`);
+ } catch (error) {
+ toast.error(getV3ApiErrorMessage(error, t("workspace.workflows.delete_failed")));
+ setIsDeleting(false);
+ setIsDeleteDialogOpen(false);
+ }
+ };
+
+ return (
+
+ {/* The definition is the workflow's real content: while it can't change (enabled, archived,
+ or a read-only member) the editor is effectively read-only, so say that instead of
+ advertising an autosave that has nothing to act on. An outstanding save failure still wins,
+ because name/description autosave keeps running while a workflow is enabled — a failed
+ rename would otherwise hide behind a "Read-only" pill. */}
+ {builder.canEditDefinition || hasSaveFailed ? (
+
+ ) : (
+
+ {t("workspace.workflows.read_only")}
+
+ )}
+ {/* Lifecycle as a status dropdown (same shape as the surveys list "New survey" menu): the
+ button reads the current state, the menu holds the transitions available from it. Edit tab
+ only — on the runs tab the pill above is carrying a save failure and nothing else. */}
+ {isEditTab && (
+ <>
+
+
+
+
+
+ {isArchived ? (
+ <>
+ }
+ onSelect={() => void builder.unarchive()}>
+ {t("common.unarchive")}
+
+ }
+ className="text-red-600 focus:text-red-600"
+ onSelect={() => setIsDeleteDialogOpen(true)}>
+ {t("common.delete")}
+
+ >
+ ) : (
+ <>
+ {isActive ? (
+ }
+ onSelect={() => void builder.disable()}>
+ {t("common.disable")}
+
+ ) : (
+ // Enabling requires a workflow the server would accept; the readiness hint next
+ // to the Save button says what is still missing.
+ }
+ disabled={!validity.isReady}
+ onSelect={() => void builder.enable()}>
+ {t("common.enable")}
+
+ )}
+ }
+ onSelect={() => setIsArchiveModalOpen(true)}>
+ {t("common.archive")}
+
+ >
+ )}
+
+
+
+
+ void handleDelete()}
+ isDeleting={isDeleting}
+ text={t("workspace.workflows.delete_workflow_confirmation", { name: workflow.name })}
+ />
+ >
+ )}
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-page-title.tsx b/apps/web/modules/ee/workflows/components/workflow-page-title.tsx
new file mode 100644
index 000000000000..39948ebcff20
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-page-title.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue, useSetAtom } from "jotai";
+import { usePathname, useRouter, useSearchParams, useSelectedLayoutSegment } from "next/navigation";
+import { type KeyboardEvent, useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import { WorkflowStatusPill } from "@/modules/ee/workflows/components/workflow-status-pill";
+import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder";
+import { getWorkflow } from "@/modules/ee/workflows/lib/api-client";
+import { workflowKeys } from "@/modules/ee/workflows/lib/query";
+import { setWorkflowNameAtom, workflowAtom, workflowNameAtom } from "@/modules/ee/workflows/state/editor";
+import { Skeleton } from "@/modules/ui/components/skeleton";
+
+interface WorkflowPageTitleProps {
+ workflowId: string;
+ isReadOnly: boolean;
+}
+
+// The layout renders PageHeader server-side while the workflow is still being fetched client-side.
+// Without a placeholder the h1 collapses to an empty row, so the title area reads as blank and the
+// tabs below jump once the name arrives. h-9 matches the text-3xl line box the name will occupy.
+const WorkflowPageTitleSkeleton = () => (
+
+
+
+
+);
+
+// Prefers the atom state hydrated by the builder. On a fresh load of a sub-route like /runs the
+// builder never mounts to hydrate the atom, so fetch the name directly; the query stays disabled
+// once the atom carries a name, so the builder page never double-fetches.
+//
+// On the edit tab the title doubles as the name editor: it binds to the draft atom and is
+// persisted by the page-level autosave, with Enter committing it immediately. A workflow arriving
+// from the dialog-less create flow (?new=1) gets the title focused and selected so the user names
+// it immediately.
+export const WorkflowPageTitle = ({ workflowId, isReadOnly }: Readonly) => {
+ const { t } = useTranslation();
+ const segment = useSelectedLayoutSegment();
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const workflow = useAtomValue(workflowAtom);
+ const workflowName = useAtomValue(workflowNameAtom);
+ const setWorkflowName = useSetAtom(setWorkflowNameAtom);
+ const inputRef = useRef(null);
+ const hasAutoFocusedRef = useRef(false);
+ const isNew = searchParams.get("new") === "1";
+ // Actions and atom state only — the builder page owns the load and the debounced autosave.
+ const { save } = useWorkflowBuilder({ workflowId, isReadOnly, loadOnMount: false });
+
+ // Scoped to sub-routes like /runs, where no builder mounts to hydrate the atom. On the edit tab
+ // this used to race the builder's own load: whichever landed first won, and the query usually
+ // did — painting the plain-text title, then swapping in the editable one a moment later.
+ // Waiting for the single source keeps the header still and drops a duplicate GET.
+ const { data } = useQuery({
+ queryKey: workflowKeys.detail(workflowId),
+ queryFn: ({ signal }) => getWorkflow(workflowId, signal),
+ enabled: segment !== null && !workflow?.name,
+ });
+
+ // Only the edit tab mounts the builder that hydrates (and saves) the draft name; metadata is
+ // editable in every status except archived — the same gate as canEditMetadata in the builder.
+ const isEditable = segment === null && Boolean(workflow) && !isReadOnly && workflow?.status !== "archived";
+
+ useEffect(() => {
+ if (!isNew || !isEditable || hasAutoFocusedRef.current) return;
+ hasAutoFocusedRef.current = true;
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ // Consume the one-shot flag so a reload or shared link doesn't re-select the title.
+ const nextParams = new URLSearchParams(searchParams);
+ nextParams.delete("new");
+ const query = nextParams.toString();
+ router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
+ }, [isNew, isEditable, searchParams, router, pathname]);
+
+ // Enter commits the rename instead of doing nothing: the field blurs so the title reads as
+ // settled, and the draft is flushed right away rather than waiting out the autosave debounce,
+ // which turns the header's "All changes saved" pill into the confirmation.
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // An IME (Japanese/Chinese/Korean) uses Enter to accept the highlighted candidate, so
+ // committing there would blur mid-composition and persist a half-typed name. The synthetic
+ // event doesn't carry isComposing; the native one does.
+ if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
+ event.preventDefault();
+ // The PATCH contract requires a name, so an empty field keeps focus for an in-place fix;
+ // save() is still called either way because it owns both the error and the success feedback.
+ if (workflowName.trim()) inputRef.current?.blur();
+ void save();
+ };
+
+ const resolved = workflow ?? data;
+ if (!resolved) return ;
+
+ // flex-wrap keeps the badge inline next to the name and pushes it below on narrow widths.
+ return (
+
+ {isEditable ? (
+ setWorkflowName(event.target.value)}
+ onKeyDown={handleKeyDown}
+ aria-label={t("common.workflow_name")}
+ placeholder={t("common.workflow_name")}
+ // Approximates content sizing where field-sizing is unsupported (Firefox/Safari).
+ size={Math.max(workflowName.length, 12)}
+ className={cn(
+ "-mx-2 -my-1 min-w-0 rounded-md bg-transparent px-2 py-1",
+ "text-3xl font-bold text-slate-800 placeholder:text-slate-400",
+ // Sizes to its content where supported; the max keeps long names from pushing the CTA out.
+ "[field-sizing:content] max-w-[28rem]",
+ // Dashed hover/focus box, matching the dashboard's editable title
+ // (see dashboard-page-header.tsx): slate while hovered, brand while editing.
+ "border border-dashed border-transparent transition-colors",
+ "hover:border-slate-300 focus:border-brand-dark",
+ // Same specificity means source order decides, and Tailwind emits hover last — without
+ // this the border drops back to slate when the pointer rests on a focused field.
+ "focus:hover:border-brand-dark",
+ "focus:ring-0 focus:outline-none"
+ )}
+ />
+ ) : (
+ {resolved.name}
+ )}
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-secondary-navigation.tsx b/apps/web/modules/ee/workflows/components/workflow-secondary-navigation.tsx
new file mode 100644
index 000000000000..252cdf99ed7c
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-secondary-navigation.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useSelectedLayoutSegment } from "next/navigation";
+import { useTranslation } from "react-i18next";
+import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
+
+interface WorkflowSecondaryNavigationProps {
+ workflowId: string;
+ workspaceId: string;
+}
+
+export const WorkflowSecondaryNavigation = ({
+ workflowId,
+ workspaceId,
+}: Readonly) => {
+ const { t } = useTranslation();
+ // The layout renders this nav, so the active tab is derived from the child segment
+ // (null = the builder/edit index, "runs" = the runs tab) instead of a passed-in prop.
+ const segment = useSelectedLayoutSegment();
+ const activeId = segment === "runs" ? "runs" : "builder";
+
+ const navigation = [
+ {
+ id: "builder",
+ label: t("common.editor"),
+ href: `/workspaces/${workspaceId}/workflows/${workflowId}`,
+ },
+ {
+ id: "runs",
+ label: t("common.runs"),
+ href: `/workspaces/${workspaceId}/workflows/${workflowId}/runs`,
+ },
+ ];
+
+ return ;
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-sort-dropdown.tsx b/apps/web/modules/ee/workflows/components/workflow-sort-dropdown.tsx
new file mode 100644
index 000000000000..181b17adfc1a
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-sort-dropdown.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { TFunction } from "i18next";
+import { ChevronDownIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { TWorkflowSortBy } from "@formbricks/workflows";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/modules/ui/components/dropdown-menu";
+
+interface TWorkflowSortOption {
+ label: string;
+ value: TWorkflowSortBy;
+}
+
+const getSortOptions = (t: TFunction): TWorkflowSortOption[] => [
+ { label: t("common.updated_at"), value: "updatedAt" },
+ { label: t("common.created_at"), value: "createdAt" },
+ { label: t("workspace.workflows.alphabetical"), value: "name" },
+];
+
+interface WorkflowSortDropdownProps {
+ sortBy: TWorkflowSortBy;
+ onSortChange: (value: TWorkflowSortBy) => void;
+}
+
+export const WorkflowSortDropdown = ({ sortBy, onSortChange }: Readonly) => {
+ const { t } = useTranslation();
+ const options = getSortOptions(t);
+ const activeLabel = options.find((option) => option.value === sortBy)?.label ?? "";
+
+ return (
+
+
+
+
+
+ {options.map((option) => (
+ onSortChange(option.value)}>
+
+
+
{option.label}
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-status-pill.tsx b/apps/web/modules/ee/workflows/components/workflow-status-pill.tsx
new file mode 100644
index 000000000000..800ca04acb59
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-status-pill.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+import type { TWorkflowStatus } from "@formbricks/workflows";
+import { Badge } from "@/modules/ui/components/badge";
+import { getWorkflowStatusBadge } from "../lib/display";
+
+interface WorkflowStatusPillProps {
+ status: TWorkflowStatus;
+ size?: "tiny" | "normal" | "large";
+}
+
+export const WorkflowStatusPill = ({ status, size = "tiny" }: Readonly) => {
+ const { t } = useTranslation();
+ const badge = getWorkflowStatusBadge(status, t);
+
+ return ;
+};
diff --git a/apps/web/modules/ee/workflows/components/workflow-validation-problems-dialog.tsx b/apps/web/modules/ee/workflows/components/workflow-validation-problems-dialog.tsx
new file mode 100644
index 000000000000..c4fdd81c501f
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflow-validation-problems-dialog.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useAtomValue, useSetAtom } from "jotai";
+import { ArrowRightIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import {
+ getWorkflowValidationProblemFocusTarget,
+ getWorkflowValidationProblemLocation,
+} from "@/modules/ee/workflows/lib/display";
+import {
+ type TWorkflowValidationProblem,
+ type TWorkflowValidationProblemCode,
+ requestWorkflowNodeFieldFocusAtom,
+ workflowDefinitionAtom,
+} from "@/modules/ee/workflows/state/editor";
+import { Button } from "@/modules/ui/components/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
+
+interface WorkflowValidationProblemsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ problems: TWorkflowValidationProblem[];
+}
+
+/**
+ * Lists the live validation problems behind the canvas's "N errors" indicator. Only ever opened
+ * with a non-empty list (a valid workflow renders a passive badge instead). Each problem is
+ * localized by its `code`; the exhaustive map keeps a new code from shipping without copy.
+ *
+ * A problem that resolves to a single config field is a button: it closes the dialog, opens that
+ * step's config panel, and focuses the offending field with its inline error revealed. Problems
+ * fixed elsewhere (naming the workflow, adding a trigger, connecting the flow) stay passive rows —
+ * there is nothing to focus.
+ */
+export const WorkflowValidationProblemsDialog = ({
+ open,
+ onOpenChange,
+ problems,
+}: Readonly) => {
+ const { t } = useTranslation();
+ // Read for display only: resolving each problem's `field` to the affected node's title.
+ const definition = useAtomValue(workflowDefinitionAtom);
+ const requestFieldFocus = useSetAtom(requestWorkflowNodeFieldFocusAtom);
+
+ // Inline literal t() calls so the translation-key scanner detects the keys.
+ const problemMessages: Record = {
+ name_missing: t("workspace.workflows.validation_problem_name_missing"),
+ trigger_missing: t("workspace.workflows.validation_problem_trigger_missing"),
+ trigger_survey_unbound: t("workspace.workflows.validation_problem_trigger_survey_unbound"),
+ trigger_ending_not_found: t("workspace.workflows.validation_problem_trigger_ending_not_found"),
+ trigger_not_connected: t("workspace.workflows.validation_problem_trigger_not_connected"),
+ flow_invalid: t("workspace.workflows.validation_problem_flow_invalid"),
+ step_not_executable: t("workspace.workflows.validation_problem_step_not_executable"),
+ step_incomplete: t("workspace.workflows.validation_problem_step_incomplete"),
+ definition_invalid: t("workspace.workflows.validation_problem_generic"),
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/components/workflows-empty-state.tsx b/apps/web/modules/ee/workflows/components/workflows-empty-state.tsx
new file mode 100644
index 000000000000..246dce347af5
--- /dev/null
+++ b/apps/web/modules/ee/workflows/components/workflows-empty-state.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { WorkflowIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+interface WorkflowsEmptyStateProps {
+ /** True when a search term or status filter is active (no matches) vs a genuinely empty list. */
+ filtered: boolean;
+}
+
+export const WorkflowsEmptyState = ({ filtered }: Readonly) => {
+ const { t } = useTranslation();
+
+ return (
+
+);
+
+export const WorkspaceWorkflowRunsBodyLoading = () => ;
+
+export const WorkflowBuilderBodyLoading = () => {
+ // The inspector column only exists while a node's config is open (deep-linked via ?node=), so
+ // the skeleton follows the same rule — otherwise the canvas visibly narrows/widens on load.
+ const searchParams = useSearchParams();
+ const showInspector = searchParams.has("node");
+
+ return (
+ // Same flex sizing as the loaded editor (see workflow-builder-page), so the canvas occupies the
+ // same box before and after hydration and nothing shifts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showInspector ? (
+
+ ) : null}
+
+
+ );
+};
+
+export const WorkflowRunsBodyLoading = () => ;
diff --git a/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx b/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx
new file mode 100644
index 000000000000..d2a8db9ef966
--- /dev/null
+++ b/apps/web/modules/ee/workflows/pages/workflow-builder-page.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useSetAtom } from "jotai";
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { WorkflowCanvas } from "@/modules/ee/workflows/components/canvas/workflow-canvas";
+import { WorkflowInspectorPanel } from "@/modules/ee/workflows/components/inspector/workflow-inspector-panel";
+import { WorkflowEmailAuthoringProvider } from "@/modules/ee/workflows/components/workflow-email-authoring-context";
+import { useReconcileTriggerEndingCards } from "@/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards";
+import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder";
+import { useWorkflowNodeUrlSync } from "@/modules/ee/workflows/hooks/use-workflow-node-url-sync";
+import { resolveBoundTriggerSurvey } from "@/modules/ee/workflows/lib/bound-survey";
+import { useWorkflowSurveyOptions } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker";
+import { WorkflowBuilderBodyLoading } from "@/modules/ee/workflows/loading";
+import { hasBoundTriggerSurveyAtom } from "@/modules/ee/workflows/state/editor";
+import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context";
+
+interface WorkflowBuilderPageProps {
+ workspaceId: string;
+ workflowId: string;
+ isReadOnly: boolean;
+ emailAuthoringContext: TWorkflowEmailAuthoringContext;
+}
+
+export const WorkflowBuilderPage = ({
+ workspaceId,
+ workflowId,
+ isReadOnly,
+ emailAuthoringContext,
+}: Readonly) => {
+ const { t } = useTranslation();
+ const builder = useWorkflowBuilder({ workspaceId, workflowId, isReadOnly });
+ const setHasBoundTriggerSurvey = useSetAtom(hasBoundTriggerSurveyAtom);
+ const surveyOptionsQuery = useWorkflowSurveyOptions(workspaceId);
+
+ // This page owns pushing the "does the trigger's survey resolve" fact into the shared atom the
+ // validity + canvas checks read. Two sources, so the flag flips the moment a survey is picked:
+ // the server-resolved authoring context (authoritative — catches deleted surveys) still lags a
+ // save + refresh behind the draft, so membership in the workspace survey list (the same query
+ // the trigger's picker offers choices from) vouches for a just-picked id immediately.
+ // Keyed on the inputs (not the computed boolean): hydration resets the atom to its optimistic
+ // default, and a boolean-keyed effect would skip re-syncing when the computed value happens to
+ // match its pre-hydration result.
+ const definition = builder.definition;
+ const surveyOptions = surveyOptionsQuery.options;
+ useEffect(() => {
+ const triggerSurveyId = definition?.trigger?.config.surveyId ?? null;
+ const isBound =
+ Boolean(resolveBoundTriggerSurvey(emailAuthoringContext, definition)) ||
+ (triggerSurveyId !== null && surveyOptions.some((option) => option.id === triggerSurveyId));
+ setHasBoundTriggerSurvey(isBound);
+ }, [emailAuthoringContext, definition, surveyOptions, setHasBoundTriggerSurvey]);
+
+ // Prune trigger ending-card ids whose endings were deleted. On the page (not the trigger form) so
+ // the canvas summary and enable gate stay correct without opening the inspector.
+ useReconcileTriggerEndingCards({ definition, isEditable: builder.canEditDefinition });
+
+ // Deep-link the inspected node (?node=…) once the editor is hydrated.
+ useWorkflowNodeUrlSync({ isEnabled: Boolean(builder.workflow) });
+
+ if (builder.isLoading) {
+ return ;
+ }
+
+ if (!builder.workflow) {
+ return (
+
+ );
+ }
+
+ return (
+ // Claims the height the page layout hands down (`min-h-0` so it may shrink below its content
+ // rather than push the page taller), and both columns stretch to fill it.
+
+
+ {/* Canvas and inspector share one height, derived from this row rather than computed from
+ the viewport. Each owns its own overflow: the canvas clips (it pans), the inspector
+ scrolls — so however long a config form runs, the page itself never grows. */}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/ee/workflows/pages/workflow-runs-page.tsx b/apps/web/modules/ee/workflows/pages/workflow-runs-page.tsx
new file mode 100644
index 000000000000..2b9cb49d5eed
--- /dev/null
+++ b/apps/web/modules/ee/workflows/pages/workflow-runs-page.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { WorkflowRunsTable } from "@/modules/ee/workflows/components/runs/workflow-runs-table";
+import { useWorkflowRuns } from "../hooks/use-workflow-runs";
+
+const RUNS_PER_PAGE = 20;
+
+interface WorkflowRunsPageProps {
+ workspaceId: string;
+ workflowId: string;
+}
+
+export const WorkflowRunsPage = ({ workspaceId, workflowId }: Readonly) => {
+ const {
+ runs,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ hasNextPage,
+ isFetchingNextPage,
+ isFetchNextPageError,
+ fetchNextPage,
+ } = useWorkflowRuns({ workspaceId, limit: RUNS_PER_PAGE, filters: { workflowId } });
+
+ return (
+ refetch()}
+ hasNextPage={hasNextPage}
+ isFetchingNextPage={isFetchingNextPage}
+ isFetchNextPageError={isFetchNextPageError}
+ onLoadMore={() => fetchNextPage()}
+ />
+ );
+};
diff --git a/apps/web/modules/ee/workflows/pages/workflows-list-page.tsx b/apps/web/modules/ee/workflows/pages/workflows-list-page.tsx
new file mode 100644
index 000000000000..d5171c7e94f8
--- /dev/null
+++ b/apps/web/modules/ee/workflows/pages/workflows-list-page.tsx
@@ -0,0 +1,253 @@
+"use client";
+
+import { useAutoAnimate } from "@formkit/auto-animate/react";
+import { TFunction } from "i18next";
+import { X } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { TWorkflowSortBy, TWorkflowStatus } from "@formbricks/workflows";
+import { ZWorkflowStatus } from "@formbricks/workflows";
+import { FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS } from "@/lib/localStorage";
+import { timeSince } from "@/lib/time";
+import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
+import { Button } from "@/modules/ui/components/button";
+import { CardTableHeader, CardTableRow } from "@/modules/ui/components/card-table";
+import { SearchBar } from "@/modules/ui/components/search-bar";
+import {
+ type TWorkflowStatusFilterOption,
+ WorkflowFilterDropdown,
+} from "../components/workflow-filter-dropdown";
+import { WorkflowListActions } from "../components/workflow-list-actions";
+import { WorkflowSortDropdown } from "../components/workflow-sort-dropdown";
+import { WorkflowStatusPill } from "../components/workflow-status-pill";
+import { WorkflowsEmptyState } from "../components/workflows-empty-state";
+import { useDebouncedValue } from "../hooks/use-debounced-value";
+import { useWorkflows } from "../hooks/use-workflows";
+import { computeStatusIn, parseStoredWorkflowFilters } from "../lib/list-filters";
+import { WorkflowsListBodyLoading } from "../loading";
+
+interface WorkflowsListPageProps {
+ workspaceId: string;
+ isReadOnly: boolean;
+ workflowsPerPage: number;
+}
+
+// Status filter options. Archived is set apart by a divider and unchecked by default, so archived
+// workflows stay hidden until the user explicitly opts in.
+const getStatusFilterOptions = (t: TFunction): TWorkflowStatusFilterOption[] => [
+ { label: t("common.draft"), value: "draft" },
+ { label: t("common.enabled"), value: "enabled" },
+ { label: t("common.disabled"), value: "disabled" },
+ { label: t("common.archived"), value: "archived", separatorBefore: true },
+];
+
+export const WorkflowsListPage = ({
+ workspaceId,
+ isReadOnly,
+ workflowsPerPage,
+}: Readonly) => {
+ const { t, i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
+ const [animationParent] = useAutoAnimate();
+
+ const [searchValue, setSearchValue] = useState("");
+ const debouncedSearchValue = useDebouncedValue(searchValue, 300);
+ const [selectedStatuses, setSelectedStatuses] = useState([]);
+ const [sortBy, setSortBy] = useState("updatedAt");
+ const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false);
+ const [isFilterInitialized, setIsFilterInitialized] = useState(false);
+
+ const statusIn = useMemo(() => computeStatusIn(selectedStatuses), [selectedStatuses]);
+
+ // Hydrate the toolbar filters from localStorage once on mount (mirrors the surveys list). Reading
+ // happens post-mount because localStorage is unavailable during SSR.
+ useEffect(() => {
+ if (globalThis.window === undefined) return;
+ const stored = globalThis.window.localStorage.getItem(FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS);
+ const parsed = parseStoredWorkflowFilters(stored);
+ if (stored && !parsed) {
+ globalThis.window.localStorage.removeItem(FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS);
+ } else if (parsed) {
+ setSearchValue(parsed.searchValue);
+ setSelectedStatuses(parsed.selectedStatuses);
+ setSortBy(parsed.sortBy);
+ }
+ setIsFilterInitialized(true);
+ }, []);
+
+ // Persist on change, but only after hydration so the empty defaults don't overwrite the stored
+ // value before it has been read.
+ useEffect(() => {
+ if (!isFilterInitialized || globalThis.window === undefined) return;
+ globalThis.window.localStorage.setItem(
+ FORMBRICKS_WORKFLOWS_FILTERS_KEY_LS,
+ JSON.stringify({ searchValue, selectedStatuses, sortBy })
+ );
+ }, [searchValue, selectedStatuses, sortBy, isFilterInitialized]);
+
+ const toggleStatus = (value: TWorkflowStatus) => {
+ setSelectedStatuses((prev) =>
+ prev.includes(value) ? prev.filter((status) => status !== value) : [...prev, value]
+ );
+ };
+
+ const clearFilters = () => {
+ setSelectedStatuses([]);
+ setSearchValue("");
+ };
+
+ const {
+ workflows,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ queryKey,
+ } = useWorkflows({
+ workspaceId,
+ limit: workflowsPerPage,
+ nameContains: debouncedSearchValue.trim(),
+ statusIn,
+ sortBy,
+ });
+
+ const showInitialLoading = isLoading && workflows.length === 0;
+ const hasActiveFilters = selectedStatuses.length > 0 || searchValue.length > 0;
+
+ const isListEmpty = !showInitialLoading && !isError && workflows.length === 0;
+
+ // Probe for ANY workflow including archived (the default query excludes archived) so an all-archived
+ // workspace isn't mistaken for an empty one — which would hide the toolbar and with it the archived filter.
+ const { workflows: anyWorkflows, isLoading: isProbingAnyWorkflows } = useWorkflows({
+ workspaceId,
+ limit: 1,
+ nameContains: "",
+ statusIn: [...ZWorkflowStatus.options],
+ enabled: isListEmpty,
+ });
+
+ // Mirror the surveys list: only a genuinely empty workspace hides the toolbar. If any workflow exists
+ // (even only archived ones), keep the toolbar so the filters stay reachable.
+ const isWorkspaceEmpty = isListEmpty && !isProbingAnyWorkflows && anyWorkflows.length === 0;
+
+ if (isWorkspaceEmpty) {
+ return (
+