Compare commits
7 Commits
admin-emai
...
fix-hidden
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54a48e70b | ||
|
|
884b6f12ae | ||
|
|
5cae0febc9 | ||
|
|
0e898db710 | ||
|
|
40d54d60d4 | ||
|
|
269e026381 | ||
|
|
8245f2f6af |
1
.github/workflows/sonarqube.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
44
docs/api-reference/generate-key.mdx
Normal 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:
|
||||

|
||||
</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.
|
||||

|
||||
</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.
|
||||
@@ -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 you’ve 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)!
|
||||
@@ -7725,7 +7725,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Health Check",
|
||||
"tags": ["default"]
|
||||
"tags": ["Health"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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”
|
||||
|
||||

|
||||
|
||||
- Create a key for the development or production environment.
|
||||
|
||||
- Copy the key immediately. 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>
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
50
docs/api-reference/test-key.mdx
Normal 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>
|
||||
BIN
docs/images/api-reference/config.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/images/api-reference/label.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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": [
|
||||
|
||||
39
docs/xm-and-surveys/core-features/test-environment.mdx
Normal 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:
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Select Target Environment">
|
||||
Choose which environment you want to copy the survey to
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
7. **Configuring the Email Content**:
|
||||
|
||||
- Subject
|
||||
- Body: Supports basic HTML formatting (p, span, b, strong, i, em, a, br tags)
|
||||
|
||||

|
||||
|
||||
8. **Save and Activate**
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "只是好奇?",
|
||||
|
||||
@@ -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) || "";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
53
packages/surveys/src/lib/color.ts
Normal 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;
|
||||
};
|
||||
48
packages/surveys/src/lib/date-time.ts
Normal 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}`;
|
||||
};
|
||||
19
packages/surveys/src/lib/i18n.ts
Normal 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 "";
|
||||
};
|
||||
473
packages/surveys/src/lib/logic.ts
Normal 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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
28
packages/surveys/src/lib/response.ts
Normal 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 "";
|
||||
}
|
||||
};
|
||||
22
packages/surveys/src/lib/storage.ts
Normal 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 "";
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
36
packages/surveys/src/lib/use-click-outside-hook.ts
Normal 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]);
|
||||
};
|
||||
@@ -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>(
|
||||
|
||||
127
packages/surveys/src/lib/video-upload.ts
Normal 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;
|
||||
};
|
||||
@@ -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/**
|
||||