Compare commits

...

7 Commits

Author SHA1 Message Date
Piyush Gupta
c54a48e70b docs: adds email followup docs (#4858)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-03-04 16:02:54 +00:00
Johannes
884b6f12ae docs: update API intro and key management docs (#4841) 2025-03-04 15:36:23 +00:00
Piyush Gupta
5cae0febc9 fix: variables initialization in logic editor preview (#4819)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-04 14:58:13 +00:00
Dhruwang Jariwala
0e898db710 chore: Remove lib dependency from survey package (#4767)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-04 14:58:00 +00:00
Johannes
40d54d60d4 docs: Release test environment docs (#4842) 2025-03-04 14:57:41 +00:00
Matti Nannt
269e026381 fix: sonarqube action not running in merge queue (#4861) 2025-03-04 15:57:26 +01:00
Matti Nannt
8245f2f6af chore: update sonar-config to properly scan apps/web (#4844) 2025-03-03 18:38:16 +01:00
82 changed files with 1165 additions and 302 deletions

View File

@@ -6,6 +6,7 @@ on:
- main
pull_request:
types: [opened, synchronize, reopened]
merge_group:
permissions:
contents: read
jobs:

View File

@@ -0,0 +1,44 @@
---
title: "Generate API Key"
icon: "key"
description: "Here is how you can generate an API key which gives you full access to the Formbricks Management API. Keep it safe!"
---
<Note>
As of now, API keys are located in the Project Configuration page. We are moving them to the Organization Settings page in the upcoming release. For you, nothing will change.
</Note>
## Generate API key
<Steps>
<Step title="Navigate to 'Configuration'">
Go to the Configuration page of your project:
![Configuration Page](/images/api-reference/config.webp)
</Step>
<Step title="Access 'API Keys' tab">
Click on the **API Keys** tab.
</Step>
<Step title="Generate key">
Decide if you want to generate a key for the development or production environment. If you want to switch environemnts you can do so in the top right corner. Click on the corresponding button.
</Step>
<Step title="Add a label to your key">
Add a label to your key to help you identify it.
![Label key](/images/api-reference/label.webp)
</Step>
<Step title="Copy your key">
Copy the API key and save it in a secure location. You won't be able to see it again.
<Note>
Store API key safely! Anyone who has your API key has full control over your
account. For security reasons, you cannot view the API key again.
</Note>
</Step>
</Steps>
## Delete API key
- On **Configuration** > **API Keys** page, find the key you wish to revoke and select “Delete”.
- Your API key will stop working immediately.

View File

@@ -1,14 +0,0 @@
---
title: "API v1.0.0"
icon: "code-compare"
---
Formbricks offers two types of APIs: the **Public Client API** and the **Management API**. Each API serves a different purpose, has *different* authentication requirements, and provides access to different data and settings.
### API Key Setup
Checkout the [API Key Setup](/api-reference/rest-api) to access the Management APIs with an API Key.
If youve forked the collection and are running it, update the `apiKey` and `environmentId` in the collection variables with your values. We also provide post-run scripts to help auto-assign variables when running scripts.
Need more help? Visit our [Website](https://formbricks.com/) or join our [Discord](https://formbricks.com/discord)!

View File

@@ -7725,7 +7725,7 @@
}
},
"summary": "Health Check",
"tags": ["default"]
"tags": ["Health"]
}
}
},

View File

@@ -5,9 +5,8 @@ description: "
Formbricks provides two APIs: the Public Client API for frontend survey interactions and the Management API for backend management tasks."
---
<Info>
View our [API Documentation](/api-reference) in more than 30 frameworks and languages.
</Info>
Formbricks offers two types of APIs: the **Public Client API** and the **Management API**. Each API serves a different purpose, has *different* authentication requirements, and provides access to different data and settings.
## Public Client API
@@ -17,7 +16,7 @@ We currently have the following Client API methods exposed and below is their do
- [Displays API](/api-reference/client-api->-display/create-display) - Mark a survey as displayed or link a display to a response for a person.
- [People API](/api-reference/client-api->-people/create-person) - Create & Update a Person (e.g., attributes, email, userId, etc.)
- [Contacts API](/api-reference/client-api->-contacts/update-contact-attributes) - Update contact attributes.
- [Responses API](/api-reference/client-api->-response/create-response) - Create & Update a Response for a Survey.
@@ -41,88 +40,7 @@ We currently have the following Management API methods exposed and below is thei
- [Webhook API](/api-reference/management-api->-webhook/get-all-webhooks) - List, Create, and Delete Webhooks
## How to Generate an API key
API requests require a personal API key for authorization. This API key gives you the same rights as if you were logged in at Formbricks UI - **keep it private!**
- Go to your settings on [Formbricks UI](https://app.formbricks.com).
- Go to page “API keys”
![API Keys](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738097810/image_jvhqsd.jpg)
- Create a key for the development or production environment.
- Copy the key immediately. You wont be able to see it again.
![API Key Label](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738098072/image_zjkvok.jpg)
<Note>
**Store API key safely! Anyone who has your API key has full control over your
account. For security reasons, you cannot view the API key again.**
</Note>
## Test your API Key
Hit the below request to verify that you are authenticated with your API Key and the server is responding.
### Get My Profile
Get the project details and environment type of your account.
### Mandatory Headers
| Name | x-Api-Key |
| --------------- | ------------------------ |
| **Type** | string |
| **Description** | Your Formbricks API key. |
### Request
```bash cURL
GET - /api/v1/me
curl --location \
'https://app.formbricks.com/api/v1/me' \
--header \
'x-api-key: <your-api-key>'
```
### Response
<CodeGroup>
```bash 200 (Success)
{
"id": "cll2m30r70004mx0huqkitgqv",
"createdAt": "2023-08-08T18:04:59.922Z",
"updatedAt": "2023-08-08T18:04:59.922Z",
"type": "production",
"project": {
"id": "cll2m30r60003mx0hnemjfckr",
"name": "My Project"
},
"appSetupCompleted": false,
"websiteSetupCompleted": false,
}
```
```bash 401 (Not Authenticated)
Not authenticated
```
</CodeGroup>
### Delete a personal API key
- Go to settings on [app.formbricks.com](https://app.formbricks.com/).
- Go to the page “API keys”.
- Find the key you wish to revoke and select “Delete”.
- Your API key will stop working immediately.
---

View File

@@ -0,0 +1,50 @@
---
title: "Test API Key"
icon: "message-check"
description: "Here is how you can test your API key to make sure it is working."
---
To test if your API key is working, you can use the following request:
### Mandatory Headers
| Name | x-Api-Key |
| --------------- | ------------------------ |
| **Type** | string |
| **Description** | Your Formbricks API key. |
### Request
```bash cURL
GET - /api/v1/me
curl --location \
'https://app.formbricks.com/api/v1/me' \
--header \
'x-api-key: <your-api-key>'
```
### Response
<CodeGroup>
```bash 200 (Success)
{
"id": "cll2m30r70004mx0huqkitgqv",
"createdAt": "2023-08-08T18:04:59.922Z",
"updatedAt": "2023-08-08T18:04:59.922Z",
"type": "production",
"project": {
"id": "cll2m30r60003mx0hnemjfckr",
"name": "My Project"
},
"appSetupCompleted": false,
"websiteSetupCompleted": false,
}
```
```bash 401 (Not Authenticated)
Not authenticated
```
</CodeGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -48,7 +48,8 @@
"xm-and-surveys/surveys/general-features/schedule-start-end-dates",
"xm-and-surveys/surveys/general-features/metadata",
"xm-and-surveys/surveys/general-features/variables",
"xm-and-surveys/surveys/general-features/hide-back-button"
"xm-and-surveys/surveys/general-features/hide-back-button",
"xm-and-surveys/surveys/general-features/email-followups"
]
},
{
@@ -90,33 +91,33 @@
]
}
]
},
{
"group": "Question Types",
"icon": "question",
"pages": [
"xm-and-surveys/surveys/question-type/address",
"xm-and-surveys/surveys/question-type/consent",
"xm-and-surveys/surveys/question-type/contact-info",
"xm-and-surveys/surveys/question-type/date",
"xm-and-surveys/surveys/question-type/file-upload",
"xm-and-surveys/surveys/question-type/free-text",
"xm-and-surveys/surveys/question-type/matrix",
"xm-and-surveys/surveys/question-type/net-promoter-score",
"xm-and-surveys/surveys/question-type/ranking",
"xm-and-surveys/surveys/question-type/rating",
"xm-and-surveys/surveys/question-type/schedule-a-meeting",
"xm-and-surveys/surveys/question-type/select-multiple",
"xm-and-surveys/surveys/question-type/select-picture",
"xm-and-surveys/surveys/question-type/select-single",
"xm-and-surveys/surveys/question-type/statement-cta"
]
}
]
},
{
"group": "Core Features",
"pages": [
{
"group": "Question Types",
"icon": "question",
"pages": [
"xm-and-surveys/core-features/question-type/address",
"xm-and-surveys/core-features/question-type/consent",
"xm-and-surveys/core-features/question-type/contact-info",
"xm-and-surveys/core-features/question-type/date",
"xm-and-surveys/core-features/question-type/file-upload",
"xm-and-surveys/core-features/question-type/free-text",
"xm-and-surveys/core-features/question-type/matrix",
"xm-and-surveys/core-features/question-type/net-promoter-score",
"xm-and-surveys/core-features/question-type/ranking",
"xm-and-surveys/core-features/question-type/rating",
"xm-and-surveys/core-features/question-type/schedule-a-meeting",
"xm-and-surveys/core-features/question-type/select-multiple",
"xm-and-surveys/core-features/question-type/select-picture",
"xm-and-surveys/core-features/question-type/select-single",
"xm-and-surveys/core-features/question-type/statement-cta"
]
},
{
"group": "Integrations",
"icon": "bridge",
@@ -136,7 +137,8 @@
},
"xm-and-surveys/core-features/user-management",
"xm-and-surveys/core-features/styling-theme",
"xm-and-surveys/core-features/email-customization"
"xm-and-surveys/core-features/email-customization",
"xm-and-surveys/core-features/test-environment"
]
},
{
@@ -288,7 +290,7 @@
},
{
"group": "API Documentation",
"pages": ["api-reference/introduction", "api-reference/rest-api"]
"pages": ["api-reference/rest-api", "api-reference/generate-key", "api-reference/test-key"]
}
],
"redirects": [

View File

@@ -0,0 +1,39 @@
---
title: "Test Environment"
icon: "code"
description: "Each project on Formbricks has two environments: a test environment and a production environment. The test environment is used to test your surveys through before they are sent out to your customers."
---
To clearly separate between test and production data, Formbricks provides two different environments for each project. These environments are completely independent of each other **incl. API keys, project configuration and environment Id's** used for connecting Formbricks to run in-product surveys.
## Toggle between environments
You can toggle between environments by clicking the environment name in the top right corner of the Formbricks app:
![Toggling between environments](/images/xm-and-surveys/core-features/test-environment/toggle.webp)
### Dev Environment (Testing)
The test environment is used to test your project before it goes live in the production environment. If you see an orange banner at the top, you are currently in the test environment:
![Test environment](/images/xm-and-surveys/core-features/test-environment/test-env.webp)
### Production Environment
The production environment is used to serve your surveys to your respondents.
## Copy surveys between environments
<Steps>
<Step title="Click More Actions">
Click on the "More actions" button of a survey in the survey list
![More actions](/images/xm-and-surveys/core-features/test-environment/more-actions.webp)
</Step>
<Step title="Select Target Environment">
Choose which environment you want to copy the survey to
![Copy survey](/images/xm-and-surveys/core-features/test-environment/modal.webp)
</Step>
</Steps>

View File

@@ -0,0 +1,74 @@
---
title: "Email Follow-ups"
description: "Follow-ups are a feature that allows you to send emails to your users on different survey events."
icon: "envelope"
---
## Overview
The email followup feature allows survey creators to automatically send customized emails to respondents based on their survey responses or when they reach specific survey endings. This feature is particularly useful for following up with respondents, sending thank you notes, or providing additional information.
<Note>
Email followups is a paid feature. It is only available for users on paid plans or have an enterprise license.
</Note>
## Key Components
### 1. Trigger Types
There are two types of triggers for email followups:
- **Response-based**: Triggered when a response is submitted
- **Ending-based**: Triggered when respondents reach specific survey endings
### 2. Email Configuration
Each followup email can be configured with:
- **Name**: A descriptive name for the followup
- **To**: Email recipient (sourced from):
- Open text questions with email input type
- Contact info questions
- Hidden fields
- **Reply-To**: One or more email addresses for replies
- **Subject**: Email subject line
- **Body**: HTML-formatted email content
## Setup Process
1. Navigate to the survey editor
2. Access the `follow-ups` section
![Followups tab](/images/xm-and-surveys/core-features/email-followups/followups-tab.webp)
3. Click the "New follow-up" button to add a new followup
4. Fill in the required information:
- Followup name
- Trigger type (response or endings)
![Followup form](/images/xm-and-surveys/core-features/email-followups/followup-form.webp)
5. **Configuring Recipients**:
The "To" field can be configured to use:
- Responses from email-type open text questions
- Responses from contact info questions
- Values from hidden fields
6. **Configure the Reply-To**:
- Add one or more valid email addresses
- Addresses can be added by typing and pressing space or comma
- Invalid email addresses are automatically rejected
![Followup recipient](/images/xm-and-surveys/core-features/email-followups/followup-recipient.webp)
7. **Configuring the Email Content**:
- Subject
- Body: Supports basic HTML formatting (p, span, b, strong, i, em, a, br tags)
![Followup content](/images/xm-and-surveys/core-features/email-followups/followup-content.webp)
8. **Save and Activate**

View File

@@ -1,7 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { createId } from "@paralleldrive/cuid2";
const rl = readline.createInterface({
input: process.stdin,

View File

@@ -1,8 +1,8 @@
import { type Prisma, PrismaClient } from "@prisma/client";
import { exec } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { type Prisma, PrismaClient } from "@prisma/client";
const execAsync = promisify(exec);

View File

@@ -6,6 +6,7 @@
"continue_with_google": "Login mit Google",
"continue_with_oidc": "Weiter mit {oidcDisplayName}",
"continue_with_openid": "Login mit OpenID",
"continue_with_saml": "Login mit SAML SSO",
"forgot-password": {
"back_to_login": "Zurück zum Login",
"email-sent": {
@@ -52,6 +53,7 @@
"new_to_formbricks": "Neu bei Formbricks?",
"use_a_backup_code": "Einen Backup-Code verwenden"
},
"saml_connection_error": "Etwas ist schiefgelaufen. Bitte überprüfe die App-Konsole für weitere Details.",
"signup": {
"captcha_failed": "reCAPTCHA fehlgeschlagen",
"have_an_account": "Hast Du ein Konto?",
@@ -1894,7 +1896,6 @@
"s": {
"check_inbox_or_spam": "Bitte überprüfe auch deinen Spam-Ordner, falls Du die E-Mail nicht in deinem Posteingang siehst.",
"completed": "Diese kostenlose und quelloffene Umfrage wurde geschlossen.",
"could_not_create_display": "Konnte Anzeige nicht erstellen",
"create_your_own": "Erstelle deine eigene",
"enter_pin": "Diese Umfrage ist geschützt. Gib unten die PIN ein",
"just_curious": "Einfach neugierig?",

View File

@@ -6,6 +6,7 @@
"continue_with_google": "Continue with Google",
"continue_with_oidc": "Continue with {oidcDisplayName}",
"continue_with_openid": "Continue with OpenID",
"continue_with_saml": "Continue with SAML SSO",
"forgot-password": {
"back_to_login": "Back to login",
"email-sent": {
@@ -52,6 +53,7 @@
"new_to_formbricks": "New to Formbricks?",
"use_a_backup_code": "Use a backup code"
},
"saml_connection_error": "Something went wrong. Please check your app console for more details.",
"signup": {
"captcha_failed": "Captcha failed",
"have_an_account": "Have an account?",
@@ -265,7 +267,7 @@
"off": "Off",
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners, managers and manage access members can perform this action.",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"or": "or",
"organization": "Organization",
"organization_not_found": "Organization not found",
@@ -1894,7 +1896,6 @@
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you don't see the email in your inbox.",
"completed": "This free & open-source survey has been closed.",
"could_not_create_display": "Could not create display",
"create_your_own": "Create your own",
"enter_pin": "This survey is protected. Enter the PIN below",
"just_curious": "Just curious?",

View File

@@ -6,6 +6,7 @@
"continue_with_google": "Continuer avec Google",
"continue_with_oidc": "Continuer avec {oidcDisplayName}",
"continue_with_openid": "Continuer avec OpenID",
"continue_with_saml": "Continuer avec SAML SSO",
"forgot-password": {
"back_to_login": "Retour à la connexion",
"email-sent": {
@@ -52,6 +53,7 @@
"new_to_formbricks": "Nouveau sur Formbricks ?",
"use_a_backup_code": "Utiliser un code de secours"
},
"saml_connection_error": "Quelque chose s'est mal passé. Veuillez vérifier la console de votre application pour plus de détails.",
"signup": {
"captcha_failed": "Captcha échoué",
"have_an_account": "Avez-vous un compte ?",
@@ -1894,7 +1896,6 @@
"s": {
"check_inbox_or_spam": "Veuillez également vérifier votre dossier de spam si vous ne voyez pas l'e-mail dans votre boîte de réception.",
"completed": "Cette enquête gratuite et open-source a été fermée.",
"could_not_create_display": "Impossible de créer l'affichage",
"create_your_own": "Créez le vôtre",
"enter_pin": "Ce sondage est protégé. Entrez le code PIN ci-dessous",
"just_curious": "Juste curieux ?",

View File

@@ -6,6 +6,7 @@
"continue_with_google": "Continuar com o Google",
"continue_with_oidc": "Continuar com {oidcDisplayName}",
"continue_with_openid": "Continuar com OpenID",
"continue_with_saml": "Continuar com SAML SSO",
"forgot-password": {
"back_to_login": "Voltar para o login",
"email-sent": {
@@ -52,6 +53,7 @@
"new_to_formbricks": "Novo no Formbricks?",
"use_a_backup_code": "Usar um código de backup"
},
"saml_connection_error": "Algo deu errado. Por favor, verifica o console do app para mais detalhes.",
"signup": {
"captcha_failed": "reCAPTCHA falhou",
"have_an_account": "Já tem uma conta?",
@@ -1894,7 +1896,6 @@
"s": {
"check_inbox_or_spam": "Por favor, dá uma olhada na sua pasta de spam se você não encontrar o e-mail na sua caixa de entrada.",
"completed": "Essa pesquisa gratuita e de código aberto foi encerrada.",
"could_not_create_display": "Não foi possível criar a tela",
"create_your_own": "Crie o seu próprio",
"enter_pin": "Essa pesquisa está protegida. Insira o PIN abaixo",
"just_curious": "Só curioso?",

View File

@@ -6,6 +6,7 @@
"continue_with_google": "使用 Google 繼續",
"continue_with_oidc": "使用 '{'oidcDisplayName'}' 繼續",
"continue_with_openid": "使用 OpenID 繼續",
"continue_with_saml": "使用 SAML SSO 繼續",
"forgot-password": {
"back_to_login": "返回登入",
"email-sent": {
@@ -52,6 +53,7 @@
"new_to_formbricks": "初次使用 Formbricks",
"use_a_backup_code": "使用備份碼"
},
"saml_connection_error": "發生錯誤。請檢查您的 app 主控台以取得更多詳細資料。",
"signup": {
"captcha_failed": "驗證碼失敗",
"have_an_account": "已有帳戶?",
@@ -1894,7 +1896,6 @@
"s": {
"check_inbox_or_spam": "如果您的收件匣中沒有看到電子郵件,也請檢查您的垃圾郵件資料夾。",
"completed": "此免費且開源的問卷已關閉。",
"could_not_create_display": "無法建立顯示",
"create_your_own": "建立您自己的",
"enter_pin": "此問卷已受保護。請輸入下方 PIN 碼",
"just_curious": "只是好奇?",

View File

@@ -1,4 +1,4 @@
export const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
// return undefined if hex is undefined, this is important for adding the default values to the CSS variables
// TODO: find a better way to handle this
if (!hex || hex === "") return undefined;
@@ -17,34 +17,6 @@ export const hexToRGBA = (hex: string | undefined, opacity: number): string | un
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
export const lightenDarkenColor = (hexColor: string, magnitude: number): string => {
hexColor = hexColor.replace(`#`, ``);
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
if (hexColor.length === 3) {
hexColor = hexColor
.split("")
.map((char) => char + char)
.join("");
}
if (hexColor.length === 6) {
let decimalColor = parseInt(hexColor, 16);
let r = (decimalColor >> 16) + magnitude;
r = Math.max(0, Math.min(255, r)); // Clamp value between 0 and 255
let g = ((decimalColor >> 8) & 0x00ff) + magnitude;
g = Math.max(0, Math.min(255, g)); // Clamp value between 0 and 255
let b = (decimalColor & 0x0000ff) + magnitude;
b = Math.max(0, Math.min(255, b)); // Clamp value between 0 and 255
// Convert back to hex and return
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
} else {
// Return the original color if it's neither 3 nor 6 characters
return hexColor;
}
};
export const mixColor = (hexColor: string, mixWithHex: string, weight: number): string => {
// Convert both colors to RGBA format
const color1 = hexToRGBA(hexColor, 1) || "";

View File

@@ -1,17 +1,8 @@
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
// Helper function to calculate difference in days between two dates
export const diffInDays = (date1: Date, date2: Date) => {
@@ -19,42 +10,12 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
// Helper function to get the month name
export const getMonthName = (monthIndex: number) => {
return monthNames[monthIndex];
};
export const formatDateWithOrdinal = (date: Date): string => {
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
const dayOfWeekNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const dayOfWeek = dayOfWeekNames[date.getDay()];
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
const day = date.getDate();
const monthIndex = date.getMonth();
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
const year = date.getFullYear();
return `${dayOfWeek}, ${monthNames[monthIndex]} ${day}${getOrdinalSuffix(day)}, ${year}`;
};
// Helper function to format the date with an ordinal suffix
export const getOrdinalDate = (date: number) => {
const j = date % 10,
k = date % 100;
if (j === 1 && k !== 11) {
return date + "st";
}
if (j === 2 && k !== 12) {
return date + "nd";
}
if (j === 3 && k !== 13) {
return date + "rd";
}
return date + "th";
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};
export const isValidDateString = (value: string) => {

View File

@@ -21,7 +21,7 @@ export const checkForYoutubeUrl = (url: string): boolean => {
}
};
export const checkForVimeoUrl = (url: string): boolean => {
const checkForVimeoUrl = (url: string): boolean => {
try {
const vimeoUrl = new URL(url);
@@ -37,7 +37,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
}
};
export const checkForLoomUrl = (url: string): boolean => {
const checkForLoomUrl = (url: string): boolean => {
try {
const loomUrl = new URL(url);

View File

@@ -1,13 +1,13 @@
/* eslint-disable no-console -- debugging*/
import React, { type JSX, useEffect, useRef, useState } from "react";
import { Modal } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils";
import { SurveyStore } from "@/lib/survey/store";
import { type TEnvironmentStateSurvey, type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config";
import type { SurveyContainerProps } from "@/types/survey";
import React, { type JSX, useEffect, useRef, useState } from "react";
import { Modal } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();

View File

@@ -4,9 +4,9 @@ import { LoadingSpinner } from "@/components/general/loading-spinner";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { replaceRecallInfo } from "@/lib/recall";
import { useEffect } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TSurveyEndScreenCard, type TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";

View File

@@ -1,8 +1,8 @@
import { getOriginalFileNameFromUrl } from "@/lib/storage";
import { isFulfilled, isRejected } from "@/lib/utils";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useMemo, useState } from "preact/hooks";
import { type JSXInternal } from "preact/src/jsx";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import { isFulfilled, isRejected } from "@formbricks/lib/utils/promises";
import { type TAllowedFileExtension } from "@formbricks/types/common";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TUploadFileConfig } from "@formbricks/types/storage";

View File

@@ -1,5 +1,5 @@
import { GlobeIcon } from "@/components/general/globe-icon";
import { useClickOutside } from "@/lib/utils";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { useRef, useState } from "preact/hooks";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";

View File

@@ -13,7 +13,7 @@ import { OpenTextQuestion } from "@/components/questions/open-text-question";
import { PictureSelectionQuestion } from "@/components/questions/picture-selection-question";
import { RankingQuestion } from "@/components/questions/ranking-question";
import { RatingQuestion } from "@/components/questions/rating-question";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getLocalizedValue } from "@/lib/i18n";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";

View File

@@ -1,10 +1,5 @@
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
import { useState } from "preact/hooks";
import {
checkForLoomUrl,
checkForVimeoUrl,
checkForYoutubeUrl,
convertToEmbedUrl,
} from "@formbricks/lib/utils/videoUpload";
//Function to add extra params to videoUrls in order to reduce video controls
const getVideoUrlWithParams = (videoUrl: string): string => {

View File

@@ -1,5 +1,5 @@
import { SubmitButton } from "@/components/buttons/submit-button";
import { processResponseData } from "@formbricks/lib/responses";
import { processResponseData } from "@/lib/response";
import { type TResponseData } from "@formbricks/types/responses";
import { type TSurveyQuestion } from "@formbricks/types/surveys/types";

View File

@@ -9,13 +9,13 @@ import { WelcomeCard } from "@/components/general/welcome-card";
import { AutoCloseWrapper } from "@/components/wrappers/auto-close-wrapper";
import { StackedCardsContainer } from "@/components/wrappers/stacked-cards-container";
import { ApiClient } from "@/lib/api-client";
import { evaluateLogic, performActions } from "@/lib/logic";
import { parseRecallInformation } from "@/lib/recall";
import { ResponseQueue } from "@/lib/response-queue";
import { SurveyState } from "@/lib/survey-state";
import { cn, getDefaultLanguageCode } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type JSX, useCallback } from "react";
import { evaluateLogic, performActions } from "@formbricks/lib/surveyLogic/utils";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { type TJsEnvironmentStateSurvey, TJsFileUploadParams } from "@formbricks/types/js";
import type {
@@ -119,12 +119,22 @@ export function Survey({
}, [apiHost, environmentId, getSetIsError, getSetIsResponseSendingFinished, surveyState]);
const [localSurvey, setlocalSurvey] = useState<TJsEnvironmentStateSurvey>(survey);
const [currentVariables, setCurrentVariables] = useState<TResponseVariables>({});
// Update localSurvey when the survey prop changes (it changes in case of survey editor)
useEffect(() => {
setlocalSurvey(survey);
}, [survey]);
useEffect(() => {
setCurrentVariables(
survey.variables.reduce<TResponseVariables>((acc, variable) => {
acc[variable.id] = variable.value;
return acc;
}, {})
);
}, [survey.variables]);
const autoFocusEnabled = autoFocus ?? window.self === window.top;
const [questionId, setQuestionId] = useState(() => {
@@ -146,12 +156,6 @@ export function Survey({
const [history, setHistory] = useState<string[]>([]);
const [responseData, setResponseData] = useState<TResponseData>(hiddenFieldsRecord ?? {});
const [_variableStack, setVariableStack] = useState<VariableStackEntry[]>([]);
const [currentVariables, setCurrentVariables] = useState<TResponseVariables>(() => {
return localSurvey.variables.reduce<TResponseVariables>((acc, variable) => {
acc[variable.id] = variable.value;
return acc;
}, {});
});
const [ttc, setTtc] = useState<TResponseTtc>({});
const cardArrangement = useMemo(() => {
@@ -162,9 +166,7 @@ export function Survey({
}, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]);
const currentQuestionIndex = localSurvey.questions.findIndex((q) => q.id === questionId);
const currentQuestion = useMemo(() => {
return localSurvey.questions.find((q) => q.id === questionId);
}, [questionId, localSurvey.questions]);
const currentQuestion = localSurvey.questions[currentQuestionIndex];
const contentRef = useRef<HTMLDivElement | null>(null);
const showProgressBar = !styling.hideProgressBar;

View File

@@ -1,9 +1,9 @@
import { SubmitButton } from "@/components/buttons/submit-button";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { replaceRecallInfo } from "@/lib/recall";
import { calculateElementIdx } from "@/lib/utils";
import { useEffect } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseTtc, type TResponseVariables } from "@formbricks/types/responses";
import { type TI18nString } from "@formbricks/types/surveys/types";

View File

@@ -5,10 +5,10 @@ import { Input } from "@/components/general/input";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useMemo, useRef, useState } from "preact/hooks";
import { useCallback } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyAddressQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -5,9 +5,9 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,9 +4,9 @@ import { Headline } from "@/components/general/headline";
import { HtmlBody } from "@/components/general/html-body";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyConsentQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -5,9 +5,9 @@ import { Input } from "@/components/general/input";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyContactInfoQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,9 +4,9 @@ import { Headline } from "@/components/general/headline";
import { HtmlBody } from "@/components/general/html-body";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useState } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyCTAQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,13 +4,13 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getMonthName, getOrdinalDate } from "@/lib/date-time";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useState } from "preact/hooks";
import DatePicker from "react-date-picker";
import { DatePickerProps } from "react-date-picker";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getMonthName, getOrdinalDate } from "@formbricks/lib/utils/datetime";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import "../../styles/date-picker.css";

View File

@@ -2,9 +2,9 @@ import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";

View File

@@ -4,11 +4,11 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { getShuffledRowIndices } from "@/lib/utils";
import { type JSX } from "preact";
import { useCallback, useMemo, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TI18nString, TSurveyMatrixQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,10 +4,10 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,10 +4,10 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,10 +4,10 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyNPSQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,10 +4,10 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { type RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,11 +4,11 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getOriginalFileNameFromUrl } from "@/lib/storage";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";

View File

@@ -4,11 +4,11 @@ import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback, useMemo, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type {
TSurveyQuestionChoice,

View File

@@ -3,11 +3,11 @@ import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "preact/hooks";
import type { JSX } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyQuestionId, TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import {

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "preact/hooks";
import { cn } from "@formbricks/lib/cn";
import { type TPlacement } from "@formbricks/types/common";
interface SurveyContainerProps {

View File

@@ -0,0 +1,53 @@
const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
// return undefined if hex is undefined, this is important for adding the default values to the CSS variables
// TODO: find a better way to handle this
if (!hex || hex === "") return undefined;
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (_, r, g, b) => r + r + g + g + b + b);
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return "";
let r = parseInt(result[1], 16);
let g = parseInt(result[2], 16);
let b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
export const mixColor = (hexColor: string, mixWithHex: string, weight: number): string => {
// Convert both colors to RGBA format
const color1 = hexToRGBA(hexColor, 1) || "";
const color2 = hexToRGBA(mixWithHex, 1) || "";
// Extract RGBA values
const [r1, g1, b1] = color1.match(/\d+/g)?.map(Number) || [0, 0, 0];
const [r2, g2, b2] = color2.match(/\d+/g)?.map(Number) || [0, 0, 0];
// Mix the colors
const r = Math.round(r1 * (1 - weight) + r2 * weight);
const g = Math.round(g1 * (1 - weight) + g2 * weight);
const b = Math.round(b1 * (1 - weight) + b2 * weight);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
};
export const isLight = (color: string) => {
let r: number | undefined, g: number | undefined, b: number | undefined;
if (color.length === 4) {
r = parseInt(color[1] + color[1], 16);
g = parseInt(color[2] + color[2], 16);
b = parseInt(color[3] + color[3], 16);
} else if (color.length === 7) {
r = parseInt(color[1] + color[2], 16);
g = parseInt(color[3] + color[4], 16);
b = parseInt(color[5] + color[6], 16);
}
if (r === undefined || g === undefined || b === undefined) {
throw new Error("Invalid color");
}
return r * 0.299 + g * 0.587 + b * 0.114 > 128;
};

View File

@@ -0,0 +1,48 @@
// Helper function to get the month name
export const getMonthName = (monthIndex: number, locale: string = "en-US") => {
if (monthIndex < 0 || monthIndex > 11) {
throw new Error("Month index must be between 0 and 11");
}
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2000, monthIndex, 1));
};
// Helper function to format the date with an ordinal suffix
export const getOrdinalDate = (date: number) => {
const j = date % 10,
k = date % 100;
if (j === 1 && k !== 11) {
return date + "st";
}
if (j === 2 && k !== 12) {
return date + "nd";
}
if (j === 3 && k !== 13) {
return date + "rd";
}
return date + "th";
};
export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
if (!regex.test(value)) {
return false;
}
const date = new Date(value);
return !isNaN(date.getTime());
};
const getOrdinalSuffix = (day: number): string => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
const day = date.getDate();
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
const year = date.getFullYear();
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};

View File

@@ -0,0 +1,19 @@
import { TI18nString } from "@formbricks/types/surveys/types";
// Type guard to check if an object is an I18nString
const isI18nObject = (obj: any): obj is TI18nString => {
return typeof obj === "object" && obj !== null && Object.keys(obj).includes("default");
};
export const getLocalizedValue = (value: TI18nString | undefined, languageId: string): string => {
if (!value) {
return "";
}
if (isI18nObject(value)) {
if (value[languageId]) {
return value[languageId];
}
return value.default;
}
return "";
};

View File

@@ -0,0 +1,473 @@
import { getLocalizedValue } from "@/lib/i18n";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import {
TActionCalculate,
TConditionGroup,
TSingleCondition,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
const getVariableValue = (
variables: TSurveyVariable[],
variableId: string,
variablesData: TResponseVariables
) => {
const variable = variables.find((v) => v.id === variableId);
if (!variable) return undefined;
const variableValue = variablesData[variableId];
return variable.type === "number" ? Number(variableValue) || 0 : variableValue || "";
};
type TCondition = TSingleCondition | TConditionGroup;
export const isConditionGroup = (condition: TCondition): condition is TConditionGroup => {
return (condition as TConditionGroup).connector !== undefined;
};
export const evaluateLogic = (
localSurvey: TJsEnvironmentStateSurvey,
data: TResponseData,
variablesData: TResponseVariables,
conditions: TConditionGroup,
selectedLanguage: string
): boolean => {
const evaluateConditionGroup = (group: TConditionGroup): boolean => {
const results = group.conditions.map((condition) => {
if (isConditionGroup(condition)) {
return evaluateConditionGroup(condition);
} else {
return evaluateSingleCondition(localSurvey, data, variablesData, condition, selectedLanguage);
}
});
return group.connector === "or" ? results.some((r) => r) : results.every((r) => r);
};
return evaluateConditionGroup(conditions);
};
export const performActions = (
survey: TJsEnvironmentStateSurvey,
actions: TSurveyLogicAction[],
data: TResponseData,
calculationResults: TResponseVariables
): {
jumpTarget: string | undefined;
requiredQuestionIds: string[];
calculations: TResponseVariables;
} => {
let jumpTarget: string | undefined;
const requiredQuestionIds: string[] = [];
const calculations: TResponseVariables = { ...calculationResults };
actions.forEach((action) => {
switch (action.objective) {
case "calculate":
const result = performCalculation(survey, action, data, calculations);
if (result !== undefined) calculations[action.variableId] = result;
break;
case "requireAnswer":
requiredQuestionIds.push(action.target);
break;
case "jumpToQuestion":
if (!jumpTarget) {
jumpTarget = action.target;
}
break;
}
});
return { jumpTarget, requiredQuestionIds, calculations };
};
const getLeftOperandValue = (
localSurvey: TJsEnvironmentStateSurvey,
data: TResponseData,
variablesData: TResponseVariables,
leftOperand: TSingleCondition["leftOperand"],
selectedLanguage: string
) => {
switch (leftOperand.type) {
case "question":
const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value);
if (!currentQuestion) return undefined;
const responseValue = data[leftOperand.value];
if (currentQuestion.type === "openText" && currentQuestion.inputType === "number") {
return Number(responseValue) || undefined;
}
if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") {
const isOthersEnabled = currentQuestion.choices.at(-1)?.id === "other";
if (typeof responseValue === "string") {
const choice = currentQuestion.choices.find((choice) => {
return getLocalizedValue(choice.label, selectedLanguage) === responseValue;
});
if (!choice) {
if (isOthersEnabled) {
return "other";
}
return undefined;
}
return choice.id;
} else if (Array.isArray(responseValue)) {
let choices: string[] = [];
responseValue.forEach((value) => {
const foundChoice = currentQuestion.choices.find((choice) => {
return getLocalizedValue(choice.label, selectedLanguage) === value;
});
if (foundChoice) {
choices.push(foundChoice.id);
} else if (isOthersEnabled) {
choices.push("other");
}
});
if (choices) {
return Array.from(new Set(choices));
}
}
}
return data[leftOperand.value];
case "variable":
const variables = localSurvey.variables || [];
return getVariableValue(variables, leftOperand.value, variablesData);
case "hiddenField":
return data[leftOperand.value];
default:
return undefined;
}
};
const getRightOperandValue = (
localSurvey: TJsEnvironmentStateSurvey,
data: TResponseData,
variablesData: TResponseVariables,
rightOperand: TSingleCondition["rightOperand"]
) => {
if (!rightOperand) return undefined;
switch (rightOperand.type) {
case "question":
return data[rightOperand.value];
case "variable":
const variables = localSurvey.variables || [];
return getVariableValue(variables, rightOperand.value, variablesData);
case "hiddenField":
return data[rightOperand.value];
case "static":
return rightOperand.value;
default:
return undefined;
}
};
const evaluateSingleCondition = (
localSurvey: TJsEnvironmentStateSurvey,
data: TResponseData,
variablesData: TResponseVariables,
condition: TSingleCondition,
selectedLanguage: string
): boolean => {
try {
let leftValue = getLeftOperandValue(
localSurvey,
data,
variablesData,
condition.leftOperand,
selectedLanguage
);
let rightValue = condition.rightOperand
? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand)
: undefined;
let leftField: TSurveyQuestion | TSurveyVariable | string;
if (condition.leftOperand?.type === "question") {
leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion;
} else if (condition.leftOperand?.type === "variable") {
leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable;
} else if (condition.leftOperand?.type === "hiddenField") {
leftField = condition.leftOperand.value as string;
} else {
leftField = "";
}
let rightField: TSurveyQuestion | TSurveyVariable | string;
if (condition.rightOperand?.type === "question") {
rightField = localSurvey.questions.find(
(q) => q.id === condition.rightOperand?.value
) as TSurveyQuestion;
} else if (condition.rightOperand?.type === "variable") {
rightField = localSurvey.variables.find(
(v) => v.id === condition.rightOperand?.value
) as TSurveyVariable;
} else if (condition.rightOperand?.type === "hiddenField") {
rightField = condition.rightOperand.value as string;
} else {
rightField = "";
}
if (
condition.leftOperand.type === "variable" &&
(leftField as TSurveyVariable).type === "number" &&
condition.rightOperand?.type === "hiddenField"
) {
rightValue = Number(rightValue as string);
}
switch (condition.operator) {
case "equals":
if (condition.leftOperand.type === "question") {
if (
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
// when left value is of date question and right value is string
return new Date(leftValue).getTime() === new Date(rightValue).getTime();
}
}
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "question") {
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
return rightValue.includes(leftValue as string);
} else return false;
} else if (
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
return new Date(leftValue).getTime() === new Date(rightValue).getTime();
}
}
return (
(Array.isArray(leftValue) &&
leftValue.length === 1 &&
typeof rightValue === "string" &&
leftValue.includes(rightValue)) ||
leftValue === rightValue
);
case "doesNotEqual":
// when left value is of picture selection question and right value is its option
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection &&
Array.isArray(leftValue) &&
leftValue.length > 0 &&
typeof rightValue === "string"
) {
return !leftValue.includes(rightValue);
}
// when left value is of date question and right value is string
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
return new Date(leftValue).getTime() !== new Date(rightValue).getTime();
}
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "question") {
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
return !rightValue.includes(leftValue as string);
} else return false;
} else if (
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
return new Date(leftValue).getTime() !== new Date(rightValue).getTime();
}
}
return (
(Array.isArray(leftValue) &&
leftValue.length === 1 &&
typeof rightValue === "string" &&
!leftValue.includes(rightValue)) ||
leftValue !== rightValue
);
case "contains":
return String(leftValue).includes(String(rightValue));
case "doesNotContain":
return !String(leftValue).includes(String(rightValue));
case "startsWith":
return String(leftValue).startsWith(String(rightValue));
case "doesNotStartWith":
return !String(leftValue).startsWith(String(rightValue));
case "endsWith":
return String(leftValue).endsWith(String(rightValue));
case "doesNotEndWith":
return !String(leftValue).endsWith(String(rightValue));
case "isSubmitted":
if (typeof leftValue === "string") {
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload &&
leftValue
) {
return leftValue !== "skipped";
}
return leftValue !== "" && leftValue !== null;
} else if (Array.isArray(leftValue)) {
return leftValue.length > 0;
} else if (typeof leftValue === "number") {
return leftValue !== null;
}
return false;
case "isSkipped":
return (
(Array.isArray(leftValue) && leftValue.length === 0) ||
leftValue === "" ||
leftValue === null ||
leftValue === undefined ||
(typeof leftValue === "object" && Object.entries(leftValue).length === 0)
);
case "isGreaterThan":
return Number(leftValue) > Number(rightValue);
case "isLessThan":
return Number(leftValue) < Number(rightValue);
case "isGreaterThanOrEqual":
return Number(leftValue) >= Number(rightValue);
case "isLessThanOrEqual":
return Number(leftValue) <= Number(rightValue);
case "equalsOneOf":
return Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.includes(leftValue);
case "includesAllOf":
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.every((v) => leftValue.includes(v))
);
case "includesOneOf":
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => leftValue.includes(v))
);
case "doesNotIncludeAllOf":
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.every((v) => !leftValue.includes(v))
);
case "doesNotIncludeOneOf":
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => !leftValue.includes(v))
);
case "isAccepted":
return leftValue === "accepted";
case "isClicked":
return leftValue === "clicked";
case "isAfter":
return new Date(String(leftValue)) > new Date(String(rightValue));
case "isBefore":
return new Date(String(leftValue)) < new Date(String(rightValue));
case "isBooked":
return leftValue === "booked" || !!(leftValue && leftValue !== "");
case "isPartiallySubmitted":
if (typeof leftValue === "object") {
return Object.values(leftValue).includes("");
} else return false;
case "isCompletelySubmitted":
if (typeof leftValue === "object") {
const values = Object.values(leftValue);
return values.length > 0 && !values.includes("");
} else return false;
default:
return false;
}
} catch (e) {
return false;
}
};
const performCalculation = (
survey: TJsEnvironmentStateSurvey,
action: TActionCalculate,
data: TResponseData,
calculations: Record<string, number | string>
): number | string | undefined => {
const variables = survey.variables || [];
const variable = variables.find((v) => v.id === action.variableId);
if (!variable) return undefined;
let currentValue = calculations[action.variableId];
if (currentValue === undefined) {
currentValue = variable.type === "number" ? 0 : "";
}
let operandValue: string | number | undefined;
// Determine the operand value based on the action.value type
switch (action.value.type) {
case "static":
operandValue = action.value.value;
break;
case "variable":
const value = calculations[action.value.value];
if (typeof value === "number" || typeof value === "string") {
operandValue = value;
}
break;
case "question":
case "hiddenField":
const val = data[action.value.value];
if (typeof val === "number" || typeof val === "string") {
if (variable.type === "number" && !isNaN(Number(val))) {
operandValue = Number(val);
}
operandValue = val;
}
break;
}
if (operandValue === undefined || operandValue === null) return undefined;
let result: number | string;
switch (action.operator) {
case "add":
result = Number(currentValue) + Number(operandValue);
break;
case "subtract":
result = Number(currentValue) - Number(operandValue);
break;
case "multiply":
result = Number(currentValue) * Number(operandValue);
break;
case "divide":
if (Number(operandValue) === 0) return undefined;
result = Number(currentValue) / Number(operandValue);
break;
case "assign":
result = operandValue;
break;
case "concat":
result = String(currentValue) + String(operandValue);
break;
}
return result;
};

View File

@@ -1,10 +1,38 @@
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { formatDateWithOrdinal, isValidDateString } from "@formbricks/lib/utils/datetime";
import { extractFallbackValue, extractId, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { formatDateWithOrdinal, isValidDateString } from "@/lib/date-time";
import { getLocalizedValue } from "@/lib/i18n";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
// Extracts the ID of recall question from a string containing the "recall" pattern.
const extractId = (text: string): string | null => {
const pattern = /#recall:([A-Za-z0-9_-]+)/;
const match = text.match(pattern);
if (match && match[1]) {
return match[1];
} else {
return null;
}
};
// Extracts the fallback value from a string containing the "fallback" pattern.
const extractFallbackValue = (text: string): string => {
const pattern = /fallback:(\S*)#/;
const match = text.match(pattern);
if (match && match[1]) {
return match[1];
} else {
return "";
}
};
// Extracts the complete recall information (ID and fallback) from a headline string.
const extractRecallInfo = (headline: string, id?: string): string | null => {
const idPattern = id ? id : "[A-Za-z0-9_-]+";
const pattern = new RegExp(`#recall:(${idPattern})\\/fallback:(\\S*)#`);
const match = headline.match(pattern);
return match ? match[0] : null;
};
export const replaceRecallInfo = (
text: string,
responseData: TResponseData,
@@ -54,7 +82,7 @@ export const parseRecallInformation = (
responseData: TResponseData,
variables: TResponseVariables
) => {
const modifiedQuestion = structuredClone(question);
const modifiedQuestion = JSON.parse(JSON.stringify(question));
if (question.headline[languageCode].includes("recall:")) {
modifiedQuestion.headline[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.headline, languageCode),

View File

@@ -0,0 +1,28 @@
export const processResponseData = (
responseData: string | number | string[] | Record<string, string>
): string => {
switch (typeof responseData) {
case "string":
return responseData;
case "number":
return responseData.toString();
case "object":
if (Array.isArray(responseData)) {
responseData = responseData
.filter((item) => item !== null && item !== undefined && item !== "")
.join(", ");
return responseData;
} else {
const formattedString = Object.entries(responseData)
.filter(([_, value]) => value !== "")
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
return formattedString;
}
default:
return "";
}
};

View File

@@ -0,0 +1,22 @@
export const getOriginalFileNameFromUrl = (fileURL: string): string => {
try {
const fileNameFromURL = fileURL.startsWith("/storage/")
? fileURL.split("/").pop()
: new URL(fileURL).pathname.split("/").pop();
const fileExt = fileNameFromURL?.split(".").pop() ?? "";
const originalFileName = fileNameFromURL?.split("--fid--")[0] ?? "";
const fileId = fileNameFromURL?.split("--fid--")[1] ?? "";
if (!fileId) {
const fileName = originalFileName ? decodeURIComponent(originalFileName || "") : "";
return fileName;
}
const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}` || "") : "";
return fileName;
} catch (error) {
console.error(`Error parsing file URL: ${error}`);
return "";
}
};

View File

@@ -1,8 +1,8 @@
import { isLight, mixColor } from "@/lib/color";
import global from "@/styles/global.css?inline";
import preflight from "@/styles/preflight.css?inline";
import calendarCss from "react-calendar/dist/Calendar.css?inline";
import datePickerCss from "react-date-picker/dist/DatePicker.css?inline";
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
import editorCss from "../../../../apps/web/modules/ui/components/editor/styles-editor-frontend.css?inline";

View File

@@ -0,0 +1,36 @@
import { MutableRef, useEffect } from "preact/hooks";
// Improved version of https://usehooks.com/useOnClickOutside/
export const useClickOutside = (
ref: MutableRef<HTMLElement | null>,
handler: (event: MouseEvent | TouchEvent) => void
): void => {
useEffect(() => {
let startedInside = false;
let startedWhenMounted = false;
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if `mousedown` or `touchstart` started inside ref element
if (startedInside || !startedWhenMounted) return;
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler(event);
};
const validateEventStart = (event: MouseEvent | TouchEvent) => {
startedWhenMounted = ref.current !== null;
startedInside = ref.current !== null && ref.current.contains(event.target as Node);
};
document.addEventListener("mousedown", validateEventStart);
document.addEventListener("touchstart", validateEventStart);
document.addEventListener("click", listener);
return () => {
document.removeEventListener("mousedown", validateEventStart);
document.removeEventListener("touchstart", validateEventStart);
document.removeEventListener("click", listener);
};
}, [ref, handler]);
};

View File

@@ -1,5 +1,4 @@
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
import { MutableRef, useEffect } from "preact/hooks";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -106,39 +105,12 @@ const getPossibleNextQuestions = (question: TSurveyQuestion): string[] => {
return possibleDestinations;
};
// Improved version of https://usehooks.com/useOnClickOutside/
export const useClickOutside = (
ref: MutableRef<HTMLElement | null>,
handler: (event: MouseEvent | TouchEvent) => void
): void => {
useEffect(() => {
let startedInside = false;
let startedWhenMounted = false;
export const isFulfilled = <T>(val: PromiseSettledResult<T>): val is PromiseFulfilledResult<T> => {
return val.status === "fulfilled";
};
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if `mousedown` or `touchstart` started inside ref element
if (startedInside || !startedWhenMounted) return;
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler(event);
};
const validateEventStart = (event: MouseEvent | TouchEvent) => {
startedWhenMounted = ref.current !== null;
startedInside = ref.current !== null && ref.current.contains(event.target as Node);
};
document.addEventListener("mousedown", validateEventStart);
document.addEventListener("touchstart", validateEventStart);
document.addEventListener("click", listener);
return () => {
document.removeEventListener("mousedown", validateEventStart);
document.removeEventListener("touchstart", validateEventStart);
document.removeEventListener("click", listener);
};
}, [ref, handler]);
export const isRejected = <T>(val: PromiseSettledResult<T>): val is PromiseRejectedResult => {
return val.status === "rejected";
};
export const makeRequest = async <T>(

View File

@@ -0,0 +1,127 @@
export const checkForYoutubeUrl = (url: string): boolean => {
try {
const youtubeUrl = new URL(url);
if (youtubeUrl.protocol !== "https:") return false;
const youtubeDomains = [
"www.youtube.com",
"www.youtu.be",
"www.youtube-nocookie.com",
"youtube.com",
"youtu.be",
"youtube-nocookie.com",
];
const hostname = youtubeUrl.hostname;
return youtubeDomains.includes(hostname);
} catch (err) {
// invalid URL
return false;
}
};
export const checkForVimeoUrl = (url: string): boolean => {
try {
const vimeoUrl = new URL(url);
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
} catch (err) {
// invalid URL
return false;
}
};
export const checkForLoomUrl = (url: string): boolean => {
try {
const loomUrl = new URL(url);
if (loomUrl.protocol !== "https:") return false;
const loomDomains = ["www.loom.com", "loom.com"];
const hostname = loomUrl.hostname;
return loomDomains.includes(hostname);
} catch (err) {
// invalid URL
return false;
}
};
export const extractYoutubeId = (url: string): string | null => {
let id = "";
// Regular expressions for various YouTube URL formats
const regExpList = [
/youtu\.be\/([a-zA-Z0-9_-]+)/, // youtu.be/<id>
/youtube\.com.*v=([a-zA-Z0-9_-]+)/, // youtube.com/watch?v=<id>
/youtube\.com.*embed\/([a-zA-Z0-9_-]+)/, // youtube.com/embed/<id>
/youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/<id>
];
regExpList.some((regExp) => {
const match = url.match(regExp);
if (match && match[1]) {
id = match[1];
return true;
}
return false;
});
return id || null;
};
const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(\d+)/;
const match = url.match(regExp);
if (match && match[1]) {
return match[1];
}
return null;
};
const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
const match = url.match(regExp);
if (match && match[1]) {
return match[1];
}
return null;
};
// Always convert a given URL into its embed form if supported.
export const convertToEmbedUrl = (url: string): string | undefined => {
// YouTube
if (checkForYoutubeUrl(url)) {
const videoId = extractYoutubeId(url);
if (videoId) {
return `https://www.youtube.com/embed/${videoId}`;
}
}
// Vimeo
if (checkForVimeoUrl(url)) {
const videoId = extractVimeoId(url);
if (videoId) {
return `https://player.vimeo.com/video/${videoId}`;
}
}
// Loom
if (checkForLoomUrl(url)) {
const videoId = extractLoomId(url);
if (videoId) {
return `https://www.loom.com/embed/${videoId}`;
}
}
// If no supported platform found, return undefined
return undefined;
};

View File

@@ -1,17 +1,25 @@
sonar.projectKey=formbricks_formbricks
sonar.organization=formbricks
sonar.javascript.lcov.reportPaths=apps/web/coverage/lcov.info
# Sources
sonar.sources=apps/web
sonar.exclusions=**/node_modules/**,**/.next/**,**/dist/**,**/build/**,**/*.test.*,**/*.spec.*,**/__mocks__/**
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=formbricks
#sonar.projectVersion=1.0
# Tests
sonar.tests=apps/web
sonar.test.inclusions=**/*.test.*,**/*.spec.*
sonar.javascript.lcov.reportPaths=apps/web/coverage/lcov.info
# TypeScript configuration
sonar.typescript.tsconfigPath=apps/web/tsconfig.json
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
# SCM
sonar.scm.provider=git
sonar.scm.exclusions.disabled=false
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8
# Encoding of the source code
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/constants.ts,**/route.ts,modules/**/types/**