mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-17 11:39:06 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85285d1fe1 | |||
| 1ae98226ad | |||
| d25dc8f85d |
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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}"],
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-4
@@ -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 } }}
|
||||
|
||||
+1
-5
@@ -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")}
|
||||
|
||||
+194
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
+26
-2
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -18,8 +18,7 @@
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU"
|
||||
"sv-SE"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
+8
-13
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 că {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
@@ -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 på 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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-1
@@ -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,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,
|
||||
},
|
||||
};
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
+6
-8
@@ -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;
|
||||
+2
-1
@@ -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 (
|
||||
+14
-18
@@ -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;
|
||||
+14
-20
@@ -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;
|
||||
+6
-17
@@ -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;
|
||||
+23
-47
@@ -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} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
+27
-43
@@ -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"] });
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user