Compare commits

..

3 Commits

Author SHA1 Message Date
Johannes 85285d1fe1 make work with blocks 2025-12-16 23:03:38 +01:00
Johannes 1ae98226ad Merge branch 'main' of https://github.com/formbricks/formbricks into feature/response-generation 2025-12-16 11:21:35 +01:00
Johannes d25dc8f85d add generate response functionality 2025-11-13 09:24:44 +01:00
298 changed files with 7669 additions and 27029 deletions
-352
View File
@@ -1,352 +0,0 @@
# Create New Question Element
Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
## Usage
When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
1. **Create the component file** `{question-type}.tsx` with this structure:
```typescript
import * as React from "react";
import { ElementHeader } from "../components/element-header";
import { useTextDirection } from "../hooks/use-text-direction";
import { cn } from "../lib/utils";
interface {QuestionType}Props {
/** Unique identifier for the element container */
elementId: string;
/** The main question or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the input/control group */
inputId: string;
/** Current value */
value?: {ValueType};
/** Callback function called when the value changes */
onChange: (value: {ValueType}) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the controls are disabled */
disabled?: boolean;
// Add question-specific props here
}
function {QuestionType}({
elementId,
headline,
description,
inputId,
value,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
// ... question-specific props
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", /* add other text content from question */],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
{/* TODO: Add your question-specific UI here */}
{/* Error message */}
{errorMessage && (
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
<span>{errorMessage}</span>
</div>
)}
</div>
);
}
export { {QuestionType} };
export type { {QuestionType}Props };
```
2. **Create the Storybook file** `{question-type}.stories.tsx`:
```typescript
import type { Decorator, Meta, StoryObj } from "@storybook/react";
import React from "react";
import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
// Styling options for the StylingPlayground story
interface StylingOptions {
// Question styling
questionHeadlineFontFamily: string;
questionHeadlineFontSize: string;
questionHeadlineFontWeight: string;
questionHeadlineColor: string;
questionDescriptionFontFamily: string;
questionDescriptionFontWeight: string;
questionDescriptionFontSize: string;
questionDescriptionColor: string;
// Add component-specific styling options here
}
type StoryProps = {QuestionType}Props & Partial<StylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/Elements/{QuestionType}",
component: {QuestionType},
parameters: {
layout: "centered",
docs: {
description: {
component: "A complete {question type} question element...",
},
},
},
tags: ["autodocs"],
argTypes: {
headline: {
control: "text",
description: "The main question text",
table: { category: "Content" },
},
description: {
control: "text",
description: "Optional description or subheader text",
table: { category: "Content" },
},
value: {
control: "object",
description: "Current value",
table: { category: "State" },
},
required: {
control: "boolean",
description: "Whether the field is required",
table: { category: "Validation" },
},
errorMessage: {
control: "text",
description: "Error message to display",
table: { category: "Validation" },
},
dir: {
control: { type: "select" },
options: ["ltr", "rtl", "auto"],
description: "Text direction for RTL support",
table: { category: "Layout" },
},
disabled: {
control: "boolean",
description: "Whether the controls are disabled",
table: { category: "State" },
},
onChange: {
action: "changed",
table: { category: "Events" },
},
// Add question-specific argTypes here
},
};
export default meta;
type Story = StoryObj<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
const args = context.args as StoryProps;
const {
questionHeadlineFontFamily,
questionHeadlineFontSize,
questionHeadlineFontWeight,
questionHeadlineColor,
questionDescriptionFontFamily,
questionDescriptionFontSize,
questionDescriptionFontWeight,
questionDescriptionColor,
// Extract component-specific styling options
} = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-question-headline-font-family": questionHeadlineFontFamily,
"--fb-question-headline-font-size": questionHeadlineFontSize,
"--fb-question-headline-font-weight": questionHeadlineFontWeight,
"--fb-question-headline-color": questionHeadlineColor,
"--fb-question-description-font-family": questionDescriptionFontFamily,
"--fb-question-description-font-size": questionDescriptionFontSize,
"--fb-question-description-font-weight": questionDescriptionFontWeight,
"--fb-question-description-color": questionDescriptionColor,
// Add component-specific CSS variables
};
return (
<div style={cssVarStyle} className="w-[600px]">
<Story />
</div>
);
};
export const StylingPlayground: Story = {
args: {
headline: "Example question?",
description: "Example description",
// Default styling values
questionHeadlineFontFamily: "system-ui, sans-serif",
questionHeadlineFontSize: "1.125rem",
questionHeadlineFontWeight: "600",
questionHeadlineColor: "#1e293b",
questionDescriptionFontFamily: "system-ui, sans-serif",
questionDescriptionFontSize: "0.875rem",
questionDescriptionFontWeight: "400",
questionDescriptionColor: "#64748b",
// Add component-specific default values
},
argTypes: {
// Question styling argTypes
questionHeadlineFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineColor: {
control: "color",
table: { category: "Question Styling" },
},
questionDescriptionFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionColor: {
control: "color",
table: { category: "Question Styling" },
},
// Add component-specific argTypes
},
decorators: [withCSSVariables],
};
export const Default: Story = {
args: {
headline: "Example question?",
// Add default props
},
};
export const WithDescription: Story = {
args: {
headline: "Example question?",
description: "Example description text",
},
};
export const Required: Story = {
args: {
headline: "Example question?",
required: true,
},
};
export const WithError: Story = {
args: {
headline: "Example question?",
errorMessage: "This field is required",
required: true,
},
};
export const Disabled: Story = {
args: {
headline: "Example question?",
disabled: true,
},
};
export const RTL: Story = {
args: {
headline: "مثال على السؤال؟",
description: "مثال على الوصف",
// Add RTL-specific props
},
};
```
3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
```css
/* Component-specific CSS variables */
--fb-{component}-{property}: {default-value};
```
4. **Export from** `packages/survey-ui/src/index.ts`:
```typescript
export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
```
## Key Requirements
- ✅ Always use `ElementHeader` component for headline/description
- ✅ Always use `useTextDirection` hook for RTL support
- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
- ✅ Always include error message display if applicable
- ✅ Always support disabled state if applicable
- ✅ Always add JSDoc comments to props interface
- ✅ Always create Storybook stories with styling playground
- ✅ Always export types from component file
- ✅ Always add to index.ts exports
## Examples
- `open-text.tsx` - Text input/textarea question (string value)
- `multi-select.tsx` - Multiple checkbox selection (string[] value)
## Checklist
When creating a new question element, verify:
- [ ] Component file created with proper structure
- [ ] Props interface with JSDoc comments for all props
- [ ] Uses `ElementHeader` component (don't duplicate header logic)
- [ ] Uses `useTextDirection` hook for RTL support
- [ ] Handles undefined/null values safely
- [ ] Storybook file created with styling playground
- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
- [ ] CSS variables added to `globals.css` if component needs custom styling
- [ ] Exported from `index.ts` with types
- [ ] TypeScript types properly exported
- [ ] Error message display included if applicable
- [ ] Disabled state supported if applicable
+2 -7
View File
@@ -9,12 +9,8 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
# BASE_PATH=
# Encryption keys
# Please set both for now, we will change this in the future
@@ -193,9 +189,8 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# Chatwoot
# CHATWOOT_BASE_URL=
# CHATWOOT_WEBSITE_TOKEN=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=
+7 -24
View File
@@ -13,12 +13,13 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
@@ -26,34 +27,16 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: apps/storybook
zip: true
-8
View File
@@ -203,14 +203,6 @@ Here are a few options:
</a>
## Thanks
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
<a id="contact-us"></a>
## 📆 Contact us
+2 -25
View File
@@ -1,11 +1,8 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* This function is used to resolve the absolute path of a package.
@@ -16,7 +13,7 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -28,25 +25,5 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
async viteFinal(config) {
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
const rootPath = resolve(__dirname, "../../../");
// Configure server to allow files from outside the storybook directory
config.server = config.server || {};
config.server.fs = {
...config.server.fs,
allow: [...(config.server.fs?.allow || []), rootPath],
};
// Configure simple alias resolution
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
"@": surveyUiPath,
};
return config;
},
};
export default config;
+15 -16
View File
@@ -1,6 +1,19 @@
import type { Preview } from "@storybook/react-vite";
import React from "react";
import "../../../packages/survey-ui/src/styles/globals.css";
import { I18nProvider } from "../../web/lingodotdev/client";
import "../../web/modules/ui/globals.css";
// Create a Storybook-specific Lingodot Dev decorator
const withLingodotDev = (Story: any) => {
return React.createElement(
I18nProvider,
{
language: "en-US",
defaultLanguage: "en-US",
} as any,
React.createElement(Story)
);
};
const preview: Preview = {
parameters: {
@@ -9,23 +22,9 @@ const preview: Preview = {
color: /(background|color)$/i,
date: /Date$/i,
},
expanded: true,
},
backgrounds: {
default: "light",
},
},
decorators: [
(Story) =>
React.createElement(
"div",
{
id: "fbjs",
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
},
React.createElement(Story)
),
],
decorators: [withLingodotDev],
};
export default preview;
+14 -16
View File
@@ -11,24 +11,22 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@storybook/addon-a11y": "10.0.8",
"@storybook/addon-links": "10.0.8",
"@storybook/addon-onboarding": "10.0.8",
"@storybook/react-vite": "10.0.8",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@tailwindcss/vite": "4.1.17",
"@typescript-eslint/parser": "8.48.0",
"@vitejs/plugin-react": "5.1.1",
"esbuild": "0.27.0",
"eslint-plugin-storybook": "10.0.8",
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15",
"prop-types": "15.8.1",
"storybook": "10.0.8",
"vite": "7.2.4",
"@storybook/addon-docs": "10.0.8"
"storybook": "9.0.15",
"vite": "6.4.1",
"@storybook/addon-docs": "9.0.15"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+3 -11
View File
@@ -1,15 +1,7 @@
/** @type {import('tailwindcss').Config} */
import surveyUi from "../../packages/survey-ui/tailwind.config";
import base from "../web/tailwind.config";
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
...surveyUi.theme?.extend,
},
},
...base,
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
};
+2 -3
View File
@@ -1,17 +1,16 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react()],
define: {
"process.env": {},
},
resolve: {
alias: {
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
"@": path.resolve(__dirname, "../web"),
},
},
});
-4
View File
@@ -37,10 +37,6 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
# Base path for the application (optional)
ARG BASE_PATH=""
ENV BASE_PATH=${BASE_PATH}
# Set the working directory
WORKDIR /app
@@ -44,7 +44,6 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
@@ -56,7 +55,6 @@ export const ProjectSettings = ({
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
publicDomain,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -227,13 +225,12 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}
@@ -5,7 +5,6 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -48,8 +47,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -65,11 +62,10 @@ const Page = async (props: ProjectSettingsPageProps) => {
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -1,7 +1,6 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
@@ -16,7 +15,6 @@ interface EnvironmentLayoutProps {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
@@ -74,7 +72,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
@@ -46,7 +46,6 @@ interface NavigationProps {
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
@@ -57,7 +56,6 @@ export const MainNavigation = ({
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -185,7 +183,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -288,16 +286,15 @@ export const MainNavigation = ({
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
@@ -3,8 +3,13 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { createResponseWithQuotaEvaluation } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -17,6 +22,29 @@ import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customizat
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const loremIpsumSentences = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.",
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit.",
"Nisi ut aliquip ex ea commodo consequat.",
"Pellentesque habitant morbi tristique senectus et netus et malesuada fames.",
"Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante.",
"Donec eu libero sit amet quam egestas semper.",
"Aenean ultricies mi vitae est. Mauris placerat eleifend leo.",
];
function generateLoremIpsum(): string {
const sentenceCount = Math.floor(Math.random() * 3) + 1;
const selectedSentences: string[] = [];
for (let i = 0; i < sentenceCount; i++) {
const randomIndex = Math.floor(Math.random() * loremIpsumSentences.length);
selectedSentences.push(loremIpsumSentences[randomIndex]);
}
return selectedSentences.join(" ");
}
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
});
@@ -260,3 +288,169 @@ export const updateSingleUseLinksAction = authenticatedActionClient
return updatedSurvey;
});
const ZGenerateTestResponsesAction = z.object({
surveyId: ZId,
environmentId: ZId,
});
export const generateTestResponsesAction = authenticatedActionClient
.schema(ZGenerateTestResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
if (survey.environmentId !== parsedInput.environmentId) {
throw new OperationNotAllowedError("Survey does not belong to the specified environment");
}
const supportedElementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.Matrix,
];
// Extract elements from blocks
const elements = getElementsFromBlocks(survey.blocks);
const supportedElements = elements.filter((element) => supportedElementTypes.includes(element.type));
if (supportedElements.length === 0) {
throw new OperationNotAllowedError(
"Survey does not contain any supported question types (OpenText, NPS, Rating, Multiple Choice, Picture Selection, Ranking, or Matrix)"
);
}
const responsesToCreate = 5;
const createdResponses: string[] = [];
for (let i = 0; i < responsesToCreate; i++) {
const responseData: Record<string, string | number | string[] | Record<string, string>> = {};
for (const element of supportedElements) {
if (element.type === TSurveyElementTypeEnum.OpenText) {
responseData[element.id] = generateLoremIpsum();
} else if (element.type === TSurveyElementTypeEnum.NPS) {
responseData[element.id] = Math.floor(Math.random() * 11);
} else if (element.type === TSurveyElementTypeEnum.Rating) {
const range = "range" in element && typeof element.range === "number" ? element.range : 5;
responseData[element.id] = Math.floor(Math.random() * range) + 1;
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
// Single choice: pick one random option, store the label
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const randomIndex = Math.floor(Math.random() * element.choices.length);
const selectedChoice = element.choices[randomIndex];
// For "other" option, generate custom text; otherwise use the choice label
responseData[element.id] =
selectedChoice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(selectedChoice.label, "default");
}
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
// Multi choice: pick 1-3 random options, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.PictureSelection) {
// Picture selection: single or multi based on allowMulti
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const allowMulti = "allowMulti" in element ? element.allowMulti : false;
if (allowMulti) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => choice.id);
} else {
const randomIndex = Math.floor(Math.random() * element.choices.length);
responseData[element.id] = element.choices[randomIndex].id;
}
}
} else if (element.type === TSurveyElementTypeEnum.Ranking) {
// Ranking: all options in random order, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.Matrix) {
// Matrix: for each row, pick a random column
if (
"rows" in element &&
"columns" in element &&
Array.isArray(element.rows) &&
Array.isArray(element.columns) &&
element.rows.length > 0 &&
element.columns.length > 0
) {
const matrixData: Record<string, string> = {};
for (const row of element.rows) {
const randomColumnIndex = Math.floor(Math.random() * element.columns.length);
matrixData[row.id] = element.columns[randomColumnIndex].id;
}
responseData[element.id] = matrixData;
}
}
}
const responseInput: TResponseInput = {
environmentId: parsedInput.environmentId,
surveyId: parsedInput.surveyId,
finished: true,
data: responseData,
meta: {
source: "test",
userAgent: {
browser: "Test Generator",
device: "desktop",
os: "Test OS",
},
},
};
try {
const response = await createResponseWithQuotaEvaluation(responseInput);
createdResponses.push(response.id);
} catch (error) {
throw new UnknownError(
`Failed to create response: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
return {
success: true,
createdCount: createdResponses.length,
};
});
@@ -1,6 +1,6 @@
"use client";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, Sparkles, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -20,7 +20,7 @@ import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/action
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
import { generateTestResponsesAction, resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -63,6 +63,7 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isGeneratingResponses, setIsGeneratingResponses] = useState(false);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
@@ -147,6 +148,23 @@ export const SurveyAnalysisCTA = ({
setIsResetModalOpen(false);
};
const handleGenerateTestResponses = async () => {
if (isGeneratingResponses) return;
setIsGeneratingResponses(true);
const result = await generateTestResponsesAction({
surveyId: survey.id,
environmentId: environment.id,
});
if (result?.data?.success) {
toast.success(`Successfully generated ${result.data.createdCount} test responses`);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsGeneratingResponses(false);
};
const iconActions = [
{
icon: BellRing,
@@ -163,6 +181,12 @@ export const SurveyAnalysisCTA = ({
},
isVisible: survey.type === "link",
},
{
icon: Sparkles,
tooltip: isGeneratingResponses ? "Generating responses..." : "Generate test responses",
onClick: handleGenerateTestResponses,
isVisible: !isReadOnly,
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
+2 -11
View File
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth";
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -19,15 +18,7 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
{IS_CHATWOOT_CONFIGURED && (
<ChatwootWidget
userEmail={user?.email}
userName={user?.name}
userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
/>
)}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
+2
View File
@@ -1,9 +1,11 @@
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper />
{children}
</>
);
-97
View File
@@ -1,97 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
chatwootWebsiteToken?: string;
userEmail?: string | null;
userName?: string | null;
userId?: string | null;
}
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
export const ChatwootWidget = ({
userEmail,
userName,
userId,
chatwootWebsiteToken,
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const setUserInfo = useCallback(() => {
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
name: userName,
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
if (existingScript) return;
const script = document.createElement("script");
script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
script.id = CHATWOOT_SCRIPT_ID;
script.async = true;
script.onload = () => {
(
globalThis as unknown as {
chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
}
).chatwootSDK?.run({
websiteToken: chatwootWebsiteToken,
baseUrl: chatwootBaseUrl,
});
};
document.head.appendChild(script);
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
scriptElement?.remove();
userSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};
+67
View File
@@ -0,0 +1,67 @@
"use client";
import Intercom from "@intercom/messenger-js-sdk";
import { useCallback, useEffect } from "react";
import { TUser } from "@formbricks/types/user";
interface IntercomClientProps {
isIntercomConfigured: boolean;
intercomUserHash?: string;
user?: TUser | null;
intercomAppId?: string;
}
export const IntercomClient = ({
user,
intercomUserHash,
isIntercomConfigured,
intercomAppId,
}: IntercomClientProps) => {
const initializeIntercom = useCallback(() => {
let initParams = {};
if (user && intercomUserHash) {
const { id, name, email, createdAt } = user;
initParams = {
user_id: id,
user_hash: intercomUserHash,
name,
email,
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
};
}
Intercom({
app_id: intercomAppId!,
...initParams,
});
}, [user, intercomUserHash, intercomAppId]);
useEffect(() => {
try {
if (isIntercomConfigured) {
if (!intercomAppId) {
throw new Error("Intercom app ID is required");
}
if (user && !intercomUserHash) {
throw new Error("Intercom user hash is required");
}
initializeIntercom();
}
return () => {
// Shutdown Intercom when component unmounts
if (typeof window !== "undefined" && window.Intercom) {
window.Intercom("shutdown");
}
};
} catch (error) {
console.error("Failed to initialize Intercom:", error);
}
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
return null;
};
@@ -0,0 +1,26 @@
import { createHmac } from "crypto";
import type { TUser } from "@formbricks/types/user";
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
import { IntercomClient } from "./IntercomClient";
interface IntercomClientWrapperProps {
user?: TUser | null;
}
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
let intercomUserHash: string | undefined;
if (user) {
const secretKey = INTERCOM_SECRET_KEY;
if (secretKey) {
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
}
}
return (
<IntercomClient
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
user={user}
intercomAppId={INTERCOM_APP_ID}
intercomUserHash={intercomUserHash}
/>
);
};
+1 -2
View File
@@ -18,8 +18,7 @@
"zh-Hant-TW",
"nl-NL",
"es-ES",
"sv-SE",
"ru-RU"
"sv-SE"
]
},
"version": 1.8
+8 -13
View File
@@ -446,12 +446,14 @@ checksums:
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 80763c6e4585cd57fa58e4d2d82e6500
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
emails/invite_accepted_email_text: 48d792826ab9a97eed27599c17ec70d5
emails/invite_accepted_email_text_par1: b27eadc4779c9fa477103d136a6acab9
emails/invite_accepted_email_text_par2: c77209b510baf0415264fdb5ab8076a8
emails/invite_email_button_label: 02099d40cd11e717c0431fa43e68272c
emails/invite_email_heading: d9f9b18e4de575980de3cde3e4ed08bf
emails/invite_email_text: 1499fa615105121a133440929b039a64
emails/invite_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_email_text_par1: 70b976a3d4a5509f6d905f9f3f962ada
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
@@ -1099,13 +1101,6 @@ checksums:
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/security_updates_description: 17c49b565a7dde28b810f67af2e8db07
environments/settings/teams/security_updates_enroll: edcc8815899ece9209ce981c26c44df3
environments/settings/teams/security_updates_enrolled: 98863ec2d846b7a13ff1ed38ce1038fe
environments/settings/teams/security_updates_enrolled_description: d9c7605767af8f4d7265cba7dfba5f11
environments/settings/teams/security_updates_enrolled_successfully: 3bbb41fac1c04effec3af8ffbd8b72c5
environments/settings/teams/security_updates_enrolling: 15ca7daa32fb57e18a0a6357de26eb4b
environments/settings/teams/security_updates_title: 2f5f5f55bb9a325b5c8228bcad4f2784
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
@@ -1915,9 +1910,9 @@ checksums:
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
+3 -4
View File
@@ -177,7 +177,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"zh-Hans-CN",
"es-ES",
"sv-SE",
"ru-RU",
];
// Billing constants
@@ -216,9 +215,9 @@ export const BILLING_LIMITS = {
},
} as const;
export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
+4 -6
View File
@@ -39,12 +39,11 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.string().url().optional(),
INTERCOM_SECRET_KEY: z.string().optional(),
INTERCOM_APP_ID: z.string().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -163,16 +162,15 @@ export const env = createEnv({
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
-12
View File
@@ -141,7 +141,6 @@ export const appLanguages = [
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
"ru-RU": "Английский (США)",
},
},
{
@@ -159,7 +158,6 @@ export const appLanguages = [
"nl-NL": "Duits",
"es-ES": "Alemán",
"sv-SE": "Tyska",
"ru-RU": "Немецкий",
},
},
{
@@ -177,7 +175,6 @@ export const appLanguages = [
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
"ru-RU": "Португальский (Бразилия)",
},
},
{
@@ -195,7 +192,6 @@ export const appLanguages = [
"nl-NL": "Frans",
"es-ES": "Francés",
"sv-SE": "Franska",
"ru-RU": "Французский",
},
},
{
@@ -213,7 +209,6 @@ export const appLanguages = [
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
"sv-SE": "Kinesiska (traditionell)",
"ru-RU": "Китайский (традиционный)",
},
},
{
@@ -231,7 +226,6 @@ export const appLanguages = [
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
"ru-RU": "Португальский (Португалия)",
},
},
{
@@ -249,7 +243,6 @@ export const appLanguages = [
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"sv-SE": "Rumänska",
"ru-RU": "Румынский",
},
},
{
@@ -267,7 +260,6 @@ export const appLanguages = [
"nl-NL": "Japans",
"es-ES": "Japonés",
"sv-SE": "Japanska",
"ru-RU": "Японский",
},
},
{
@@ -285,7 +277,6 @@ export const appLanguages = [
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
"ru-RU": "Китайский (упрощенный)",
},
},
{
@@ -303,7 +294,6 @@ export const appLanguages = [
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"sv-SE": "Nederländska",
"ru-RU": "Голландский",
},
},
{
@@ -321,7 +311,6 @@ export const appLanguages = [
"nl-NL": "Spaans",
"es-ES": "Español",
"sv-SE": "Spanska",
"ru-RU": "Испанский",
},
},
{
@@ -339,7 +328,6 @@ export const appLanguages = [
"nl-NL": "Zweeds",
"es-ES": "Sueco",
"sv-SE": "Svenska",
"ru-RU": "Шведский",
},
},
];
+1 -3
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -107,8 +107,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return zhCN;
case "es-ES":
return es;
case "ru-RU":
return ru;
}
};
-8
View File
@@ -61,9 +61,6 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://www.vimeo.com/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
expect(convertToEmbedUrl("https://player.vimeo.com/video/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
});
test("converts Loom URL to embed URL", () => {
@@ -73,9 +70,6 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://loom.com/share/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
expect(convertToEmbedUrl("https://www.loom.com/embed/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
});
test("returns undefined for unsupported URLs", () => {
@@ -115,7 +109,6 @@ describe("extractVimeoId", () => {
test("extracts video ID from Vimeo URLs", () => {
expect(extractVimeoId("https://vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://www.vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://player.vimeo.com/video/123456789")).toBe("123456789");
});
test("returns null for invalid Vimeo URLs", () => {
@@ -128,7 +121,6 @@ describe("extractLoomId", () => {
test("extracts video ID from Loom URLs", () => {
expect(extractLoomId("https://loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/embed/abcdef123456")).toBe("abcdef123456");
});
test("returns null for invalid Loom URLs", async () => {
+3 -3
View File
@@ -26,7 +26,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
@@ -74,7 +74,7 @@ export const extractYoutubeId = (url: string): string | null => {
};
export const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
const regExp = /vimeo\.com\/(\d+)/;
const match = regExp.exec(url);
if (match?.[1]) {
@@ -85,7 +85,7 @@ export const extractVimeoId = (url: string): string | null => {
};
export const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/;
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
const match = regExp.exec(url);
if (match?.[1]) {
+7 -12
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"hidden_field": "Verstecktes Feld",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey {inviterName}",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
"invite_accepted_email_text": "Nur zur Info: {inviteeName} hat deine Einladung angenommen. Viel Spaß bei der Zusammenarbeit!",
"invite_accepted_email_text_par1": "Wollte dir nur Bescheid geben, dass",
"invite_accepted_email_text_par2": "deine Einladung angenommen hat. Viel Spaß bei der Zusammenarbeit!",
"invite_email_button_label": "Organisation beitreten",
"invite_email_heading": "Hey {inviteeName}",
"invite_email_text": "Dein Kollege {inviterName} hat dich eingeladen, bei Formbricks mitzumachen. Um die Einladung anzunehmen, klicke bitte auf den Link unten:",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Dein Kollege",
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
"number_variable": "Zahlenvariable",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"security_updates_description": "Melden Sie sich für unsere Sicherheits-Mailingliste an, um informiert zu bleiben, falls Sicherheitslücken gefunden werden.",
"security_updates_enroll": "Jetzt anmelden",
"security_updates_enrolled": "Angemeldet",
"security_updates_enrolled_description": "Sie sind angemeldet, um Sicherheitsupdates unter {email} zu erhalten.",
"security_updates_enrolled_successfully": "Erfolgreich für Sicherheitsupdates angemeldet!",
"security_updates_enrolling": "Wird angemeldet...",
"security_updates_title": "Sicherheitsupdates",
"select_member": "Mitglied auswählen",
"select_project": "Projekt auswählen",
"team_admin": "Team-Admin",
@@ -2053,7 +2048,7 @@
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der <b>am schnellsten wachsenden Open-Source-Umfrageplattform</b> weltweit basiert.",
"paragraph_2": "Führe gezielte Umfragen auf Websites, in Apps oder überall online durch. Sammle wertvolle Insights, um unwiderstehliche Erlebnisse für Kunden, Nutzer und Mitarbeiter zu gestalten.",
"paragraph_3": "Wir verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die <b>volle Kontrolle über Ihre Daten</b> zu behalten.",
"paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um <b>volle Kontrolle über deine Daten</b> zu behalten.",
"welcome_to_formbricks": "Willkommen bei Formbricks!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"hidden_field": "Hidden field",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey {inviterName}",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "You've got a new organization member!",
"invite_accepted_email_text": "Just letting you know that {inviteeName} accepted your invitation. Have fun collaborating!",
"invite_accepted_email_text_par1": "Just letting you know that",
"invite_accepted_email_text_par2": "accepted your invitation. Have fun collaborating!",
"invite_email_button_label": "Join organization",
"invite_email_heading": "Hey {inviteeName}",
"invite_email_text": "Your colleague {inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Your colleague",
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"new_email_verification_text": "To verify your new email address, please click the button below:",
"number_variable": "Number variable",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Please fill all the fields to add a new project.",
"read": "Read",
"read_write": "Read & Write",
"security_updates_description": "Enroll to our Security Mailing List to stay informed if vulnerabilities are found.",
"security_updates_enroll": "Enroll now",
"security_updates_enrolled": "Enrolled",
"security_updates_enrolled_description": "You're enrolled to receive security updates at {email}.",
"security_updates_enrolled_successfully": "Successfully enrolled for security updates!",
"security_updates_enrolling": "Enrolling...",
"security_updates_title": "Security Updates",
"select_member": "Select member",
"select_project": "Select project",
"team_admin": "Team Admin",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Get started",
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
"paragraph_1": "Formbricks is an Experience Management Suite built on the <b>fastest growing open-source survey platform</b> worldwide.",
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
"paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"welcome_to_formbricks": "Welcome to Formbricks!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
"hidden_field": "Campo oculto",
"imprint": "Aviso legal",
"invite_accepted_email_heading": "Hola, {inviterName}",
"invite_accepted_email_heading": "Hola",
"invite_accepted_email_subject": "¡Tienes un nuevo miembro en la organización!",
"invite_accepted_email_text": "Te informamos que {inviteeName} ha aceptado tu invitación. ¡Que disfrutéis colaborando!",
"invite_accepted_email_text_par1": "Solo para informarte que",
"invite_accepted_email_text_par2": "ha aceptado tu invitación. ¡Diviértete colaborando!",
"invite_email_button_label": "Unirse a la organización",
"invite_email_heading": "Hola, {inviteeName}",
"invite_email_text": "Tu compañero {inviterName} te ha invitado a unirte a Formbricks. Para aceptar la invitación, haz clic en el enlace que aparece a continuación:",
"invite_email_heading": "Hola",
"invite_email_text_par1": "Tu colega",
"invite_email_text_par2": "te ha invitado a unirte a Formbricks. Para aceptar la invitación, por favor haz clic en el enlace a continuación:",
"invite_member_email_subject": "¡Estás invitado a colaborar en Formbricks!",
"new_email_verification_text": "Para verificar tu nueva dirección de correo electrónico, por favor haz clic en el botón a continuación:",
"number_variable": "Variable numérica",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"security_updates_description": "Inscríbete en nuestra lista de correo de seguridad para mantenerte informado si se encuentran vulnerabilidades.",
"security_updates_enroll": "Inscribirse ahora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Estás inscrito para recibir actualizaciones de seguridad en {email}.",
"security_updates_enrolled_successfully": "Te has inscrito correctamente para recibir actualizaciones de seguridad.",
"security_updates_enrolling": "Inscribiendo...",
"security_updates_title": "Actualizaciones de seguridad",
"select_member": "Seleccionar miembro",
"select_project": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Comenzar",
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
"paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> a nivel mundial.",
"paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> en todo el mundo.",
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para <b>crear experiencias irresistibles</b> para clientes, usuarios y empleados.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el <b>control total sobre tus datos</b>.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener <b>control total sobre tus datos</b>.",
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut {inviterName}",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
"invite_accepted_email_text": "Juste pour te faire savoir que {inviteeName} a accepté ton invitation. Amusez-vous bien à collaborer!",
"invite_accepted_email_text_par1": "Je te fais savoir que",
"invite_accepted_email_text_par2": "accepté votre invitation. Amusez-vous bien à collaborer !",
"invite_email_button_label": "Rejoindre l'organisation",
"invite_email_heading": "Salut {inviteeName}",
"invite_email_text": "Ton collègue {inviterName} t'a invité à le rejoindre sur Formbricks. Pour accepter l'invitation, clique sur le lien ci-dessous:",
"invite_email_heading": "Salut",
"invite_email_text_par1": "Votre collègue",
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
"number_variable": "Variable numérique",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.",
"read": "Lire",
"read_write": "Lire et Écrire",
"security_updates_description": "Inscrivez-vous à notre liste de diffusion sécurité pour être informé si des vulnérabilités sont découvertes.",
"security_updates_enroll": "S'inscrire maintenant",
"security_updates_enrolled": "Inscrit",
"security_updates_enrolled_description": "Vous êtes inscrit pour recevoir les mises à jour de sécurité à {email}.",
"security_updates_enrolled_successfully": "Inscription aux mises à jour de sécurité réussie!",
"security_updates_enrolling": "Inscription en cours...",
"security_updates_title": "Mises à jour de sécurité",
"select_member": "Sélectionner membre",
"select_project": "Sélectionner projet",
"team_admin": "Administrateur d'équipe",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Commencer",
"made_with_love_in_kiel": "Fabriqué avec 🤍 en Allemagne",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme de sondage open-source à la croissance la plus rapide</b> au monde.",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme d'enquête open source à la croissance la plus rapide</b> au monde.",
"paragraph_2": "Réalisez des enquêtes ciblées sur des sites web, dans des applications ou partout en ligne. Collectez des informations précieuses pour <b>créer des expériences irrésistibles</b> pour les clients, les utilisateurs et les employés.",
"paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total de vos données</b>.",
"paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total sur vos données</b>. Toujours.",
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
"imprint": "企業情報",
"invite_accepted_email_heading": "{inviterName}さん",
"invite_accepted_email_heading": "こんにちは",
"invite_accepted_email_subject": "新しい組織メンバーが加わりました!",
"invite_accepted_email_text": "{inviteeName}さんがあなたの招待を承認しました。コラボレーションをお楽しみください!",
"invite_accepted_email_text_par1": "お知らせですが、",
"invite_accepted_email_text_par2": "があなたの招待を承認しました。コラボレーションを楽しんでください!",
"invite_email_button_label": "組織に参加",
"invite_email_heading": "{inviteeName}さん",
"invite_email_text": "同僚の{inviterName}さんがFormbricksへの参加を招待しています。招待を承認するには、以下のリンクをクリックしてください:",
"invite_email_heading": "こんにちは",
"invite_email_text_par1": "あなたの同僚の",
"invite_email_text_par2": "が、Formbricksへの参加をあなたに招待しました。招待を承認するには、以下のリンクをクリックしてください。",
"invite_member_email_subject": "Formbricksでのコラボレーションに招待されました!",
"new_email_verification_text": "新しいメールアドレスを認証するには、以下のボタンをクリックしてください。",
"number_variable": "数値変数",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。",
"read": "読み取り",
"read_write": "読み書き",
"security_updates_description": "脆弱性が発見された際に通知を受け取るため、セキュリティメーリングリストに登録してください。",
"security_updates_enroll": "今すぐ登録",
"security_updates_enrolled": "登録済み",
"security_updates_enrolled_description": "{email}でセキュリティアップデートを受信するよう登録されています。",
"security_updates_enrolled_successfully": "セキュリティアップデートの登録が完了しました",
"security_updates_enrolling": "登録中...",
"security_updates_title": "セキュリティアップデート",
"select_member": "メンバーを選択",
"select_project": "プロジェクトを選択",
"team_admin": "チーム管理者",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "始める",
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
"paragraph_1": "Formbricksは、世界で<b>最も急成長しているオープンソースのアンケートプラットフォーム</b>をベースに構築されたエクスペリエンス管理スイートです。",
"paragraph_1": "Formbricksは、世界で<b>最も急速に成長しているオープンソースのフォームプラットフォーム</b>から構築されたエクスペリエンス管理スイートです。",
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの<b>魅力的な体験</b>を作り出します。",
"paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、<b>データを完全に管理</b>できます。",
"paragraph_3": "私たちは最高のデータプライバシーを約束します。セルフホストして、<b>データを完全に制御</b>できます。",
"welcome_to_formbricks": "Formbricksへようこそ!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hé {inviterName}",
"invite_accepted_email_heading": "Hoi",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
"invite_accepted_email_text": "We wilden je even laten weten dat {inviteeName} je uitnodiging heeft geaccepteerd. Veel plezier met samenwerken!",
"invite_accepted_email_text_par1": "Laat het je gewoon weten",
"invite_accepted_email_text_par2": "heeft uw uitnodiging geaccepteerd. Veel plezier met samenwerken!",
"invite_email_button_label": "Sluit je aan bij de organisatie",
"invite_email_heading": "Hé {inviteeName}",
"invite_email_text": "Je collega {inviterName} heeft je uitgenodigd om samen te werken bij Formbricks. Klik op onderstaande link om de uitnodiging te accepteren:",
"invite_email_heading": "Hoi",
"invite_email_text_par1": "Jouw collega",
"invite_email_text_par2": "nodigde je uit om je bij Formbricks aan te sluiten. Om de uitnodiging te accepteren, klikt u op de onderstaande link:",
"invite_member_email_subject": "Je bent uitgenodigd om samen te werken aan Formbricks!",
"new_email_verification_text": "Om uw nieuwe e-mailadres te verifiëren, klikt u op de onderstaande knop:",
"number_variable": "Numerieke variabele",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Vul alle velden in om een nieuw project toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"security_updates_description": "Schrijf je in voor onze beveiligingsmailinglijst om op de hoogte te blijven als er kwetsbaarheden worden gevonden.",
"security_updates_enroll": "Nu inschrijven",
"security_updates_enrolled": "Ingeschreven",
"security_updates_enrolled_description": "Je bent ingeschreven om beveiligingsupdates te ontvangen op {email}.",
"security_updates_enrolled_successfully": "Succesvol ingeschreven voor beveiligingsupdates!",
"security_updates_enrolling": "Bezig met inschrijven...",
"security_updates_title": "Beveiligingsupdates",
"select_member": "Selecteer lid",
"select_project": "Selecteer project",
"team_admin": "Teambeheerder",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Ga aan de slag",
"made_with_love_in_kiel": "Gemaakt met 🤍 in Duitsland",
"paragraph_1": "Formbricks is een Experience Management Suite gebouwd op het <b>snelst groeiende open-source enquêteplatform</b> wereldwijd.",
"paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het <b>snelst groeiende open source enquêteplatform</b> wereldwijd.",
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om <b>onweerstaanbare ervaringen te creëren</b> voor klanten, gebruikers en medewerkers.",
"paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om <b>volledige controle over je gegevens</b> te behouden.",
"paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om <b>volledige controle over uw gegevens</b> te behouden.",
"welcome_to_formbricks": "Welkom bij Formbricks!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressum",
"invite_accepted_email_heading": "Olá, {inviterName}",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
"invite_accepted_email_text": "Só para você saber que {inviteeName} aceitou seu convite. Divirta-se colaborando!",
"invite_accepted_email_text_par1": "Só pra te avisar que",
"invite_accepted_email_text_par2": "aceitou seu convite. Divirta-se colaborando!",
"invite_email_button_label": "Entrar na organização",
"invite_email_heading": "Olá, {inviteeName}",
"invite_email_text": "Seu colega {inviterName} convidou você para se juntar a ele no Formbricks. Para aceitar o convite, clique no link abaixo:",
"invite_email_heading": "E aí",
"invite_email_text_par1": "Seu colega",
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
"number_variable": "Variável numérica",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"security_updates_description": "Inscreva-se na nossa lista de e-mails de segurança para ser informado caso vulnerabilidades sejam encontradas.",
"security_updates_enroll": "Inscrever-se agora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Você está inscrito para receber atualizações de segurança em {email}.",
"security_updates_enrolled_successfully": "Inscrito com sucesso para atualizações de segurança!",
"security_updates_enrolling": "Inscrevendo...",
"security_updates_title": "Atualizações de segurança",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da equipe",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída sobre a <b>plataforma de pesquisa de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no mundo.",
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>.",
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressão",
"invite_accepted_email_heading": "Olá {inviterName}",
"invite_accepted_email_heading": "Olá",
"invite_accepted_email_subject": "Tem um novo membro na organização!",
"invite_accepted_email_text": "Só para informar que {inviteeName} aceitou o teu convite. Divirtam-se a colaborar!",
"invite_accepted_email_text_par1": "Só para te informar que",
"invite_accepted_email_text_par2": "aceitou o seu convite. Divirta-se a colaborar!",
"invite_email_button_label": "Junte-se à organização",
"invite_email_heading": "Olá {inviteeName}",
"invite_email_text": "O teu colega {inviterName} convidou-te para te juntares a ele no Formbricks. Para aceitar o convite, clica na ligação abaixo:",
"invite_email_heading": "Olá",
"invite_email_text_par1": "O seu colega",
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
"number_variable": "Variável numérica",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Ler",
"read_write": "Ler e Escrever",
"security_updates_description": "Inscreva-se na nossa lista de correio de segurança para se manter informado caso sejam encontradas vulnerabilidades.",
"security_updates_enroll": "Inscrever agora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Está inscrito para receber atualizações de segurança em {email}.",
"security_updates_enrolled_successfully": "Inscrito com sucesso para atualizações de segurança!",
"security_updates_enrolling": "A inscrever...",
"security_updates_title": "Atualizações de segurança",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da Equipa",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 na Alemanha",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos open-source de crescimento mais rápido</b> a nível mundial.",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para <b>criar experiências irresistíveis</b> para clientes, utilizadores e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter <b>controlo total sobre os seus dados</b>.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter <b>controlo total sobre os seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
"hidden_field": "Câmp ascuns",
"imprint": "Amprentă",
"invite_accepted_email_heading": "Salut, {inviterName}",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Ai un nou membru în organizație!",
"invite_accepted_email_text": "Vrem doar să te anunțăm {inviteeName} a acceptat invitația ta. Spor la colaborare!",
"invite_accepted_email_text_par1": "Doar te anunț că",
"invite_accepted_email_text_par2": "a acceptat invitația ta. Distracție plăcută colaborând!",
"invite_email_button_label": "Alătură-te organizației",
"invite_email_heading": "Salut, {inviteeName}",
"invite_email_text": "Colegul tău, {inviterName}, te-a invitat să i te alături pe Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_email_heading": "Hei",
"invite_email_text_par1": "Colegul tău",
"invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!",
"new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:",
"number_variable": "Variabilă numerică",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.",
"read": "Citește",
"read_write": "Citire & Scriere",
"security_updates_description": "Înscrie-te la lista noastră de e-mailuri de securitate pentru a fi informat dacă sunt descoperite vulnerabilități.",
"security_updates_enroll": "Înscrie-te acum",
"security_updates_enrolled": "Înscris",
"security_updates_enrolled_description": "Ești înscris pentru a primi actualizări de securitate la {email}.",
"security_updates_enrolled_successfully": "Înscriere reușită pentru actualizările de securitate!",
"security_updates_enrolling": "Se înscrie...",
"security_updates_title": "Actualizări de securitate",
"select_member": "Selectează membrul",
"select_project": "Selectează proiectul",
"team_admin": "Administrator Echipe",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Începeți",
"made_with_love_in_kiel": "Creat cu 🤍 în Germania",
"paragraph_1": "Formbricks este o suită de management al experienței construită pe <b>cea mai rapidă platformă open-source de sondaje</b> din lume.",
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
"paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra <b>controlul deplin asupra datelor tale</b>.",
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
},
"invite": {
File diff suppressed because it is too large Load Diff
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
"hidden_field": "Dolt fält",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hej {inviterName}",
"invite_accepted_email_heading": "Hej",
"invite_accepted_email_subject": "Du har fått en ny organisationsmedlem!",
"invite_accepted_email_text": "Vi vill bara meddela att {inviteeName} har accepterat din inbjudan. Ha det så kul med samarbetet!",
"invite_accepted_email_text_par1": "Vi vill bara meddela dig att",
"invite_accepted_email_text_par2": "accepterade din inbjudan. Ha kul med samarbetet!",
"invite_email_button_label": "Gå med i organisation",
"invite_email_heading": "Hej {inviteeName}",
"invite_email_text": "Din kollega {inviterName} har bjudit in dig att gå med dem på Formbricks. För att acceptera inbjudan, klicka på länken nedan:",
"invite_email_heading": "Hej",
"invite_email_text_par1": "Din kollega",
"invite_email_text_par2": "bjöd in dig att gå med dem på Formbricks. För att acceptera inbjudan, vänligen klicka på länken nedan:",
"invite_member_email_subject": "Du är inbjuden att samarbeta på Formbricks!",
"new_email_verification_text": "För att verifiera din nya e-postadress, vänligen klicka på knappen nedan:",
"number_variable": "Nummervariabel",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "Vänligen fyll i alla fält för att lägga till ett nytt projekt.",
"read": "Läs",
"read_write": "Läs och skriv",
"security_updates_description": "Anmäl dig till vår säkerhetsmejllista för att hålla dig informerad om sårbarheter upptäcks.",
"security_updates_enroll": "Anmäl dig nu",
"security_updates_enrolled": "Anmäld",
"security_updates_enrolled_description": "Du är anmäld för att ta emot säkerhetsuppdateringar på {email}.",
"security_updates_enrolled_successfully": "Du har anmälts för säkerhetsuppdateringar!",
"security_updates_enrolling": "Anmäler...",
"security_updates_title": "Säkerhetsuppdateringar",
"select_member": "Välj medlem",
"select_project": "Välj projekt",
"team_admin": "Teamadministratör",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "Kom igång",
"made_with_love_in_kiel": "Gjort med 🤍 i Tyskland",
"paragraph_1": "Formbricks är en Experience Management Suite byggd den <b>snabbast växande open source-enkätplattformen</b> i världen.",
"paragraph_1": "Formbricks är en Experience Management Suite byggd av den <b>snabbast växande öppenkällkods enkätplattformen</b> i världen.",
"paragraph_2": "Kör riktade enkäter på webbplatser, i appar eller var som helst online. Samla värdefulla insikter för att <b>skapa oemotståndliga upplevelser</b> för kunder, användare och anställda.",
"paragraph_3": "Vi är engagerade i högsta möjliga datasekretess. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"paragraph_3": "Vi är engagerade i högsta grad av dataintegritet. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"welcome_to_formbricks": "Välkommen till Formbricks!"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
"imprint": "印记",
"invite_accepted_email_heading": "你好,{inviterName}",
"invite_accepted_email_heading": "",
"invite_accepted_email_subject": "你 有 一个 新 成员 进入 组织 了!",
"invite_accepted_email_text": "{inviteeName} 已接受了你的邀请。祝你们合作愉快!",
"invite_accepted_email_text_par1": "只是 告诉 你",
"invite_accepted_email_text_par2": "接受了 你的 邀请。 合作 愉快!",
"invite_email_button_label": "加入 组织",
"invite_email_heading": "你好,{inviteeName}",
"invite_email_text": "你的同事 {inviterName} 邀请你加入 Formbricks。要接受邀请,请点击下方链接:",
"invite_email_heading": "",
"invite_email_text_par1": "您的 同事",
"invite_email_text_par2": "邀请您加入他们在 Formbricks 。要接受邀请,请点击下面的链接:",
"invite_member_email_subject": "您 被 邀请 来 协作 于 Formbricks",
"new_email_verification_text": "要 验证 您 的 新 邮箱 地址 ,请 点击 下方 的 按钮 :",
"number_variable": "数字变量",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。",
"read": "阅读",
"read_write": "读 & 写",
"security_updates_description": "加入我们的安全邮件列表,及时了解发现的安全漏洞信息。",
"security_updates_enroll": "立即加入",
"security_updates_enrolled": "已加入",
"security_updates_enrolled_description": "您已加入安全更新通知,相关信息将发送至 {email}。",
"security_updates_enrolled_successfully": "已成功加入安全更新通知!",
"security_updates_enrolling": "正在加入...",
"security_updates_title": "安全更新",
"select_member": "选择成员",
"select_project": "选择项目",
"team_admin": "团队管理员",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "开始使用",
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
"paragraph_1": "Formbricks 是一体验管理套件,基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_1": "Formbricks 是一体验管理套件, 基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工<b>打造 无法抗拒 的体验</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,<b>全面掌控您的数据</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持<b>对您的数据的完全控制</b>。",
"welcome_to_formbricks": "欢迎来到 Formbricks !"
},
"invite": {
+8 -13
View File
@@ -475,12 +475,14 @@
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
"imprint": "版本訊息",
"invite_accepted_email_heading": "嗨{inviterName}",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "您有一位新的組織成員!",
"invite_accepted_email_text": "通知你,{inviteeName} 已經接受了你的邀請。祝你們合作愉快!",
"invite_accepted_email_text_par1": "通知您,",
"invite_accepted_email_text_par2": "接受了您的邀請。合作愉快!",
"invite_email_button_label": "加入組織",
"invite_email_heading": "嗨{inviteeName}",
"invite_email_text": "的同事 {inviterName} 邀請你加入他們在 Formbricks。請點擊下方連結以接受邀請:",
"invite_email_heading": "嗨",
"invite_email_text_par1": "的同事",
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:",
"invite_member_email_subject": "您被邀請協作 Formbricks",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
"number_variable": "數字變數",
@@ -1180,13 +1182,6 @@
"please_fill_all_project_fields": "請填寫所有欄位以新增新專案。",
"read": "讀取",
"read_write": "讀取和寫入",
"security_updates_description": "加入我們的安全郵件名單,隨時掌握漏洞相關資訊。",
"security_updates_enroll": "立即加入",
"security_updates_enrolled": "已加入",
"security_updates_enrolled_description": "您已加入安全更新通知,將會寄送至 {email}。",
"security_updates_enrolled_successfully": "已成功加入安全更新通知!",
"security_updates_enrolling": "正在加入...",
"security_updates_title": "安全更新",
"select_member": "選擇成員",
"select_project": "選擇專案",
"team_admin": "團隊管理員",
@@ -2051,9 +2046,9 @@
"intro": {
"get_started": "開始使用",
"made_with_love_in_kiel": "用 🤍 在德國製造",
"paragraph_1": "Formbricks 是一套體驗管理工具,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_1": "Formbricks 是一套體驗管理套件,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工<b>打造無法抗拒的體驗</b>。",
"paragraph_3": "我們致力於最高等級的資料隱私。自託管,讓您<b>完全掌控您的資料</b>。",
"paragraph_3": "我們致力於最高程度的資料隱私。自託管<b>完全掌控您的資料</b>。",
"welcome_to_formbricks": "歡迎使用 Formbricks"
},
"invite": {
@@ -9,6 +9,7 @@ import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
@@ -58,11 +59,13 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
};
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
"/management/contact-attribute-keys": {
"/contact-attribute-keys": {
servers: managementServer,
get: getContactAttributeKeysEndpoint,
post: createContactAttributeKeyEndpoint,
},
"/management/contact-attribute-keys/{id}": {
"/contact-attribute-keys/{id}": {
servers: managementServer,
get: getContactAttributeKeyEndpoint,
put: updateContactAttributeKeyEndpoint,
delete: deleteContactAttributeKeyEndpoint,
@@ -0,0 +1,6 @@
export const managementServer = [
{
url: `https://app.formbricks.com/api/v2/management`,
description: "Formbricks Management API",
},
];
@@ -1,5 +1,6 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteResponseEndpoint,
getResponseEndpoint,
@@ -56,11 +57,13 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
};
export const responsePaths: ZodOpenApiPathsObject = {
"/management/responses": {
"/responses": {
servers: managementServer,
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/management/responses/{id}": {
"/responses/{id}": {
servers: managementServer,
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,
@@ -1,8 +1,10 @@
import { ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
"/management/surveys/{surveyId}/contact-links/segments/{segmentId}": {
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
servers: managementServer,
get: getContactLinksBySegmentEndpoint,
},
};
@@ -1,6 +1,7 @@
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
@@ -51,16 +52,19 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
};
export const surveyPaths: ZodOpenApiPathsObject = {
// "/management/surveys": {
// "/surveys": {
// servers: managementServer,
// get: getSurveysEndpoint,
// post: createSurveyEndpoint,
// },
// "/management/surveys/{id}": {
// "/surveys/{id}": {
// servers: managementServer,
// get: getSurveyEndpoint,
// put: updateSurveyEndpoint,
// delete: deleteSurveyEndpoint,
// },
"/management/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
"/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
servers: managementServer,
get: getPersonalizedSurveyLink,
},
};
@@ -1,5 +1,6 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteWebhookEndpoint,
getWebhookEndpoint,
@@ -55,11 +56,13 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
};
export const webhookPaths: ZodOpenApiPathsObject = {
"/management/webhooks": {
"/webhooks": {
servers: managementServer,
get: getWebhooksEndpoint,
post: createWebhookEndpoint,
},
"/management/webhooks/{id}": {
"/webhooks/{id}": {
servers: managementServer,
get: getWebhookEndpoint,
put: updateWebhookEndpoint,
delete: deleteWebhookEndpoint,
@@ -7,6 +7,7 @@ import {
ZProjectTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
@@ -118,7 +119,8 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
};
export const projectTeamPaths: ZodOpenApiPathsObject = {
"/organizations/{organizationId}/project-teams": {
"/{organizationId}/project-teams": {
servers: organizationServer,
get: getProjectTeamsEndpoint,
post: createProjectTeamEndpoint,
put: updateProjectTeamEndpoint,
@@ -11,6 +11,7 @@ import {
ZTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getTeamsEndpoint: ZodOpenApiOperationObject = {
@@ -68,11 +69,13 @@ export const createTeamEndpoint: ZodOpenApiOperationObject = {
};
export const teamPaths: ZodOpenApiPathsObject = {
"/organizations/{organizationId}/teams": {
"/{organizationId}/teams": {
servers: organizationServer,
get: getTeamsEndpoint,
post: createTeamEndpoint,
},
"/organizations/{organizationId}/teams/{id}": {
"/{organizationId}/teams/{id}": {
servers: organizationServer,
get: getTeamEndpoint,
put: updateTeamEndpoint,
delete: deleteTeamEndpoint,
@@ -7,6 +7,7 @@ import {
ZUserInput,
ZUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getUsersEndpoint: ZodOpenApiOperationObject = {
@@ -95,7 +96,8 @@ export const updateUserEndpoint: ZodOpenApiOperationObject = {
};
export const userPaths: ZodOpenApiPathsObject = {
"/organizations/{organizationId}/users": {
"/{organizationId}/users": {
servers: organizationServer,
get: getUsersEndpoint,
post: createUserEndpoint,
patch: updateUserEndpoint,
@@ -0,0 +1,6 @@
export const organizationServer = [
{
url: `https://app.formbricks.com/api/v2/organizations`,
description: "Formbricks Organizations API",
},
];
@@ -3,15 +3,15 @@
import { signIn } from "next-auth/react";
import { useEffect } from "react";
export const SignIn = ({ token, webAppUrl }) => {
export const SignIn = ({ token }) => {
useEffect(() => {
if (token) {
signIn("token", {
token: token,
callbackUrl: webAppUrl,
callbackUrl: `/`,
});
}
}, [token, webAppUrl]);
}, [token]);
return <></>;
};
+1 -2
View File
@@ -1,4 +1,3 @@
import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { SignIn } from "@/modules/auth/verify/components/sign-in";
@@ -10,7 +9,7 @@ export const VerifyPage = async ({ searchParams }) => {
return token ? (
<FormWrapper>
<p className="text-center">{t("auth.verify.verifying")}</p>
<SignIn token={token} webAppUrl={WEBAPP_URL} />
<SignIn token={token} />
</FormWrapper>
) : (
<p className="text-center">{t("auth.verify.no_token_provided")}</p>
@@ -1,5 +1,6 @@
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
const bulkContactEndpoint: ZodOpenApiOperationObject = {
@@ -110,7 +111,8 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
};
export const bulkContactPaths: ZodOpenApiPathsObject = {
"/management/contacts/bulk": {
"/contacts/bulk": {
servers: managementServer,
put: bulkContactEndpoint,
},
};
@@ -1,4 +1,5 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZContactCreateRequest, ZContactResponse } from "@/modules/ee/contacts/types/contact";
@@ -53,7 +54,8 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
};
export const contactPaths: ZodOpenApiPathsObject = {
"/management/contacts": {
"/contacts": {
servers: managementServer,
post: createContactEndpoint,
},
};
@@ -1,8 +1,9 @@
import { Button } from "@react-email/components";
import React from "react";
interface EmailButtonProps {
readonly label: string;
readonly href: string;
label: string;
href: string;
}
export function EmailButton({ label, href }: EmailButtonProps): React.JSX.Element {
@@ -1,25 +1,23 @@
import { Container } from "@react-email/components";
import { cn } from "../../src/lib/cn";
import { cn } from "@/lib/cn";
interface ElementHeaderProps {
readonly headline: string;
readonly subheader?: string;
readonly className?: string;
headline: string;
subheader?: string;
className?: string;
}
export function ElementHeader({ headline, subheader, className }: ElementHeaderProps): React.JSX.Element {
return (
<>
<Container className={cn("text-question-color m-0 block text-base leading-6 font-semibold", className)}>
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
<div dangerouslySetInnerHTML={{ __html: headline }} />
</Container>
{subheader && (
<Container className="text-question-color m-0 mt-2 block p-0 text-sm leading-6 font-normal">
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
<div dangerouslySetInnerHTML={{ __html: subheader }} />
</Container>
)}
</>
);
}
export default ElementHeader;
@@ -1,5 +1,6 @@
import { Text } from "@react-email/components";
import { TFunction } from "../types/translations";
import { TFunction } from "i18next";
import React from "react";
export function EmailFooter({ t }: { t: TFunction }): React.JSX.Element {
return (
@@ -1,24 +1,22 @@
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { TEmailTemplateLegalProps } from "../types/email";
import { TFunction } from "../types/translations";
import { TFunction } from "i18next";
import React from "react";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
const fbLogoUrl = "https://app.formbricks.com/logo-transparent.png";
const fbLogoUrl = FB_LOGO_URL;
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface EmailTemplateProps extends TEmailTemplateLegalProps {
interface EmailTemplateProps {
readonly children: React.ReactNode;
readonly logoUrl?: string;
readonly t: TFunction;
}
export function EmailTemplate({
export async function EmailTemplate({
children,
logoUrl,
t,
privacyUrl,
imprintUrl,
imprintAddress,
}: EmailTemplateProps): React.JSX.Element {
}: EmailTemplateProps): Promise<React.JSX.Element> {
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
return (
@@ -55,23 +53,23 @@ export function EmailTemplate({
rel="noopener noreferrer">
{t("emails.email_template_text_1")}
</Link>
{imprintAddress && (
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{imprintAddress}</Text>
{IMPRINT_ADDRESS && (
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">
{imprintUrl && (
{IMPRINT_URL && (
<Link
href={imprintUrl}
href={IMPRINT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.imprint")}
</Link>
)}
{imprintUrl && privacyUrl && " • "}
{privacyUrl && (
{IMPRINT_URL && PRIVACY_URL && " • "}
{PRIVACY_URL && (
<Link
href={privacyUrl}
href={PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
@@ -85,5 +83,3 @@ export function EmailTemplate({
</Html>
);
}
export default EmailTemplate;
@@ -1,10 +1,6 @@
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
import {
Column,
Container,
ElementHeader,
Button as EmailButton,
Img,
Link,
@@ -12,8 +8,11 @@ import {
Section,
Tailwind,
Text,
render,
} from "@formbricks/email";
} from "@react-email/components";
import { render } from "@react-email/render";
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
import { TSurveyCTAElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
@@ -25,6 +24,7 @@ import { isLight, mixColor } from "@/lib/utils/colors";
import { parseRecallInfo } from "@/lib/utils/recall";
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { ElementHeader } from "./email-element-header";
interface PreviewEmailTemplateProps {
survey: TSurvey;
@@ -183,7 +183,7 @@ export async function PreviewEmailTemplate({
{ctaElement.buttonExternal && ctaElement.ctaButtonLabel && ctaElement.buttonUrl && (
<Container className="mx-0 mt-4 flex max-w-none items-center justify-end">
<EmailButton
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base leading-4 font-medium no-underline shadow-none"
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base font-medium leading-4 no-underline shadow-none"
href={ctaElement.buttonUrl}>
<Text className="inline">
{getLocalizedValue(ctaElement.ctaButtonLabel, defaultLanguageCode)}{" "}
@@ -306,13 +306,13 @@ export async function PreviewEmailTemplate({
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
@@ -360,11 +360,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
<Column className="w-40 px-4 py-2 break-words" />
<Column className="w-40 break-words px-4 py-2" />
{firstQuestion.columns.map((column) => {
return (
<Column
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
key={column.id}>
{getLocalizedValue(column.label, "default")}
</Column>
@@ -376,7 +376,7 @@ export async function PreviewEmailTemplate({
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={row.id}>
<Column className="w-40 px-4 py-2 break-words">
<Column className="w-40 break-words px-4 py-2">
{getLocalizedValue(row.label, "default")}
</Column>
{firstQuestion.columns.map((column) => {
@@ -0,0 +1,30 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface ForgotPasswordEmailProps {
verifyLink: string;
}
export async function ForgotPasswordEmail({
verifyLink,
}: ForgotPasswordEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default ForgotPasswordEmail;
@@ -0,0 +1,34 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface VerificationEmailProps {
readonly verifyLink: string;
}
export async function NewEmailVerification({
verifyLink,
}: VerificationEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.new_email_verification_text")}</Text>
<Text className="text-sm">{t("emails.verification_security_notice")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default NewEmailVerification;
@@ -0,0 +1,20 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
export async function PasswordResetNotifyEmail(): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.password_changed_email_heading")}</Heading>
<Text className="text-sm">{t("emails.password_changed_email_text")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default PasswordResetNotifyEmail;
@@ -1,32 +1,28 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import { EmailButton } from "../../src/components/email-button";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface VerificationEmailProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly verificationRequestLink: string;
readonly t?: TFunction;
interface VerificationEmailProps {
verifyLink: string;
verificationRequestLink: string;
}
export function VerificationEmail({
export async function VerificationEmail({
verifyLink,
verificationRequestLink,
t = mockT,
...legalProps
}: VerificationEmailProps): React.JSX.Element {
}: VerificationEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t} {...legalProps}>
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.verification_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="text-sm break-all text-black" href={verifyLink}>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
@@ -42,6 +38,4 @@ export function VerificationEmail({
);
}
export default function VerificationEmailPreview(): React.JSX.Element {
return <VerificationEmail {...exampleData.verificationEmail} />;
}
export default VerificationEmail;
@@ -0,0 +1,26 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmailCustomizationPreviewEmailProps {
userName: string;
logoUrl?: string;
}
export async function EmailCustomizationPreviewEmail({
userName,
logoUrl,
}: EmailCustomizationPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.email_customization_preview_email_heading", { userName })}</Heading>
<Text className="text-sm">{t("emails.email_customization_preview_email_text")}</Text>
</Container>
</EmailTemplate>
);
}
export default EmailCustomizationPreviewEmail;
@@ -0,0 +1,33 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteAcceptedEmailProps {
inviterName: string;
inviteeName: string;
}
export async function InviteAcceptedEmail({
inviterName,
inviteeName,
}: InviteAcceptedEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_accepted_email_heading", { inviterName })} {inviterName}
</Heading>
<Text className="text-sm">
{t("emails.invite_accepted_email_text_par1", { inviteeName })} {inviteeName}{" "}
{t("emails.invite_accepted_email_text_par2")}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default InviteAcceptedEmail;
@@ -0,0 +1,37 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteEmailProps {
inviteeName: string;
inviterName: string;
verifyLink: string;
}
export async function InviteEmail({
inviteeName,
inviterName,
verifyLink,
}: InviteEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_email_heading", { inviteeName })} {inviteeName}
</Heading>
<Text className="text-sm">
{t("emails.invite_email_text_par1", { inviterName })} {inviterName}{" "}
{t("emails.invite_email_text_par2")}
</Text>
<EmailButton href={verifyLink} label={t("emails.invite_email_button_label")} />
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default InviteEmail;
@@ -1,32 +1,21 @@
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
import { TFunction } from "i18next";
import { FileIcon } from "lucide-react";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TFunction } from "../types/translations";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
// Simplified version - just get the filename from URL
const getOriginalFileNameFromUrl = (url: string): string => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || "file";
return decodeURIComponent(filename);
} catch {
return url.split("/").pop() || "file";
}
};
export const renderEmailResponseValue = (
export const renderEmailResponseValue = async (
response: string | string[],
questionType: TSurveyElementTypeEnum,
t: TFunction,
overrideFileUploadResponse = false
): React.JSX.Element => {
): Promise<React.JSX.Element> => {
switch (questionType) {
case TSurveyElementTypeEnum.FileUpload:
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 text-sm break-words whitespace-pre-wrap italic">
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text>
) : (
@@ -76,6 +65,6 @@ export const renderEmailResponseValue = (
);
default:
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response as string}</Text>;
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
}
};
@@ -0,0 +1,36 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmbedSurveyPreviewEmailProps {
html: string;
environmentId: string;
logoUrl?: string;
}
export async function EmbedSurveyPreviewEmail({
html,
environmentId,
logoUrl,
}: EmbedSurveyPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
<Text className="text-sm">
<b>{t("emails.embed_survey_preview_email_didnt_request")}</b>{" "}
{t("emails.embed_survey_preview_email_fight_spam")}
</Text>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: html }} />
<Text className="text-center text-sm text-slate-700">
{t("emails.embed_survey_preview_email_environment_id")}: {environmentId}
</Text>
</Container>
</EmailTemplate>
);
}
export default EmbedSurveyPreviewEmail;
@@ -0,0 +1,36 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface LinkSurveyEmailProps {
surveyName: string;
surveyLink: string;
logoUrl: string;
}
export async function LinkSurveyEmail({
surveyName,
surveyLink,
logoUrl,
}: LinkSurveyEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.verification_email_hey")}</Heading>
<Text className="text-sm">{t("emails.verification_email_thanks")}</Text>
<Text className="text-sm">{t("emails.verification_email_to_fill_survey")}</Text>
<EmailButton href={surveyLink} label={t("emails.verification_email_take_survey")} />
<Text className="text-sm text-slate-400">
{t("emails.verification_email_survey_name")}: {surveyName}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default LinkSurveyEmail;
@@ -2,52 +2,35 @@ import { Column, Container, Heading, Hr, Link, Row, Section, Text } from "@react
import { FileDigitIcon, FileType2Icon } from "lucide-react";
import type { TOrganization } from "@formbricks/types/organizations";
import type { TResponse } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { EmailButton } from "../../src/components/email-button";
import { EmailTemplate } from "../../src/components/email-template";
import { renderEmailResponseValue } from "../../src/lib/email-utils";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { ProcessedResponseElement } from "../../src/types/follow-up";
import { TFunction } from "../../src/types/translations";
import { type TSurvey } from "@formbricks/types/surveys/types";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
import { EmailButton } from "../../components/email-button";
import { EmailTemplate } from "../../components/email-template";
export interface ResponseFinishedEmailProps extends TEmailTemplateLegalProps {
readonly survey: TSurvey;
readonly responseCount: number;
readonly response: TResponse;
readonly WEBAPP_URL: string;
readonly environmentId: string;
readonly organization: TOrganization;
readonly elements: ProcessedResponseElement[]; // Pre-processed data, not a function
readonly t?: TFunction;
interface ResponseFinishedEmailProps {
survey: TSurvey;
responseCount: number;
response: TResponse;
WEBAPP_URL: string;
environmentId: string;
organization: TOrganization;
}
const mockGetElementResponseMapping = (survey: TSurvey, response: TResponse) => {
// For preview, just return the response data as elements
return Object.entries(response.data)
.filter(([key]) => !survey.hiddenFields.fieldIds?.includes(key))
.map(([key, value]) => ({
element: key,
response: value as string | string[],
type: TSurveyElementTypeEnum.OpenText, // Default type for preview
}));
};
export function ResponseFinishedEmail({
export async function ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements,
t = mockT,
...legalProps
}: ResponseFinishedEmailProps): React.JSX.Element {
}: ResponseFinishedEmailProps): Promise<React.JSX.Element> {
const elements = getElementResponseMapping(survey, response);
const t = await getTranslate();
return (
<EmailTemplate t={t} {...legalProps}>
<EmailTemplate t={t}>
<Container>
<Row>
<Column>
@@ -59,7 +42,7 @@ export function ResponseFinishedEmail({
</Text>
<Hr />
{elements.map((e) => {
if (!e.response) return null;
if (!e.response) return;
return (
<Row key={e.element}>
<Column className="w-full font-medium">
@@ -75,6 +58,7 @@ export function ResponseFinishedEmail({
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
@@ -90,7 +74,7 @@ export function ResponseFinishedEmail({
)}
{variable.name}
</Text>
<Text className="mt-0 font-medium break-words whitespace-pre-wrap">
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
{variableResponse}
</Text>
</Column>
@@ -110,7 +94,7 @@ export function ResponseFinishedEmail({
<Text className="mb-2 flex items-center gap-2 text-sm">
{hiddenFieldId} <EyeOffIcon />
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap">
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
{hiddenFieldResponse}
</Text>
</Column>
@@ -174,11 +158,3 @@ function EyeOffIcon(): React.JSX.Element {
</svg>
);
}
// Default export for preview server
export default function ResponseFinishedEmailPreview(): React.JSX.Element {
const { survey, response, ...rest } = exampleData.responseFinishedEmail;
const elements = mockGetElementResponseMapping(survey, response);
return <ResponseFinishedEmail {...rest} survey={survey} response={response} elements={elements} />;
}
+30 -64
View File
@@ -1,18 +1,6 @@
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import {
renderEmailCustomizationPreviewEmail,
renderEmbedSurveyPreviewEmail,
renderForgotPasswordEmail,
renderInviteAcceptedEmail,
renderInviteEmail,
renderLinkSurveyEmail,
renderNewEmailVerification,
renderPasswordResetNotifyEmail,
renderResponseFinishedEmail,
renderVerificationEmail,
} from "@formbricks/email";
import { TEmailTemplateLegalProps } from "@formbricks/email/src/types/email";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
@@ -21,11 +9,8 @@ import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import {
DEBUG,
IMPRINT_ADDRESS,
IMPRINT_URL,
MAIL_FROM,
MAIL_FROM_NAME,
PRIVACY_URL,
SMTP_AUTHENTICATED,
SMTP_HOST,
SMTP_PASSWORD,
@@ -33,24 +18,25 @@ import {
SMTP_REJECT_UNAUTHORIZED_TLS,
SMTP_SECURE_ENABLED,
SMTP_USER,
TERMS_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
import { VerificationEmail } from "./emails/auth/verification-email";
import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
const legalProps: TEmailTemplateLegalProps = {
privacyUrl: PRIVACY_URL || undefined,
termsUrl: TERMS_URL || undefined,
imprintUrl: IMPRINT_URL || undefined,
imprintAddress: IMPRINT_ADDRESS || undefined,
};
interface SendEmailDataProps {
to: string;
replyTo?: string;
@@ -103,7 +89,7 @@ export const sendVerificationNewEmail = async (id: string, email: string): Promi
const token = createEmailChangeToken(id, email);
const verifyLink = `${WEBAPP_URL}/verify-email-change?token=${encodeURIComponent(token)}`;
const html = await renderNewEmailVerification({ verifyLink, t, ...legalProps });
const html = await render(await NewEmailVerification({ verifyLink }));
return await sendEmail({
to: email,
@@ -131,12 +117,7 @@ export const sendVerificationEmail = async ({
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?token=${encodeURIComponent(token)}`;
const html = await renderVerificationEmail({
verificationRequestLink,
verifyLink,
t,
...legalProps,
});
const html = await render(await VerificationEmail({ verificationRequestLink, verifyLink }));
return await sendEmail({
to: email,
@@ -159,7 +140,7 @@ export const sendForgotPasswordEmail = async (user: {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
const html = await renderForgotPasswordEmail({ verifyLink, t, ...legalProps });
const html = await render(await ForgotPasswordEmail({ verifyLink }));
return await sendEmail({
to: user.email,
subject: t("emails.forgot_password_email_subject"),
@@ -169,7 +150,7 @@ export const sendForgotPasswordEmail = async (user: {
export const sendPasswordResetNotifyEmail = async (user: { email: string }): Promise<boolean> => {
const t = await getTranslate();
const html = await renderPasswordResetNotifyEmail({ t, ...legalProps });
const html = await render(await PasswordResetNotifyEmail());
return await sendEmail({
to: user.email,
subject: t("emails.password_reset_notify_email_subject"),
@@ -190,7 +171,7 @@ export const sendInviteMemberEmail = async (
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
const html = await renderInviteEmail({ inviteeName, inviterName, verifyLink, t, ...legalProps });
const html = await render(await InviteEmail({ inviteeName, inviterName, verifyLink }));
return await sendEmail({
to: email,
subject: t("emails.invite_member_email_subject"),
@@ -204,7 +185,7 @@ export const sendInviteAcceptedEmail = async (
email: string
): Promise<void> => {
const t = await getTranslate();
const html = await renderInviteAcceptedEmail({ inviteeName, inviterName, t, ...legalProps });
const html = await render(await InviteAcceptedEmail({ inviteeName, inviterName }));
await sendEmail({
to: email,
subject: t("emails.invite_accepted_email_subject"),
@@ -227,20 +208,16 @@ export const sendResponseFinishedEmail = async (
throw new Error("Organization not found");
}
// Pre-process the element response mapping before passing to email
const elements = getElementResponseMapping(survey, response);
const html = await renderResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements,
t,
...legalProps,
});
const html = await render(
await ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
})
);
await sendEmail({
to: email,
@@ -264,13 +241,7 @@ export const sendEmbedSurveyPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const html = await renderEmbedSurveyPreviewEmail({
html: innerHtml,
environmentId,
logoUrl,
t,
...legalProps,
});
const html = await render(await EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, logoUrl }));
return await sendEmail({
to,
subject: t("emails.embed_survey_preview_email_subject"),
@@ -284,12 +255,7 @@ export const sendEmailCustomizationPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const emailHtmlBody = await renderEmailCustomizationPreviewEmail({
userName,
logoUrl,
t,
...legalProps,
});
const emailHtmlBody = await render(await EmailCustomizationPreviewEmail({ userName, logoUrl }));
return await sendEmail({
to,
@@ -314,7 +280,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
};
const surveyLink = getSurveyLink();
const html = await renderLinkSurveyEmail({ surveyName, surveyLink, logoUrl, t, ...legalProps });
const html = await render(await LinkSurveyEmail({ surveyName, surveyLink, logoUrl }));
return await sendEmail({
to: data.email,
subject: t("emails.verified_link_survey_email_subject"),
@@ -24,7 +24,6 @@ import {
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
import { enrollInSecurityUpdates } from "./lib/security-updates";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -388,39 +387,3 @@ export const leaveOrganizationAction = authenticatedActionClient.schema(ZLeaveOr
}
)
);
const ZEnrollSecurityUpdatesAction = z.object({
organizationId: ZId,
});
export const enrollSecurityUpdatesAction = authenticatedActionClient
.schema(ZEnrollSecurityUpdatesAction)
.action(async ({ ctx, parsedInput }) => {
// Ensure this is only called for self-hosted instances
if (IS_FORMBRICKS_CLOUD) {
throw new OperationNotAllowedError(
"Security updates enrollment is only available for self-hosted instances"
);
}
// Only owners can enroll in security updates
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
// Enroll with the current user's email
const result = await enrollInSecurityUpdates(ctx.user.email);
if (!result.success) {
throw new Error("Failed to enroll in security updates");
}
return { success: true };
});
@@ -1,98 +0,0 @@
"use client";
import { ShieldCheckIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { enrollSecurityUpdatesAction } from "@/modules/organization/settings/teams/actions";
import { TSecurityUpdatesStatus } from "@/modules/organization/settings/teams/lib/security-updates";
import { Button } from "@/modules/ui/components/button";
import { H4, P } from "@/modules/ui/components/typography";
interface SecurityUpdatesCardProps {
organizationId: string;
userEmail: string;
securityUpdatesStatus: TSecurityUpdatesStatus;
}
export const SecurityUpdatesCard = ({
organizationId,
userEmail,
securityUpdatesStatus,
}: SecurityUpdatesCardProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isEnrolling, setIsEnrolling] = useState(false);
const handleEnroll = async () => {
setIsEnrolling(true);
try {
const result = await enrollSecurityUpdatesAction({ organizationId });
if (result?.data?.success) {
toast.success(t("environments.settings.teams.security_updates_enrolled_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
console.error(error);
} finally {
setIsEnrolling(false);
}
};
const isEnrolled = securityUpdatesStatus.enrolled;
return (
<div
className={cn(
"relative my-4 w-full max-w-4xl rounded-xl border bg-white shadow-sm",
isEnrolled ? "border-green-200 bg-green-50" : "border-slate-200"
)}>
<div className="flex items-start justify-between p-6">
<div className="flex items-start gap-4">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-full",
isEnrolled ? "bg-green-100" : "bg-slate-100"
)}>
<ShieldCheckIcon className={cn("h-5 w-5", isEnrolled ? "text-green-600" : "text-slate-600")} />
</div>
<div className="flex flex-col gap-1">
<H4 className="font-medium tracking-normal">
{t("environments.settings.teams.security_updates_title")}
</H4>
<P className="!mt-0 text-sm text-slate-500">
{isEnrolled
? t("environments.settings.teams.security_updates_enrolled_description", {
email: securityUpdatesStatus.email || userEmail,
})
: t("environments.settings.teams.security_updates_description")}
</P>
</div>
</div>
{!isEnrolled && (
<Button onClick={handleEnroll} disabled={isEnrolling} className="shrink-0">
{isEnrolling
? t("environments.settings.teams.security_updates_enrolling")
: t("environments.settings.teams.security_updates_enroll")}
</Button>
)}
{isEnrolled && (
<div className="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">
{t("environments.settings.teams.security_updates_enrolled")}
</span>
</div>
)}
</div>
</div>
);
};
@@ -1,68 +0,0 @@
"use server";
import { getInstanceId } from "@/lib/instance";
export type TSecurityUpdatesStatus = {
enrolled: boolean;
email?: string;
};
/**
* Checks if the current instance is enrolled in security updates.
*
* TODO: Replace with actual EE server call
* GET /security-updates/status?instanceId=xxx
*
* @returns The enrollment status and email if enrolled
*/
export const getSecurityUpdatesStatus = async (): Promise<TSecurityUpdatesStatus> => {
const instanceId = await getInstanceId();
if (!instanceId) {
return { enrolled: false };
}
// TODO: Replace with actual EE server call
// const response = await fetch(`${EE_SERVER_URL}/instances/${instanceId}/security-updates`);
// if (!response.ok) {
// return { enrolled: false };
// }
// return await response.json();
// Mock: Always return not enrolled for now
return { enrolled: false };
};
/**
* Enrolls the current instance in security updates.
*
* TODO: Replace with actual EE server call
* POST /security-updates/enroll { instanceId, email }
*
* @param email - The email address to receive security updates
* @returns Success status
*/
export const enrollInSecurityUpdates = async (email: string): Promise<{ success: boolean }> => {
const instanceId = await getInstanceId();
if (!instanceId) {
throw new Error("Instance ID not found");
}
// TODO: Replace with actual EE server call
// const response = await fetch(`${EE_SERVER_URL}/instances/${instanceId}/security-updates`, {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ instanceId, email }),
// });
//
// if (!response.ok) {
// throw new Error("Failed to enroll in security updates");
// }
//
// return await response.json();
// Mock: Always succeed for now
console.log(`[Mock] Enrolling instance ${instanceId} with email ${email}`);
return { success: true };
};
@@ -1,25 +1,20 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
import { SecurityUpdatesCard } from "@/modules/organization/settings/teams/components/security-updates-card";
import { getSecurityUpdatesStatus } from "@/modules/organization/settings/teams/lib/security-updates";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
export const TeamsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
export const TeamsPage = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner } = await getEnvironmentAuth(
params.environmentId
);
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
@@ -37,12 +32,6 @@ export const TeamsPage = async (props: { params: Promise<{ environmentId: string
const hasUserManagementAccess =
hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
// Fetch security updates status for self-hosted instances only (owners only)
const shouldShowSecurityUpdates = !IS_FORMBRICKS_CLOUD && isOwner;
const [securityUpdatesStatus, user] = shouldShowSecurityUpdates
? await Promise.all([getSecurityUpdatesStatus(), getUser(session.user.id)])
: [null, null];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
@@ -53,15 +42,6 @@ export const TeamsPage = async (props: { params: Promise<{ environmentId: string
activeId="teams"
/>
</PageHeader>
{securityUpdatesStatus && user && (
<SecurityUpdatesCard
organizationId={organization.id}
userEmail={user.email}
securityUpdatesStatus={securityUpdatesStatus}
/>
)}
<MembersView
membershipRole={currentUserMembership?.role}
organization={organization}
@@ -38,7 +38,6 @@ interface ThemeStylingProps {
isUnsplashConfigured: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
publicDomain: string;
}
export const ThemeStyling = ({
@@ -48,7 +47,6 @@ export const ThemeStyling = ({
isUnsplashConfigured,
isReadOnly,
isStorageConfigured = true,
publicDomain,
}: ThemeStylingProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -201,7 +199,6 @@ export const ThemeStyling = ({
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
publicDomain={publicDomain}
/>
</div>
</div>
@@ -1,7 +1,6 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
@@ -28,7 +27,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
@@ -51,7 +49,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
publicDomain={publicDomain}
/>
</SettingsCard>
<SettingsCard
@@ -284,7 +284,7 @@ export const BlockCard = ({
</div>
<button
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
className="opacity-0 hover:cursor-move group-hover:opacity-100"
aria-label="Drag to reorder block">
<GripIcon className="h-4 w-4" />
</button>
@@ -22,7 +22,6 @@ interface EditWelcomeCardProps {
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const EditWelcomeCard = ({
@@ -35,7 +34,6 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: EditWelcomeCardProps) => {
const { t } = useTranslation();
@@ -67,7 +65,7 @@ export const EditWelcomeCard = ({
<div
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<Hand className="h-4 w-4" />
@@ -137,7 +135,6 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
<div className="mt-3">
@@ -153,7 +150,6 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
@@ -174,7 +170,6 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -808,7 +808,6 @@ export const ElementsView = ({
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
)}
@@ -50,7 +50,6 @@ interface SurveyEditorProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
publicDomain: string;
}
export const SurveyEditor = ({
@@ -80,7 +79,6 @@ export const SurveyEditor = ({
isStorageConfigured,
quotas,
isExternalUrlsAllowed,
publicDomain,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
const [activeElementId, setActiveElementId] = useState<string | null>(null);
@@ -274,7 +272,6 @@ export const SurveyEditor = ({
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
isSpamProtectionAllowed={isSpamProtectionAllowed}
publicDomain={publicDomain}
/>
</aside>
</div>
@@ -400,7 +400,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
{!isStorageConfigured && (
<div>
<Alert variant="warning" size="small">
-3
View File
@@ -6,7 +6,6 @@ import {
SURVEY_BG_COLORS,
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -106,7 +105,6 @@ export const SurveyEditorPage = async (props) => {
}
const isCxMode = searchParams.mode === "cx";
const publicDomain = getPublicDomain();
return (
<SurveyEditor
@@ -136,7 +134,6 @@ export const SurveyEditorPage = async (props) => {
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
/>
);
};
@@ -0,0 +1,120 @@
import { Column, Hr, Row, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "@/modules/email/components/email-template";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
interface FollowUpEmailProps {
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
readonly includeVariables: boolean;
readonly includeHiddenFields: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JSX.Element> {
const { properties } = props.followUp.action;
let { body } = properties;
// Parse recall tags and replace with actual response values
body = parseRecallInfo(body, props.response.data, props.response.variables);
const elements = props.attachResponseData ? getElementResponseMapping(props.survey, props.response) : [];
const t = await getTranslate();
return (
<EmailTemplate logoUrl={props.logoUrl} t={t}>
<>
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
}),
}}
/>
{elements.length > 0 ? (
<>
<Hr />
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
</>
) : null}
{elements.map((e) => {
if (!e.response) return;
return (
<Row key={e.element}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">{e.element}</Text>
{renderEmailResponseValue(e.response, e.type, t, true)}
</Column>
</Row>
);
})}
{props.attachResponseData &&
props.includeVariables &&
props.survey.variables
.filter((variable) => {
const variableResponse = props.response.variables[variable.id];
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
const variableResponse = props.response.variables[variable.id];
return (
<Row key={variable.id}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{variable.type === "number"
? `${t("emails.number_variable")}: ${variable.name}`
: `${t("emails.text_variable")}: ${variable.name}`}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
{variableResponse}
</Text>
</Column>
</Row>
);
})}
{props.attachResponseData &&
props.includeHiddenFields &&
props.survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId] as string;
return (
<Row key={hiddenFieldId}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenFieldId}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
{hiddenFieldResponse}
</Text>
</Column>
</Row>
);
})}
</>
</EmailTemplate>
);
}
@@ -155,7 +155,7 @@ export const FollowUpItem = ({
</div>
</button>
<div className="absolute top-4 right-4 flex items-center">
<div className="absolute right-4 top-4 flex items-center">
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"
+14 -81
View File
@@ -1,18 +1,9 @@
import sanitizeHtml from "sanitize-html";
import { render } from "@react-email/components";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import {
ProcessedHiddenField,
ProcessedResponseElement,
ProcessedVariable,
renderFollowUpEmail,
} from "@formbricks/email";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/constants";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { sendEmail } from "@/modules/email";
import { FollowUpEmail } from "@/modules/survey/follow-ups/components/follow-up-email";
export const sendFollowUpEmail = async ({
followUp,
@@ -37,79 +28,21 @@ export const sendFollowUpEmail = async ({
}): Promise<void> => {
const {
action: {
properties: { subject, body },
properties: { subject },
},
} = followUp;
const t = await getTranslate();
// Process body: parse recall tags and sanitize HTML
const processedBody = sanitizeHtml(parseRecallInfo(body, response.data, response.variables), {
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
allowedAttributes: {
a: ["href", "rel", "target"],
"*": ["dir", "class"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: {
a: ["http", "https"],
},
});
// Process response data
const responseData: ProcessedResponseElement[] = attachResponseData
? getElementResponseMapping(survey, response).map((e) => ({
element: e.element,
response: e.response,
type: e.type,
}))
: [];
// Process variables
const variables: ProcessedVariable[] =
attachResponseData && includeVariables
? survey.variables
.filter((variable) => {
const variableResponse = response.variables[variable.id];
return (
(typeof variableResponse === "string" || typeof variableResponse === "number") &&
variableResponse !== undefined
);
})
.map((variable) => ({
id: variable.id,
name: variable.name,
type: variable.type,
value: response.variables[variable.id],
}))
: [];
// Process hidden fields
const hiddenFields: ProcessedHiddenField[] =
attachResponseData && includeHiddenFields
? (survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => ({
id: hiddenFieldId,
value: response.data[hiddenFieldId] as string,
})) ?? [])
: [];
const emailHtmlBody = await renderFollowUpEmail({
body: processedBody,
responseData,
variables,
hiddenFields,
logoUrl,
t,
privacyUrl: PRIVACY_URL || undefined,
termsUrl: TERMS_URL || undefined,
imprintUrl: IMPRINT_URL || undefined,
imprintAddress: IMPRINT_ADDRESS || undefined,
});
const emailHtmlBody = await render(
await FollowUpEmail({
followUp,
logoUrl,
attachResponseData,
includeVariables,
includeHiddenFields,
survey,
response,
})
);
await sendEmail({
to,
@@ -8,7 +8,7 @@ import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
@@ -1,73 +0,0 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { transformElement } from "./transformers";
import { validateElement } from "./validators";
/**
* Extract prefilled values from URL search parameters
*
* Supports prefilling for all survey element types with the following features:
* - Option ID or label matching for choice-based elements (single/multi-select, ranking, picture selection)
* - Comma-separated values for multi-select and ranking
* - Backward compatibility with label-based prefilling
*
* @param survey - The survey object containing blocks and elements
* @param searchParams - URL search parameters (e.g., from useSearchParams() or new URLSearchParams())
* @param languageId - Current language code for label matching
* @returns Object with element IDs as keys and prefilled values, or undefined if no valid prefills
*
* @example
* // Single select with option ID
* ?questionId=option-abc123
*
* // Multi-select with labels (backward compatible)
* ?questionId=Option1,Option2,Option3
*
* // Ranking with option IDs
* ?rankingId=choice-3,choice-1,choice-2
*
* // NPS question
* ?npsId=9
*
* // Multiple questions
* ?q1=answer1&q2=10&q3=option-xyz
*/
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillData: TResponseData = {};
const elements = getElementsFromBlocks(survey.blocks);
searchParams.forEach((value, key) => {
try {
// Skip reserved parameter names
if (FORBIDDEN_IDS.includes(key)) {
return;
}
// Find matching element
const element = elements.find((el) => el.id === key);
if (!element) {
return;
}
// Validate the value for this element type (returns match data)
const validationResult = validateElement(element, value, languageId);
if (!validationResult.isValid) {
return;
}
// Transform the value using pre-matched data from validation
const transformedValue = transformElement(validationResult, value, languageId);
prefillData[element.id] = transformedValue;
} catch (error) {
// Catch any errors to prevent one bad prefill from breaking all prefills
console.error(`[Prefill] Error processing prefill for ${key}:`, error);
}
});
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
};
@@ -1,94 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { matchMultipleOptionsByIdOrLabel, matchOptionByIdOrLabel } from "./matchers";
describe("matchOptionByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First", de: "Erste" } },
{ id: "choice-2", label: { en: "Second", de: "Zweite" } },
{ id: "other", label: { en: "Other", de: "Andere" } },
];
test("matches by ID", () => {
const result = matchOptionByIdOrLabel(choices, "choice-1", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in English", () => {
const result = matchOptionByIdOrLabel(choices, "First", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in German", () => {
const result = matchOptionByIdOrLabel(choices, "Zweite", "de");
expect(result).toEqual(choices[1]);
});
test("prefers ID match over label match", () => {
const choicesWithConflict = [
{ id: "First", label: { en: "Not First" } },
{ id: "choice-2", label: { en: "First" } },
];
const result = matchOptionByIdOrLabel(choicesWithConflict, "First", "en");
expect(result).toEqual(choicesWithConflict[0]); // Matches by ID, not label
});
test("returns null for no match", () => {
const result = matchOptionByIdOrLabel(choices, "NonExistent", "en");
expect(result).toBeNull();
});
test("returns null for empty string", () => {
const result = matchOptionByIdOrLabel(choices, "", "en");
expect(result).toBeNull();
});
test("handles special characters in labels", () => {
const specialChoices = [{ id: "c1", label: { en: "Option (1)" } }];
const result = matchOptionByIdOrLabel(specialChoices, "Option (1)", "en");
expect(result).toEqual(specialChoices[0]);
});
});
describe("matchMultipleOptionsByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First" } },
{ id: "choice-2", label: { en: "Second" } },
{ id: "choice-3", label: { en: "Third" } },
];
test("matches multiple values by ID", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches multiple values by label", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches mixed IDs and labels", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "Second", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[1], choices[2]]);
});
test("preserves order of values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["Third", "First", "Second"], "en");
expect(result).toEqual([choices[2], choices[0], choices[1]]);
});
test("skips non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "NonExistent", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("returns empty array for all non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["NonExistent1", "NonExistent2"], "en");
expect(result).toEqual([]);
});
test("handles empty values array", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, [], "en");
expect(result).toEqual([]);
});
});
@@ -1,42 +0,0 @@
import { TSurveyElementChoice } from "@formbricks/types/surveys/elements";
/**
* Match a value against element choices by ID first, then by label
* This enables both option ID-based and label-based prefilling
*
* @param choices - Array of choice objects with id and label
* @param value - Value from URL parameter (either choice ID or label text)
* @param languageCode - Current language code for label matching
* @returns Matched choice or null if no match found
*/
export const matchOptionByIdOrLabel = (
choices: TSurveyElementChoice[],
value: string,
languageCode: string
): TSurveyElementChoice | null => {
const matchById = choices.find((choice) => choice.id === value);
if (matchById) return matchById;
const matchByLabel = choices.find((choice) => choice.label[languageCode] === value);
if (matchByLabel) return matchByLabel;
return null;
};
/**
* Match multiple values against choices
* Used for multi-select and ranking elements
*
* @param choices - Array of choice objects
* @param values - Array of values from URL parameter
* @param languageCode - Current language code
* @returns Array of matched choices (preserves order)
*/
export const matchMultipleOptionsByIdOrLabel = (
choices: TSurveyElementChoice[],
values: string[],
languageCode: string
): TSurveyElementChoice[] =>
values
.map((value) => matchOptionByIdOrLabel(choices, value, languageCode))
.filter((match) => match !== null);
@@ -1,64 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { parseCommaSeparated, parseNumber } from "./parsers";
describe("parseCommaSeparated", () => {
test("parses simple comma-separated values", () => {
expect(parseCommaSeparated("a,b,c")).toEqual(["a", "b", "c"]);
});
test("trims whitespace from values", () => {
expect(parseCommaSeparated("a , b , c")).toEqual(["a", "b", "c"]);
expect(parseCommaSeparated(" a, b, c ")).toEqual(["a", "b", "c"]);
});
test("filters out empty values", () => {
expect(parseCommaSeparated("a,,b")).toEqual(["a", "b"]);
expect(parseCommaSeparated("a,b,")).toEqual(["a", "b"]);
expect(parseCommaSeparated(",a,b")).toEqual(["a", "b"]);
});
test("handles empty string", () => {
expect(parseCommaSeparated("")).toEqual([]);
});
test("handles single value", () => {
expect(parseCommaSeparated("single")).toEqual(["single"]);
});
test("handles values with spaces", () => {
expect(parseCommaSeparated("First Choice,Second Choice")).toEqual(["First Choice", "Second Choice"]);
});
});
describe("parseNumber", () => {
test("parses valid integers", () => {
expect(parseNumber("5")).toBe(5);
expect(parseNumber("0")).toBe(0);
expect(parseNumber("10")).toBe(10);
});
test("parses valid floats", () => {
expect(parseNumber("5.5")).toBe(5.5);
expect(parseNumber("0.1")).toBe(0.1);
});
test("parses negative numbers", () => {
expect(parseNumber("-5")).toBe(-5);
expect(parseNumber("-5.5")).toBe(-5.5);
});
test("handles ampersand replacement", () => {
expect(parseNumber("5&5")).toBe(null); // Invalid after replacement
});
test("returns null for invalid strings", () => {
expect(parseNumber("abc")).toBeNull();
expect(parseNumber("")).toBeNull();
expect(parseNumber("5a")).toBeNull();
});
test("returns null for NaN result", () => {
expect(parseNumber("NaN")).toBeNull();
});
});
@@ -1,31 +0,0 @@
/**
* Simple parsing helpers for URL parameter values
*/
/**
* Parse comma-separated values from URL parameter
* Used for multi-select and ranking elements
* Handles whitespace trimming and empty values
*/
export const parseCommaSeparated = (value: string): string[] => {
return value
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
};
/**
* Parse number from URL parameter
* Used for NPS and Rating elements
* Returns null if parsing fails
*/
export const parseNumber = (value: string): number | null => {
try {
// Handle `&` being used instead of `;` in some cases
const cleanedValue = value.replaceAll("&", ";");
const num = Number(JSON.parse(cleanedValue));
return Number.isNaN(num) ? null : num;
} catch {
return null;
}
};
@@ -1,100 +0,0 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { parseNumber } from "./parsers";
import {
TValidationResult,
isMultiChoiceResult,
isPictureSelectionResult,
isSingleChoiceResult,
} from "./types";
export const transformOpenText = (answer: string): string => {
return answer;
};
export const transformMultipleChoiceSingle = (
validationResult: TValidationResult,
answer: string,
language: string
): string => {
if (!isSingleChoiceResult(validationResult)) return answer;
const { matchedChoice } = validationResult;
// If we have a matched choice, return its label
if (matchedChoice) {
return matchedChoice.label[language] || answer;
}
// If no matched choice (null), it's an "other" value - return original
return answer;
};
export const transformMultipleChoiceMulti = (validationResult: TValidationResult): string[] => {
if (!isMultiChoiceResult(validationResult)) return [];
const { matched, others } = validationResult;
// Return matched choices + joined "other" values as single string
if (others.length > 0) {
return [...matched, others.join(",")];
}
return matched;
};
export const transformNPS = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformRating = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformConsent = (answer: string): string => {
if (answer === "dismissed") return "";
return answer;
};
export const transformPictureSelection = (validationResult: TValidationResult): string[] => {
if (!isPictureSelectionResult(validationResult)) return [];
return validationResult.selectedIds;
};
/**
* Main transformation dispatcher
* Routes to appropriate transformer based on element type
* Uses pre-matched data from validation result to avoid duplicate matching
*/
export const transformElement = (
validationResult: TValidationResult,
answer: string,
language: string
): string | number | string[] => {
if (!validationResult.isValid) return "";
try {
switch (validationResult.type) {
case TSurveyElementTypeEnum.OpenText:
return transformOpenText(answer);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return transformMultipleChoiceSingle(validationResult, answer, language);
case TSurveyElementTypeEnum.Consent:
return transformConsent(answer);
case TSurveyElementTypeEnum.Rating:
return transformRating(answer);
case TSurveyElementTypeEnum.NPS:
return transformNPS(answer);
case TSurveyElementTypeEnum.PictureSelection:
return transformPictureSelection(validationResult);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return transformMultipleChoiceMulti(validationResult);
default:
return "";
}
} catch {
return "";
}
};
@@ -1,62 +0,0 @@
import { TSurveyElementChoice, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
type TInvalidResult = {
isValid: false;
};
// Base valid result for simple types (no match data needed)
type TSimpleValidResult = {
isValid: true;
};
// Single choice match result (MultipleChoiceSingle)
type TSingleChoiceValidResult = {
isValid: true;
matchedChoice: TSurveyElementChoice | null; // null means "other" value
};
// Multi choice match result (MultipleChoiceMulti)
type TMultiChoiceValidResult = {
isValid: true;
matched: string[]; // matched labels
others: string[]; // other text values
};
// Picture selection result (indices are already validated)
type TPictureSelectionValidResult = {
isValid: true;
selectedIds: string[];
};
// Discriminated union for all validation results
export type TValidationResult =
| (TInvalidResult & { type?: TSurveyElementTypeEnum })
| (TSimpleValidResult & {
type:
| TSurveyElementTypeEnum.OpenText
| TSurveyElementTypeEnum.NPS
| TSurveyElementTypeEnum.Rating
| TSurveyElementTypeEnum.Consent;
})
| (TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle })
| (TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti })
| (TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection });
// Type guards for narrowing validation results
export const isValidResult = (result: TValidationResult): result is TValidationResult & { isValid: true } =>
result.isValid;
export const isSingleChoiceResult = (
result: TValidationResult
): result is TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
export const isMultiChoiceResult = (
result: TValidationResult
): result is TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceMulti;
export const isPictureSelectionResult = (
result: TValidationResult
): result is TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection } =>
result.isValid && result.type === TSurveyElementTypeEnum.PictureSelection;
@@ -1,228 +0,0 @@
import {
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyPictureSelectionElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { matchOptionByIdOrLabel } from "./matchers";
import { parseCommaSeparated, parseNumber } from "./parsers";
import { TValidationResult } from "./types";
const invalid = (type?: TSurveyElementTypeEnum): TValidationResult => ({ isValid: false, type });
export const validateOpenText = (): TValidationResult => {
return { isValid: true, type: TSurveyElementTypeEnum.OpenText };
};
export const validateMultipleChoiceSingle = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
const hasOther = element.choices.at(-1)?.id === "other";
// Try matching by ID or label (new: supports both)
const matchedChoice = matchOptionByIdOrLabel(element.choices, answer, language);
if (matchedChoice) {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice,
};
}
// If no match and has "other" option, accept any non-empty text as "other" value
if (hasOther) {
const trimmedAnswer = answer.trim();
if (trimmedAnswer !== "") {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice: null, // null indicates "other" value
};
}
}
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
};
export const validateMultipleChoiceMulti = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
const hasOther = element.choices.at(-1)?.id === "other";
const lastChoiceLabel = hasOther ? element.choices.at(-1)?.label?.[language] : undefined;
const answerChoices = parseCommaSeparated(answer);
if (answerChoices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
// Process all answers and collect results
const matched: string[] = [];
const others: string[] = [];
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchedChoice = matchOptionByIdOrLabel(element.choices, ans, language);
if (matchedChoice) {
const label = matchedChoice.label[language];
if (label) {
matched.push(label);
}
continue;
}
// Check if it's the "Other" label itself
if (ans === lastChoiceLabel) {
continue;
}
// It's a free-text "other" value
if (hasOther) {
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti); // Only one free-text "other" value allowed
}
others.push(ans);
} else {
// No "other" option and doesn't match any choice
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
}
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
matched,
others,
};
};
export const validateNPS = (answer: string): TValidationResult => {
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 0 || answerNumber > 10) {
return invalid(TSurveyElementTypeEnum.NPS);
}
return { isValid: true, type: TSurveyElementTypeEnum.NPS };
};
export const validateConsent = (element: TSurveyConsentElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Consent) {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (element.required && answer === "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (answer !== "accepted" && answer !== "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
return { isValid: true, type: TSurveyElementTypeEnum.Consent };
};
export const validateRating = (element: TSurveyRatingElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Rating) {
return invalid(TSurveyElementTypeEnum.Rating);
}
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 1 || answerNumber > (element.range ?? 5)) {
return invalid(TSurveyElementTypeEnum.Rating);
}
return { isValid: true, type: TSurveyElementTypeEnum.Rating };
};
export const validatePictureSelection = (
element: TSurveyPictureSelectionElement,
answer: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const answerChoices = parseCommaSeparated(answer);
const selectedIds: string[] = [];
// Validate all indices and collect selected IDs
for (const ans of answerChoices) {
const num = parseNumber(ans);
if (num === null || num < 1 || num > element.choices.length) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const index = num - 1;
const choice = element.choices[index];
if (choice?.id) {
selectedIds.push(choice.id);
}
}
// Apply allowMulti constraint
const finalIds = element.allowMulti ? selectedIds : selectedIds.slice(0, 1);
return {
isValid: true,
type: TSurveyElementTypeEnum.PictureSelection,
selectedIds: finalIds,
};
};
/**
* Main validation dispatcher
* Routes to appropriate validator based on element type
* Returns validation result with match data for transformers
*/
export const validateElement = (
element: TSurveyElement,
answer: string,
language: string
): TValidationResult => {
// Empty required fields are invalid
if (element.required && (!answer || answer === "")) {
return invalid(element.type);
}
try {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return validateOpenText();
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return validateMultipleChoiceSingle(element, answer, language);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return validateMultipleChoiceMulti(element, answer, language);
case TSurveyElementTypeEnum.NPS:
return validateNPS(answer);
case TSurveyElementTypeEnum.Consent:
return validateConsent(element, answer);
case TSurveyElementTypeEnum.Rating:
return validateRating(element, answer);
case TSurveyElementTypeEnum.PictureSelection:
return validatePictureSelection(element, answer);
default:
return invalid();
}
} catch {
return invalid(element.type);
}
};
@@ -3,9 +3,9 @@ import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getPrefillValue } from "./index";
import { getPrefillValue } from "./utils";
describe("prefill integration tests", () => {
describe("survey link utils", () => {
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -76,7 +76,15 @@ describe("prefill integration tests", () => {
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: "q7",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA Question" },
required: false,
buttonLabel: { default: "Click me" },
buttonExternal: false,
buttonUrl: "",
},
{
id: "q8",
type: TSurveyElementTypeEnum.Consent,
@@ -154,21 +162,13 @@ describe("prefill integration tests", () => {
expect(result).toEqual({ q1: "Open text answer" });
});
test("validates MultipleChoiceSingle questions with label", () => {
test("validates MultipleChoiceSingle questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Option 1");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q2: "Option 1" });
});
test("validates MultipleChoiceSingle questions with option ID", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "c2");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option ID is converted to label
expect(result).toEqual({ q2: "Option 2" });
});
test("invalidates MultipleChoiceSingle with non-existent option", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Non-existent option");
@@ -183,29 +183,13 @@ describe("prefill integration tests", () => {
expect(result).toEqual({ q3: "Custom answer" });
});
test("handles MultipleChoiceMulti questions with labels", () => {
test("handles MultipleChoiceMulti questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti questions with option IDs", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,c5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option IDs are converted to labels
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with mixed IDs and labels", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Mixed: ID converted to label + label stays as-is
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with Other", () => {
const searchParams = new URLSearchParams();
searchParams.set("q5", "Option 6,Custom answer");
@@ -227,6 +211,20 @@ describe("prefill integration tests", () => {
expect(result).toBeUndefined();
});
test("handles CTA questions with clicked value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "clicked");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "clicked" });
});
test("handles CTA questions with dismissed value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "dismissed");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "" });
});
test("validates Consent questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q8", "accepted");
@@ -295,18 +293,4 @@ describe("prefill integration tests", () => {
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toBeUndefined();
});
test("handles whitespace in comma-separated values", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4 , Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("ignores trailing commas in multi-select", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5,");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
});
+230 -2
View File
@@ -1,2 +1,230 @@
// Prefilling logic has been moved to @/modules/survey/link/lib/prefill
// This file is kept for any future utility functions
import { TResponseData } from "@formbricks/types/responses";
import {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillAnswer: TResponseData = {};
const questions = getElementsFromBlocks(survey.blocks);
const questionIdxMap = questions.reduce(
(acc, question, idx) => {
acc[question.id] = idx;
return acc;
},
{} as Record<string, number>
);
searchParams.forEach((value, key) => {
if (FORBIDDEN_IDS.includes(key)) return;
const questionId = key;
const questionIdx = questionIdxMap[questionId];
const question = questions[questionIdx];
const answer = value;
if (question) {
if (checkValidity(question, answer, languageId)) {
prefillAnswer[questionId] = transformAnswer(question, answer, languageId);
}
}
});
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
};
const validateOpenText = (): boolean => {
return true;
};
const validateMultipleChoiceSingle = (
question: TSurveyMultipleChoiceElement,
answer: string,
language: string
): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
const choices = question.choices;
const hasOther = choices[choices.length - 1].id === "other";
if (!hasOther) {
return choices.some((choice) => choice.label[language] === answer);
}
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
if (matchesAnyChoice) {
return true;
}
const trimmedAnswer = answer.trim();
return trimmedAnswer !== "";
};
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
const choices = (
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
).choices;
const hasOther = choices[choices.length - 1].id === "other";
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
const answerChoices = answer
.split(",")
.map((ans) => ans.trim())
.filter((ans) => ans !== "");
if (answerChoices.length === 0) {
return false;
}
if (!hasOther) {
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
}
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
if (matchesChoice) {
continue;
}
if (ans === lastChoiceLabel) {
continue;
}
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return false;
}
}
return true;
};
const validateNPS = (answer: string): boolean => {
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
} catch {
return false;
}
};
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "clicked" || answer === "dismissed";
};
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "accepted" || answer === "dismissed";
};
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
const ratingQuestion = question;
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
} catch {
return false;
}
};
const validatePictureSelection = (answer: string): boolean => {
const answerChoices = answer.split(",");
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
};
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.required && (!answer || answer === "")) return false;
const validators: Partial<
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
> = {
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
};
const validator = validators[question.type];
if (!validator) return false;
try {
return validator(question, answer, language);
} catch {
return false;
}
};
const transformAnswer = (
question: TSurveyElement,
answer: string,
language: string
): string | number | string[] => {
switch (question.type) {
case TSurveyElementTypeEnum.OpenText:
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
return answer;
}
case TSurveyElementTypeEnum.Consent:
case TSurveyElementTypeEnum.CTA: {
if (answer === "dismissed") return "";
return answer;
}
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.NPS: {
const cleanedAnswer = answer.replace(/&/g, ";");
return Number(JSON.parse(cleanedAnswer));
}
case TSurveyElementTypeEnum.PictureSelection: {
const answerChoicesIdx = answer.split(",");
const answerArr: string[] = [];
answerChoicesIdx.forEach((ansIdx) => {
const choice = question.choices[Number(ansIdx) - 1];
if (choice) answerArr.push(choice.id);
});
if (question.allowMulti) return answerArr;
return answerArr.slice(0, 1);
}
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
let ansArr = answer.split(",");
const hasOthers = question.choices[question.choices.length - 1].id === "other";
if (!hasOthers) return ansArr;
// answer can be "a,b,c,d" and options can be a,c,others so we are filtering out the options that are not in the options list and sending these non-existing values as a single string(representing others) like "a", "c", "b,d"
const options = question.choices.map((o) => o.label[language]);
const others = ansArr.filter((a: string) => !options.includes(a));
if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a));
if (others.length > 0) ansArr.push(others.join(","));
return ansArr;
}
default:
return "";
}
};

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