Compare commits
10 Commits
randomize-
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
219f9beb84 | ||
|
|
e2cacfc743 | ||
|
|
9b109bf4d3 | ||
|
|
159390e411 | ||
|
|
35837be21e | ||
|
|
b2f5e5fbe3 | ||
|
|
692e7e3f24 | ||
|
|
0aca213750 | ||
|
|
0bac91a7ef | ||
|
|
d1b47c675c |
4
LICENSE
@@ -1,9 +1,9 @@
|
||||
Copyright (c) 2024 Formbricks GmbH
|
||||
Copyright (c) 2023 Formbricks GmbH
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "packages/ee/" directory of this repository, if that directory exists, is licensed under the license defined in "packages/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/react-native/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All content that resides under the "packages/js/", "packages/errors/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"lucide-react": "^0.418.0",
|
||||
"next": "14.2.5",
|
||||
"lucide-react": "^0.446.0",
|
||||
"next": "14.2.13",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
|
||||
@@ -60,7 +60,7 @@ const AppPage = ({}) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row">
|
||||
<SurveySwitch value="app" formbricks={formbricks} />
|
||||
@@ -117,7 +117,7 @@ const AppPage = ({}) => {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
|
||||
@@ -56,8 +56,8 @@ const AppPage = ({}) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col items-center justify-between md:flex-row">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row">
|
||||
<SurveySwitch value="website" formbricks={formbricks} />
|
||||
<div>
|
||||
@@ -113,7 +113,7 @@ const AppPage = ({}) => {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
|
||||
@@ -4,20 +4,25 @@ import I1 from "./images/I1.webp";
|
||||
import I2 from "./images/I2.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Using Actions in Formbricks",
|
||||
title: "Using Actions in Formbricks | Fine-tuning User Moments",
|
||||
description:
|
||||
"Dive deep into how actions in Formbricks help products and organizations to engage users at precise moments in their journey. Discover the power of actions, from coding to no-code setups, to refine user targeting and generate richer, more detailed user insights.",
|
||||
};
|
||||
|
||||
#### App Surveys
|
||||
|
||||
# Actions
|
||||
# Actions & Targeting
|
||||
|
||||
Actions are predefined events within your app that prompt Formbricks to display a survey when triggered. These are detected by the Formbricks widget, which then presents the appropriate survey based on your predefined settings.
|
||||
Understanding user thoughts and feelings at critical moments in their journey is pivotal. To achieve this, Formbricks uses user-centric actions that trigger surveys at precisely the right time. Actions are essentially notifications sent from your application to Formbricks when predefined user activities occur, making it possible to gather insights during key interactions.
|
||||
|
||||
<Note>
|
||||
Ensure that you’ve **initialized Formbricks with a userId** to fully utilize this feature along with other
|
||||
app survey capabilities.
|
||||
</Note>
|
||||
|
||||
## **How Do Actions Work?**
|
||||
|
||||
Actions in Formbricks App Surveys are deeply integrated with user activities within your app. When a user performs a specified action, the Formbricks widget detects this activity and can present a survey to that specific user if the trigger conditions match for that survey. This capability ensures that surveys are triggered at the right time. You can set up these actions through a user-friendly No-Code interface within the Formbricks dashboard.
|
||||
Actions in Formbricks App Surveys are deeply integrated with user activities within your app. When a user performs a specified action, the Formbricks widget detects this activity and can present a survey to that specific user if the trigger conditions match of that survey, while also recording the event. This capability ensures that surveys are not only triggered at the right time but are also tailored to the user’s recent interactions within the app. You can set up these actions through a user-friendly No-Code interface within the Formbricks dashboard.
|
||||
|
||||
## **Why Are Actions Useful?**
|
||||
|
||||
@@ -25,7 +30,8 @@ Actions are invaluable for enhancing survey relevance and effectiveness:
|
||||
|
||||
- **Personalized Engagement**: Surveys triggered by user actions ensure content is highly relevant and engaging, matching each user’s current context.
|
||||
- **User Attributes**: By tying surveys to specific user attributes, such as activity levels or feature usage, you can customize the survey experience to reflect individual user profiles.
|
||||
- **User Targeting**: Precise targeting based on user attributes ensures that surveys are shown only to users who meet certain criteria, enhancing the relevance and effectiveness of each survey.
|
||||
- **User Segments**: Analyze action data to create detailed user segments, targeting specific groups with surveys that are pertinent to their behaviors or interactions within the app.
|
||||
- **User Targeting**: Precise targeting based on user actions and attributes ensures that surveys are shown only to users who meet certain criteria, enhancing the relevance and effectiveness of each survey.
|
||||
|
||||
## **Setting Up No-Code Actions**
|
||||
|
||||
@@ -121,3 +127,5 @@ return <button onClick={handleClick}>Click Me</button>;
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
This documentation frames actions around user interactions, emphasizing the connection between the user's activities and the survey experience. By leveraging user-centric actions, you can create highly targeted and timely surveys that resonate with users and yield valuable insights.
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import { ResponsiveVideo } from "@/components/ResponsiveVideo";
|
||||
|
||||
import GermansGpt from "./germans-gpt.webp";
|
||||
import Hni from "./hni.webp";
|
||||
import PowerUsers from "./power-users.webp";
|
||||
import RideHailing from "./ride-hailing.webp";
|
||||
import UpsellMiro from "./upsell-miro.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Advanced Targeting for App Surveys | Formbricks",
|
||||
title: "Advanced Targeting for In-app Surveys | Formbricks",
|
||||
description:
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, metadata, and other segments. This helps you get more relevant feedback and make data-driven decisions.",
|
||||
"Advanced Targeting allows you to show surveys to just the right group of people. You can target surveys based on user attributes, user events, and metadata. This helps you get more relevant feedback and make data-driven decisions.",
|
||||
};
|
||||
|
||||
#### App Surveys
|
||||
|
||||
# Advanced Targeting
|
||||
|
||||
<Note>
|
||||
Targeting based on actions is deprecated in Advanced Targeting and will be removed soon. We recommend using
|
||||
filters on user attributes to target the survey only to specific groups of users.
|
||||
</Note>
|
||||
|
||||
Advanced Targeting allows you to show surveys to the right group of people. You can target surveys based on user attributes, device type, and more instead of spraying and praying. This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.
|
||||
|
||||
# How to setup Advanced Targeting
|
||||
<ResponsiveVideo
|
||||
title="Formbricks Multi-language Surveys"
|
||||
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&controls=0"
|
||||
/>
|
||||
|
||||
<Note>Advanced Targeting is only available on the Pro plan!</Note>
|
||||
## How to setup Advanced Targeting
|
||||
|
||||
<Note>
|
||||
Advanced Targeting is available on the Pro plan!
|
||||
</Note>
|
||||
|
||||
1. On the Formbricks dashboard, click on **People** tab from the top navigation bar.
|
||||
|
||||
@@ -20,7 +41,7 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
|
||||
3. Give your segment a title & a description to help you remember what this segment is about.
|
||||
|
||||
4. Now click on the **Add Filter** button to add a filter. You can filter based on user attributes, other segments, devices, and more.
|
||||
4. Now click on the **Add Filter** button to add a filter. You can filter based on actions, user attributes, other segments, devices, and more.
|
||||
|
||||
5. To group a set of filters together, click on the Three Dots icon on the right side of the filter and click on **Create Group**.
|
||||
|
||||
@@ -29,3 +50,32 @@ Advanced Targeting allows you to show surveys to the right group of people. You
|
||||
7. Once you are happy with the segment, click on **Save Segment**.
|
||||
|
||||
8. Now, when you create a survey, you can select this segment to target your survey to.
|
||||
|
||||
## Examples:
|
||||
|
||||
1. Let's say you want to upsell to: Miro, Loom, Figma, Slack and Asana.
|
||||
|
||||
<MdxImage
|
||||
src={UpsellMiro}
|
||||
alt="Upselling Opportunity"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. Post-experience surveying for a ride hailing app where users who have taken more than 1 ride are shown a survey.
|
||||
|
||||
<MdxImage
|
||||
src={RideHailing}
|
||||
alt="Ride Hailing Targeting"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. Sneak Peak: How we at Formbricks automate inviting power users to chat with us
|
||||
|
||||
<MdxImage
|
||||
src={PowerUsers}
|
||||
alt="Automate inviting power users to chat with us at Formbricks"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
@@ -37,9 +37,8 @@ To display the Product-Market Fit survey in your app you want to proceed as foll
|
||||
3. Setup the user action to display survey at good point in time
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(15mins).](/app-surveys/quickstart)
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide (15mins).](/app-surveys/quickstart)
|
||||
</Note>
|
||||
|
||||
### 1. Create new PMF survey
|
||||
@@ -70,12 +69,25 @@ _Want to change the button color? You can do so in the product settings!_
|
||||
|
||||
Save, and move over to where the magic happens: The “Audience” tab.
|
||||
|
||||
### 3. Pre-segment your audience
|
||||
### 3. Pre-segment your audience (coming soon)
|
||||
|
||||
<Note>
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next days.
|
||||
</Note>
|
||||
|
||||
To run this survey properly, you should pre-segment your user base. As touched upon earlier: if you ask every user you’ll get lots of opinions which are often misleading. You only want to gather feedback from people who invested the time to get to know and use your product:
|
||||
|
||||
**Filter by attribute**: You can keep the logic to decide if a user has (or has not) experienced value in your application. This makes most sense if you want to use historic usage data to decide if a user qualifies or not. Create your logic and if it applies, send an attribute to Formbricks by e.g. `formbricks.setAttribute("Loyalty", "Experienced Value");` Here is the full manual on how to [set attributes](/app-surveys/user-identification).
|
||||
|
||||
**Filter by actions (coming soon)**: Later, you can also segment users based on events tracked with Formbricks. However, this makes it impossible to use historic usage data (pre Formbricks usage). Here we will have a few options to achieve that:
|
||||
|
||||
- Check the time passed since sign-up (e.g. signed up 4 weeks ago)
|
||||
- User has performed a specific action a certain number of times or (e.g. created 5 reports)
|
||||
- User has performed a combination of actions (e.g. created a report **and** invited a organization member)
|
||||
|
||||
This way you make sure that you separate potentially misleading opinions from valuable insights.
|
||||
|
||||
### 4. Set up a trigger for the Product-Market Fit survey:
|
||||
|
||||
You need a trigger to display the survey but in this case, the filtering does all the work. It’s up to you to decide to display the survey after the user viewed a specific subpage (pageURL) or after clicking an element. Have a look at the [Actions manual](/app-surveys/actions/) if you are not sure how to set them up:
|
||||
|
||||
@@ -6,7 +6,7 @@ import ApiKeySecret from "./images/api-key-secret.webp";
|
||||
export const metadata = {
|
||||
title: "Formbricks API Overview: Public Client & Management API Breakdown",
|
||||
description:
|
||||
"Formbricks provides a powerful API to manage your surveys, responses, users, displays, attributes & webhooks programmatically. Get a detailed understanding of Formbricks' dual API offerings: the unauthenticated Public Client API optimized for client-side tasks and the secured Management API for advanced account operations. Choose the perfect fit for your integration needs and ensure robust data handling",
|
||||
"Formbricks provides a powerful API to manage your surveys, responses, users, displays, actions, attributes & webhooks programmatically. Get a detailed understanding of Formbricks' dual API offerings: the unauthenticated Public Client API optimized for client-side tasks and the secured Management API for advanced account operations. Choose the perfect fit for your integration needs and ensure robust data handling",
|
||||
};
|
||||
|
||||
#### API
|
||||
@@ -23,6 +23,7 @@ The [Public Client API](https://documenter.getpostman.com/view/11026000/2sA3Bq5X
|
||||
|
||||
We currently have the following Client API methods exposed and below is their documentation attached in Postman:
|
||||
|
||||
- [Actions API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#b8f3a10e-1642-4d82-a629-fef0a8c6c86c) - Create actions for a Person
|
||||
- [Displays API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#949272bf-daec-4d72-9b52-47af3d74a62c) - Mark Survey as Displayed or Update an existing Display by linking it with a Response for a Person
|
||||
- [People API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#ee3d2188-4253-4bca-9238-6b76455805a9) - Create & Update a Person (e.g. attributes, email, userId, etc)
|
||||
- [Responses API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#8c773032-536c-483c-a237-c7697347946e) - Create & Update a Response for a Survey
|
||||
|
||||
BIN
apps/docs/app/global/conditional-logic/images/StepEight.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/docs/app/global/conditional-logic/images/StepFive.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/docs/app/global/conditional-logic/images/StepFour.webp
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/docs/app/global/conditional-logic/images/StepOne.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/docs/app/global/conditional-logic/images/StepSeven.webp
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
apps/docs/app/global/conditional-logic/images/StepSix.webp
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
apps/docs/app/global/conditional-logic/images/StepThree.webp
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
apps/docs/app/global/conditional-logic/images/StepTwo.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
@@ -1,171 +1,136 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import ActionCalculateOperators from "./images/action-calculate-operators.webp";
|
||||
import ActionCalculateValue from "./images/action-calculate-value.webp";
|
||||
import ActionCalculateVariables from "./images/action-calculate-variables.webp";
|
||||
import ActionCalculate from "./images/action-calculate.webp";
|
||||
import ActionJump from "./images/action-jump.webp";
|
||||
import ActionOptions from "./images/action-options.webp";
|
||||
import ActionRequire from "./images/action-require.webp";
|
||||
import AddLogic from "./images/add-logic.webp";
|
||||
import ConditionChaining from "./images/condition-chaining.webp";
|
||||
import ConditionOperators from "./images/condition-operators.webp";
|
||||
import ConditionOptions from "./images/condition-options.webp";
|
||||
import ConditionValue from "./images/condition-value.webp";
|
||||
import Conditions from "./images/conditions.webp";
|
||||
import Editor from "./images/editor.webp";
|
||||
import QuestionLogic from "./images/question-logic.webp";
|
||||
|
||||
import StepEight from "./images/StepEight.webp";
|
||||
import StepFive from "./images/StepFive.webp";
|
||||
import StepFour from "./images/StepFour.webp";
|
||||
import StepOne from "./images/StepOne.webp";
|
||||
import StepSeven from "./images/StepSeven.webp";
|
||||
import StepSix from "./images/StepSix.webp";
|
||||
import StepThree from "./images/StepThree.webp";
|
||||
import StepTwo from "./images/StepTwo.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Conditional Logic",
|
||||
title: "Conditional Logic in Formbricks Surveys",
|
||||
description:
|
||||
"Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.",
|
||||
"Enhance your surveys with Conditional Logic to create a dynamic and responsive survey experience. Guide respondents through different paths based on their answers, ensuring each survey is tailored to the user’s responses. This feature is applicable for all kinds of surveys!",
|
||||
};
|
||||
|
||||
# Logic Editor
|
||||
# Conditional Logic
|
||||
|
||||
Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.
|
||||
With Formbricks, enhance your surveys with Conditional Logic to create a dynamic and responsive survey experience. This feature allows you to guide respondents through different paths based on their answers, ensuring each survey is tailored to the user’s responses. This feature is applicable for all kinds of surveys!
|
||||
|
||||
<MdxImage src={Editor} alt="Logic Editor" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
### Setting Up Conditional Logic
|
||||
|
||||
## Terminology
|
||||
Conditional Logic can be applied to any question within your survey to direct the flow based on specific responses.
|
||||
|
||||
- **Condition**: A rule that determines when an action should be executed.
|
||||
- **Action**: A task that is executed when a condition is met.
|
||||
|
||||
## **Creating Logic**
|
||||
|
||||
1. **Add a Logic Block**: Click the `Add logic +` button to add a new logic block.
|
||||
|
||||
<MdxImage src={AddLogic} alt="Add Logic" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
<Note>
|
||||
You can add multiple logic blocks to a survey. Logic blocks are executed in the order they are added. You
|
||||
can rearrange the order of logic blocks.
|
||||
</Note>
|
||||
|
||||
2. **Add Conditions**: Add conditions to the logic block. Conditions are rules that determine when an action should be executed.
|
||||
1. **Create or Edit a Question**: Start by adding a new question or editing an existing one within your survey.
|
||||
|
||||
<MdxImage
|
||||
src={Conditions}
|
||||
alt="Add Conditions"
|
||||
src={StepOne}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
Conditons can be based on:
|
||||
|
||||
- **Question**: The answer to a question.
|
||||
- **Variable**: A variable value.
|
||||
- **Hidden Field**: The value of a hidden field.
|
||||
|
||||
2.a **Condition Options**: Choose from a list of available conditions.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionOptions}
|
||||
alt="Condition Options"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2.b **Condition Operators**: Choose an operator to compare the condition value.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionOperators}
|
||||
alt="Condition Operators"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2.c **Condition Value**: Enter a value to compare the condition against.
|
||||
Comparisons can be made against a fixed value or a dynamic value.
|
||||
Dynamic values can be based on a question, variable, or hidden field.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionValue}
|
||||
alt="Condition Value"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
<Note>
|
||||
- Conditions can be grouped. - Conditions can be combined using AND or OR operators. You can add multiple
|
||||
conditions to a logic block. Conditions are evaluated in the order they are added.
|
||||
</Note>
|
||||
2. **Access Advanced Settings**: Click on **Advanced Settings** beneath the question setup area to access additional configuration options.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionChaining}
|
||||
alt="Condition Chaining"
|
||||
src={StepTwo}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
3. **Add Actions**: Add actions to the logic block. Actions are tasks that are executed when a condition is met.
|
||||
|
||||
<Note>You can add multiple actions to a logic block. Actions are executed in the order they are added.</Note>
|
||||
|
||||
- 3.a **Action Options**: Choose from a list of available actions.
|
||||
|
||||
<MdxImage
|
||||
src={ActionOptions}
|
||||
alt="Add Actions"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Action is of the following types:
|
||||
|
||||
- **Calculate**: Perform a calculation. These variables are then available for use in other questions.
|
||||
|
||||
- Calculations can be performed on variables.
|
||||
- Calculations can be based on fixed values or dynamic values.
|
||||
<MdxImage
|
||||
src={ActionCalculateVariables}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculateOperators}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculateValue}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculate}
|
||||
alt="Action Calculate"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
- **Require Answer**: Make a question required. Only the optional questions can be marked as required while filling the survey.
|
||||
<MdxImage
|
||||
src={ActionRequire}
|
||||
alt="Action Require"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- **Jump to Question**: Skip to a specific question. The user will be redirected to the specified question based on the condition.
|
||||
<MdxImage
|
||||
src={ActionJump}
|
||||
alt="Action Jump"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. **Save Logic**: Click the `Save` button to save the logic block.
|
||||
|
||||
# Question Logic
|
||||
|
||||
This logic is executed when the user answers the question. Logic can be as simple as showing a follow-up question based on the answer or as complex as calculating a score based on multiple answers.
|
||||
3. **Configure Logic Jumps**: Choose **Logic Jumps** to set up conditions that dictate the survey's progression based on responses.
|
||||
|
||||
<MdxImage
|
||||
src={QuestionLogic}
|
||||
alt="Question Logic"
|
||||
src={StepThree}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
### Configuring Logic Conditions
|
||||
|
||||
Enhance survey interactivity by setting up triggers based on respondent answers. You can specify different paths for each type of response:
|
||||
|
||||
**General Conditions (Available in All Question Types):**
|
||||
|
||||
- **Answer Submitted/Skipped**: Initiate different survey paths based on whether a question is answered or skipped.
|
||||
<MdxImage
|
||||
src={StepFour}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
**Choice-Based Conditions:**
|
||||
|
||||
<MdxImage
|
||||
src={StepFive}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
- **One of Multiple Choice Values**: Activate if the answer is among selected options. (Single select and Multi select)
|
||||
- **Equals: Exactly the Selected Values**: Initiate a jump if the response matches exactly the selected values provided in the options. (Single select and Multi select)
|
||||
- **Includes All Selected Values**: Activate if all selected values match those chosen by the survey creator. (Multi select only)
|
||||
|
||||
**Numeric Conditions (Rating & NPS Questions Only)**
|
||||
|
||||
<MdxImage
|
||||
src={StepSix}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
- **Value Comparisons**: Initiate jumps based on numerical criteria—equals, does not equal, less than, less than or equal to, greater than, or greater than equal to.
|
||||
|
||||
**Matrix-Specific Conditions**
|
||||
|
||||
<MdxImage
|
||||
src={StepSeven}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
- **Submission Completeness**: Initiate based on whether the response is completely or partially submitted.
|
||||
|
||||
### **Specifying Survey Paths**
|
||||
|
||||
After setting conditions, you specify the paths to take when those conditions are met:
|
||||
|
||||
- **Jump to a Specific Question**: Direct the respondent to a particular question elsewhere in the survey.
|
||||
- **Jump to Survey End**: Conclude the survey early based on the respondent's answers.
|
||||
|
||||
<MdxImage
|
||||
src={StepEight}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
### **Multiple Logic Jumps**
|
||||
|
||||
You can add multiple Logic Jumps to a single question. These jumps work in an **OR** fashion, meaning any of the conditions can initiate the specified survey path. The logic jumps are evaluated in the **order they are created**, and the first true condition will take precedence.
|
||||
|
||||
---
|
||||
|
||||
### **Example Scenario**
|
||||
|
||||
Suppose you’re conducting a survey about technology usage:
|
||||
|
||||
1. **Question (Single Select)**: What devices do you use daily?
|
||||
2. **Answers**: Smartphone, Laptop, Tablet, None
|
||||
|
||||
**Logic Jump Setup**:
|
||||
|
||||
- If the respondent selects **None**, jump to the end of the survey.
|
||||
- If the respondent selects **Smartphone**, jump to a question specific to smartphone usage.
|
||||
|
||||
This setup allows for a tailored survey experience that adapts to the respondent's input, making your data collection more targeted and efficient.
|
||||
|
||||
That’s it! Now go and configure your surveys to bring down your survey fatigue to 0% and ask respondents through customized paths. Enhance their relevance and engagement with your survey, leading to higher quality responses and better data.
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
171
apps/docs/app/global/logic-editor/page.mdx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import ActionCalculateOperators from "./images/action-calculate-operators.webp";
|
||||
import ActionCalculateValue from "./images/action-calculate-value.webp";
|
||||
import ActionCalculateVariables from "./images/action-calculate-variables.webp";
|
||||
import ActionCalculate from "./images/action-calculate.webp";
|
||||
import ActionJump from "./images/action-jump.webp";
|
||||
import ActionOptions from "./images/action-options.webp";
|
||||
import ActionRequire from "./images/action-require.webp";
|
||||
import AddLogic from "./images/add-logic.webp";
|
||||
import ConditionChaining from "./images/condition-chaining.webp";
|
||||
import ConditionOperators from "./images/condition-operators.webp";
|
||||
import ConditionOptions from "./images/condition-options.webp";
|
||||
import ConditionValue from "./images/condition-value.webp";
|
||||
import Conditions from "./images/conditions.webp";
|
||||
import Editor from "./images/editor.webp";
|
||||
import QuestionLogic from "./images/question-logic.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Logic Editor",
|
||||
description:
|
||||
"Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.",
|
||||
};
|
||||
|
||||
# Logic Editor
|
||||
|
||||
Create complex survey logic with the Logic Editor. Use conditions, actions, and variables to create a personalized survey experience.
|
||||
|
||||
<MdxImage src={Editor} alt="Logic Editor" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Condition**: A rule that determines when an action should be executed.
|
||||
- **Action**: A task that is executed when a condition is met.
|
||||
|
||||
## **Creating Logic**
|
||||
|
||||
1. **Add a Logic Block**: Click the `Add logic +` button to add a new logic block.
|
||||
|
||||
<MdxImage src={AddLogic} alt="Add Logic" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
<Note>
|
||||
You can add multiple logic blocks to a survey. Logic blocks are executed in the order they are added. You
|
||||
can rearrange the order of logic blocks.
|
||||
</Note>
|
||||
|
||||
2. **Add Conditions**: Add conditions to the logic block. Conditions are rules that determine when an action should be executed.
|
||||
|
||||
<MdxImage
|
||||
src={Conditions}
|
||||
alt="Add Conditions"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Conditons can be based on:
|
||||
|
||||
- **Question**: The answer to a question.
|
||||
- **Variable**: A variable value.
|
||||
- **Hidden Field**: The value of a hidden field.
|
||||
|
||||
2.a **Condition Options**: Choose from a list of available conditions.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionOptions}
|
||||
alt="Condition Options"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2.b **Condition Operators**: Choose an operator to compare the condition value.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionOperators}
|
||||
alt="Condition Operators"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2.c **Condition Value**: Enter a value to compare the condition against.
|
||||
Comparisons can be made against a fixed value or a dynamic value.
|
||||
Dynamic values can be based on a question, variable, or hidden field.
|
||||
|
||||
<MdxImage
|
||||
src={ConditionValue}
|
||||
alt="Condition Value"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
<Note>
|
||||
- Conditions can be grouped. - Conditions can be combined using AND or OR operators. You can add multiple
|
||||
conditions to a logic block. Conditions are evaluated in the order they are added.
|
||||
</Note>
|
||||
|
||||
<MdxImage
|
||||
src={ConditionChaining}
|
||||
alt="Condition Chaining"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. **Add Actions**: Add actions to the logic block. Actions are tasks that are executed when a condition is met.
|
||||
|
||||
<Note>You can add multiple actions to a logic block. Actions are executed in the order they are added.</Note>
|
||||
|
||||
- 3.a **Action Options**: Choose from a list of available actions.
|
||||
|
||||
<MdxImage
|
||||
src={ActionOptions}
|
||||
alt="Add Actions"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Action is of the following types:
|
||||
|
||||
- **Calculate**: Perform a calculation. These variables are then available for use in other questions.
|
||||
|
||||
- Calculations can be performed on variables.
|
||||
- Calculations can be based on fixed values or dynamic values.
|
||||
<MdxImage
|
||||
src={ActionCalculateVariables}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculateOperators}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculateValue}
|
||||
alt="Action Calculate Variables"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<MdxImage
|
||||
src={ActionCalculate}
|
||||
alt="Action Calculate"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
- **Require Answer**: Make a question required. Only the optional questions can be marked as required while filling the survey.
|
||||
<MdxImage
|
||||
src={ActionRequire}
|
||||
alt="Action Require"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- **Jump to Question**: Skip to a specific question. The user will be redirected to the specified question based on the condition.
|
||||
<MdxImage
|
||||
src={ActionJump}
|
||||
alt="Action Jump"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. **Save Logic**: Click the `Save` button to save the logic block.
|
||||
|
||||
# Question Logic
|
||||
|
||||
This logic is executed when the user answers the question. Logic can be as simple as showing a follow-up question based on the answer or as complex as calculating a score based on multiple answers.
|
||||
|
||||
<MdxImage
|
||||
src={QuestionLogic}
|
||||
alt="Question Logic"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
@@ -23,10 +23,6 @@ This guide will help you understand how to generate and use single-use links wit
|
||||
|
||||
- The primary purpose of single-use links is to assure that no respondent submits a survey twice.
|
||||
|
||||
<Note>
|
||||
Want to create up to 5,000 single-use links? Use our [API endpoint for that.](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#c49ef758-a78a-4ef4-a282-262621151f08).
|
||||
</Note>
|
||||
|
||||
## Using Single-Use Links with Formbricks
|
||||
|
||||
Using single-use links with Formbricks is quite straight-forward:
|
||||
|
||||
@@ -39,7 +39,7 @@ To link a response to a user in your Formbricks database, you can pass your inte
|
||||
|
||||
## Where do I find my userId?
|
||||
|
||||
The `userId` we are referring to is the `userId` of your own system. For example, a user signs up to your app and gets the Id `ABC123` assigned then this is the Id you pass along in the URL parameter.
|
||||
The `userId` we are refering to is the `userId` of your own system. For example, a user signs up to your app and gets the Id `ABC123` assigned then this is the Id you pass along in the URL parameter.
|
||||
|
||||
This allows you to connect the response to the user profile of this specific in the Formbricks database. You can then use the response data to create segments for further surveying or invite them to an interview, etc.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
@@ -11,9 +11,11 @@ export const metadata = {
|
||||
|
||||
#### Website Surveys
|
||||
|
||||
# Actions
|
||||
# Actions & Targeting
|
||||
|
||||
Actions are triggers based on interactions with your website, allowing you to capture feedback precisely when it's most relevant.
|
||||
For public-facing websites, landing pages, and pages without user authentication walls, actions serve as effective triggers for displaying surveys. This method is particularly suitable for engaging general audiences, where **individual user tracking is not required or feasible**.
|
||||
|
||||
Actions in this context are straightforward triggers based on interactions with your website, allowing you to capture feedback precisely when it's most relevant.
|
||||
|
||||
<Note>
|
||||
These actions operate **independently** as website surveys do not involve user identification. If you have
|
||||
@@ -44,7 +46,7 @@ Formbricks provides an intuitive No-Code interface for configuring actions, enab
|
||||
src={StepOne}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
2. Now click on “Add Action”
|
||||
@@ -53,7 +55,7 @@ Formbricks provides an intuitive No-Code interface for configuring actions, enab
|
||||
src={StepTwo}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
Here are four types of No-Code actions you can set up:
|
||||
@@ -53,7 +53,7 @@ export const SideNavigation = ({ pathname }) => {
|
||||
onClick={() => setSelectedId(heading.id)}
|
||||
className={`${
|
||||
heading.id === selectedId
|
||||
? "text-brand-dark font-medium"
|
||||
? "text-brand font-medium"
|
||||
: "font-normal text-slate-600 hover:text-slate-950 dark:text-white dark:hover:text-slate-50"
|
||||
}`}>
|
||||
{heading.text}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{
|
||||
title: "Features",
|
||||
children: [
|
||||
{ title: "Actions", href: "/website-surveys/actions" },
|
||||
{ title: "Actions & Targeting", href: "/website-surveys/actions-and-targeting" },
|
||||
{ title: "Show Survey to % of users", href: "/global/show-survey-to-percent-of-users" }, // app and website
|
||||
{ title: "Recontact Options", href: "/app-surveys/recontact" },
|
||||
{ title: "Hidden Fields", href: "/global/hidden-fields" }, // global
|
||||
@@ -109,6 +109,7 @@ export const navigation: Array<NavGroup> = [
|
||||
links: [
|
||||
{ title: "Access Roles", href: "/global/access-roles" },
|
||||
{ title: "Styling Theme", href: "/global/styling-theme" },
|
||||
{ title: "Logic Editor", href: "/global/logic-editor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -110,11 +110,6 @@ const nextConfig = {
|
||||
destination: "/global/schedule-start-end-dates",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/global/logic-editor",
|
||||
destination: "/global/conditional-logic",
|
||||
permanent: true,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
source: "/integrations/:path",
|
||||
|
||||
@@ -13,39 +13,39 @@
|
||||
"browserslist": "defaults, not ie <= 11",
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-core": "^1.17.4",
|
||||
"@calcom/embed-react": "^1.5.0",
|
||||
"@calcom/embed-react": "^1.5.1",
|
||||
"@docsearch/css": "3",
|
||||
"@docsearch/react": "^3.6.1",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@headlessui/react": "^2.1.8",
|
||||
"@headlessui/tailwindcss": "^0.2.1",
|
||||
"@mapbox/rehype-prism": "^0.9.0",
|
||||
"@mdx-js/loader": "^3.0.1",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"@next/mdx": "14.2.5",
|
||||
"@next/mdx": "14.2.13",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"acorn": "^8.12.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"clsx": "^2.1.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"flexsearch": "^0.7.43",
|
||||
"framer-motion": "11.3.20",
|
||||
"framer-motion": "11.7.0",
|
||||
"lottie-web": "^5.12.2",
|
||||
"lucide": "^0.418.0",
|
||||
"lucide-react": "^0.418.0",
|
||||
"lucide": "^0.446.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mdx-annotations": "^0.1.4",
|
||||
"next": "14.2.5",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-seo": "^6.5.0",
|
||||
"next": "14.2.13",
|
||||
"next-plausible": "^3.12.2",
|
||||
"next-seo": "^6.6.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"next-themes": "^0.3.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
@@ -56,13 +56,13 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-mdx": "^3.0.1",
|
||||
"schema-dts": "^1.1.2",
|
||||
"sharp": "^0.33.4",
|
||||
"sharp": "^0.33.5",
|
||||
"shiki": "^0.14.7",
|
||||
"simple-functional-loader": "^1.2.1",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"unist-util-filter": "^5.0.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zustand": "^4.5.4"
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -12,30 +12,30 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.6.1",
|
||||
"@chromatic-com/storybook": "^2.0.2",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-a11y": "^8.2.9",
|
||||
"@storybook/addon-essentials": "^8.2.9",
|
||||
"@storybook/addon-interactions": "^8.2.9",
|
||||
"@storybook/addon-links": "^8.2.9",
|
||||
"@storybook/addon-onboarding": "^8.2.9",
|
||||
"@storybook/blocks": "^8.2.9",
|
||||
"@storybook/react": "^8.2.9",
|
||||
"@storybook/react-vite": "^8.2.9",
|
||||
"@storybook/test": "^8.2.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@storybook/addon-a11y": "^8.3.3",
|
||||
"@storybook/addon-essentials": "^8.3.3",
|
||||
"@storybook/addon-interactions": "^8.3.3",
|
||||
"@storybook/addon-links": "^8.3.3",
|
||||
"@storybook/addon-onboarding": "^8.3.3",
|
||||
"@storybook/blocks": "^8.3.3",
|
||||
"@storybook/react": "^8.3.3",
|
||||
"@storybook/react-vite": "^8.3.3",
|
||||
"@storybook/test": "^8.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
||||
"@typescript-eslint/parser": "^8.7.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"esbuild": "^0.23.0",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint-plugin-storybook": "^0.9.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"storybook": "^8.2.9",
|
||||
"tsup": "^8.2.4",
|
||||
"vite": "^5.4.1"
|
||||
"storybook": "^8.3.3",
|
||||
"tsup": "^8.3.0",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Dance from "@/images/onboarding-dance.gif";
|
||||
import Lost from "@/images/onboarding-lost.gif";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
@@ -59,21 +62,20 @@ export const ConnectWithFormbricks = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border text-center",
|
||||
widgetSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
||||
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border bg-slate-200 text-center shadow",
|
||||
widgetSetupCompleted ? "border-green-500 bg-green-100" : ""
|
||||
)}>
|
||||
{widgetSetupCompleted ? (
|
||||
<div>
|
||||
<p className="text-3xl">Congrats!</p>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">Well done! We're connected.</p>
|
||||
<Image src={Dance} alt="lost" height={250} />
|
||||
<p className="mt-6 text-xl font-bold">Connection successful ✅</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||
<span className="relative flex h-10 w-10">
|
||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">Waiting for your signal...</p>
|
||||
<div className="space-y-4">
|
||||
<Image src={Lost} alt="lost" height={250} />
|
||||
<p className="animate-pulse pt-4 text-sm font-semibold text-slate-700">
|
||||
Waiting for your signal...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<Button
|
||||
id="onboarding-inapp-invite-have-a-look-first"
|
||||
className="text-slate-400"
|
||||
className="font-normal text-slate-400"
|
||||
variant="minimal"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -33,7 +33,7 @@ const Page = async ({ params }: InvitePageProps) => {
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
|
||||
<Header
|
||||
title="Who is your favorite engineer?"
|
||||
subtitle="Invite your tech-savvy co-worker to help with the setup."
|
||||
subtitle="Invite your tech-savvy co-worker to help with the setup 🤓"
|
||||
/>
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
|
||||
@@ -28,7 +28,10 @@ const Page = async ({ params }: ConnectPageProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||
<Header title={`Let's connect your product with Formbricks`} subtitle="It takes less than 4 minutes." />
|
||||
<Header
|
||||
title={`Let's connect your product with Formbricks`}
|
||||
subtitle="It takes less than 4 minutes, pinky promise!"
|
||||
/>
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
<p className="text-sm text-slate-500"></p>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
|
||||
|
||||
interface AddressQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -33,64 +32,6 @@ export const AddressQuestionForm = ({
|
||||
}: AddressQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: "addressLine1",
|
||||
label: "Address Line 1",
|
||||
...question.addressLine1,
|
||||
},
|
||||
{
|
||||
id: "addressLine2",
|
||||
label: "Address Line 2",
|
||||
...question.addressLine2,
|
||||
},
|
||||
{
|
||||
id: "city",
|
||||
label: "City",
|
||||
...question.city,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: "State",
|
||||
...question.state,
|
||||
},
|
||||
{
|
||||
id: "zip",
|
||||
label: "Zip",
|
||||
...question.zip,
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
label: "Country",
|
||||
...question.country,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const allFieldsAreOptional = [
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
updateQuestion(questionIdx, { required: false });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -140,30 +81,73 @@ export const AddressQuestionForm = ({
|
||||
Add Description
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<QuestionToggleTable
|
||||
type="address"
|
||||
fields={fields}
|
||||
onShowToggle={(field, show) => {
|
||||
<div className="mt-2 font-medium">Settings</div>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isAddressLine1Required}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, {
|
||||
[field.id]: {
|
||||
show,
|
||||
required: field.required,
|
||||
},
|
||||
// when show changes, and the field is required, the question should be required
|
||||
...(show && field.required && { required: true }),
|
||||
});
|
||||
}}
|
||||
onRequiredToggle={(field, required) => {
|
||||
updateQuestion(questionIdx, {
|
||||
[field.id]: {
|
||||
show: field.show,
|
||||
required,
|
||||
},
|
||||
isAddressLine1Required: !question.isAddressLine1Required,
|
||||
required: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
})
|
||||
}
|
||||
htmlId="isAddressRequired"
|
||||
title="Required: Address Line 1"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isAddressLine2Required}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, {
|
||||
isAddressLine2Required: !question.isAddressLine2Required,
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
htmlId="isAddressLine2Required"
|
||||
title="Required: Address Line 2"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isCityRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isCityRequired: !question.isCityRequired, required: true })
|
||||
}
|
||||
htmlId="isCityRequired"
|
||||
title="Required: City / Town"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isStateRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isStateRequired: !question.isStateRequired, required: true })
|
||||
}
|
||||
htmlId="isStateRequired"
|
||||
title="Required: State / Region"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isZipRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isZipRequired: !question.isZipRequired, required: true })
|
||||
}
|
||||
htmlId="isZipRequired"
|
||||
title="Required: ZIP / Post Code"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.isCountryRequired}
|
||||
onToggle={() =>
|
||||
updateQuestion(questionIdx, { isCountryRequired: !question.isCountryRequired, required: true })
|
||||
}
|
||||
htmlId="iscountryRequired"
|
||||
title="Required: Country"
|
||||
description=""
|
||||
childBorder
|
||||
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
|
||||
|
||||
interface ContactInfoQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyContactInfoQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyContactInfoQuestion>) => void;
|
||||
lastQuestion: boolean;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export const ContactInfoQuestionForm = ({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
}: ContactInfoQuestionFormProps): JSX.Element => {
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: "firstName",
|
||||
label: "First Name",
|
||||
...question.firstName,
|
||||
},
|
||||
{
|
||||
id: "lastName",
|
||||
label: "Last Name",
|
||||
...question.lastName,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "Email",
|
||||
...question.email,
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
label: "Phone",
|
||||
...question.phone,
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
label: "Company",
|
||||
...question.company,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const allFieldsAreOptional = [
|
||||
question.firstName,
|
||||
question.lastName,
|
||||
question.email,
|
||||
question.phone,
|
||||
question.company,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
updateQuestion(questionIdx, { required: false });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={"Question*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{question.subheader !== undefined && (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{question.subheader === undefined && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
className="mt-4"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateQuestion(questionIdx, {
|
||||
subheader: createI18nString("", surveyLanguageCodes),
|
||||
});
|
||||
}}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<QuestionToggleTable
|
||||
type="contact"
|
||||
fields={fields}
|
||||
onShowToggle={(field, show) => {
|
||||
updateQuestion(questionIdx, {
|
||||
[field.id]: {
|
||||
show,
|
||||
required: field.required,
|
||||
},
|
||||
// when show changes, and the field is required, the question should be required
|
||||
...(show && field.required && { required: true }),
|
||||
});
|
||||
}}
|
||||
onRequiredToggle={(field, required) => {
|
||||
updateQuestion(questionIdx, {
|
||||
[field.id]: {
|
||||
show: field.show,
|
||||
required,
|
||||
},
|
||||
required: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/s
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixQuestionFormProps {
|
||||
@@ -79,24 +78,6 @@ export const MatrixQuestionForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const shuffleOptionsTypes = {
|
||||
none: {
|
||||
id: "none",
|
||||
label: "Keep current order",
|
||||
show: true,
|
||||
},
|
||||
all: {
|
||||
id: "all",
|
||||
label: "Randomize all",
|
||||
show: true,
|
||||
},
|
||||
exceptLast: {
|
||||
id: "exceptLast",
|
||||
label: "Randomize all except last option",
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -154,7 +135,7 @@ export const MatrixQuestionForm = ({
|
||||
{question.rows.map((_, index) => (
|
||||
<div className="flex items-center" onKeyDown={(e) => handleKeyDown(e, "row")}>
|
||||
<QuestionFormInput
|
||||
key={`row-${index}-${question.rows.length}`}
|
||||
key={`row-${index}`}
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
@@ -196,7 +177,7 @@ export const MatrixQuestionForm = ({
|
||||
{question.columns.map((_, index) => (
|
||||
<div className="flex items-center" onKeyDown={(e) => handleKeyDown(e, "column")}>
|
||||
<QuestionFormInput
|
||||
key={`column-${index}-${question.columns.length}`}
|
||||
key={`column-${index}`}
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
@@ -230,14 +211,6 @@ export const MatrixQuestionForm = ({
|
||||
<span>Add column</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-1 items-center justify-end gap-2">
|
||||
<ShuffleOptionSelect
|
||||
shuffleOptionsTypes={shuffleOptionsTypes}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
shuffleOption={question.shuffleOption}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@formbricks/ui/components/Select";
|
||||
import { QuestionOptionChoice } from "./QuestionOptionChoice";
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
@@ -284,12 +290,29 @@ export const MultipleChoiceQuestionForm = ({
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
<ShuffleOptionSelect
|
||||
questionIdx={questionIdx}
|
||||
shuffleOption={question.shuffleOption}
|
||||
updateQuestion={updateQuestion}
|
||||
shuffleOptionsTypes={shuffleOptionsTypes}
|
||||
/>
|
||||
<Select
|
||||
defaultValue={question.shuffleOption}
|
||||
value={question.shuffleOption}
|
||||
onValueChange={(e: TShuffleOption) => {
|
||||
updateQuestion(questionIdx, { shuffleOption: e });
|
||||
}}>
|
||||
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-medium text-slate-600">
|
||||
<SelectValue placeholder="Select ordering" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(shuffleOptionsTypes).map(
|
||||
(shuffleOptionsType) =>
|
||||
shuffleOptionsType.show && (
|
||||
<SelectItem
|
||||
key={shuffleOptionsType.id}
|
||||
value={shuffleOptionsType.id}
|
||||
title={shuffleOptionsType.label}>
|
||||
{shuffleOptionsType.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ContactInfoQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm";
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
@@ -95,25 +94,16 @@ export const QuestionCard = ({
|
||||
};
|
||||
|
||||
const getIsRequiredToggleDisabled = (): boolean => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Address) {
|
||||
if (question.type === "address") {
|
||||
return [
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
question.isAddressLine1Required,
|
||||
question.isAddressLine2Required,
|
||||
question.isCityRequired,
|
||||
question.isCountryRequired,
|
||||
question.isStateRequired,
|
||||
question.isZipRequired,
|
||||
].some((condition) => condition === true);
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
return [question.firstName, question.lastName, question.email, question.phone, question.company]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -393,18 +383,6 @@ export const QuestionCard = ({
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
|
||||
@@ -7,11 +7,22 @@ import { PlusIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TI18nString, TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TShuffleOption,
|
||||
TSurvey,
|
||||
TSurveyRankingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@formbricks/ui/components/Select";
|
||||
import { QuestionOptionChoice } from "./QuestionOptionChoice";
|
||||
|
||||
interface RankingQuestionFormProps {
|
||||
@@ -216,12 +227,29 @@ export const RankingQuestionForm = ({
|
||||
onClick={() => addOption()}>
|
||||
Add option
|
||||
</Button>
|
||||
<ShuffleOptionSelect
|
||||
shuffleOptionsTypes={shuffleOptionsTypes}
|
||||
updateQuestion={updateQuestion}
|
||||
shuffleOption={question.shuffleOption}
|
||||
questionIdx={questionIdx}
|
||||
/>
|
||||
<Select
|
||||
defaultValue={question.shuffleOption}
|
||||
value={question.shuffleOption}
|
||||
onValueChange={(option: TShuffleOption) => {
|
||||
updateQuestion(questionIdx, { shuffleOption: option });
|
||||
}}>
|
||||
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-medium text-slate-600">
|
||||
<SelectValue placeholder="Select ordering" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(shuffleOptionsTypes).map(
|
||||
(shuffleOptionsType) =>
|
||||
shuffleOptionsType.show && (
|
||||
<SelectItem
|
||||
key={shuffleOptionsType.id}
|
||||
value={shuffleOptionsType.id}
|
||||
title={shuffleOptionsType.label}>
|
||||
{shuffleOptionsType.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,7 @@ export const SettingsView = ({
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environment.id}
|
||||
attributeClasses={attributeClasses}
|
||||
actionClasses={actionClasses}
|
||||
segments={segments}
|
||||
initialSegment={segments.find((segment) => segment.id === localSurvey.segment?.id)}
|
||||
/>
|
||||
|
||||
@@ -132,97 +132,101 @@ export const SurveyEditor = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<SurveyMenuBar
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
localSurvey={localSurvey}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
product={localProduct}
|
||||
responseCount={responseCount}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
|
||||
ref={surveyEditorRef}>
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
isCxMode={isCxMode}
|
||||
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
|
||||
/>
|
||||
|
||||
{activeView === "questions" && (
|
||||
<QuestionsView
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
product={localProduct}
|
||||
invalidQuestions={invalidQuestions}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<SurveyMenuBar
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
localSurvey={localSurvey}
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
product={localProduct}
|
||||
responseCount={responseCount}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
|
||||
ref={surveyEditorRef}>
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
isCxMode={isCxMode}
|
||||
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "styling" && product.styling.allowStyleOverwrite && (
|
||||
<StylingView
|
||||
colors={colors}
|
||||
environment={environment}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
{activeView === "questions" && (
|
||||
<QuestionsView
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
product={localProduct}
|
||||
invalidQuestions={invalidQuestions}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "styling" && product.styling.allowStyleOverwrite && (
|
||||
<StylingView
|
||||
colors={colors}
|
||||
environment={environment}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
product={localProduct}
|
||||
styling={styling ?? null}
|
||||
setStyling={setStyling}
|
||||
localStylingChanges={localStylingChanges}
|
||||
setLocalStylingChanges={setLocalStylingChanges}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "settings" && (
|
||||
<SettingsView
|
||||
environment={environment}
|
||||
organizationId={organizationId}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
responseCount={responseCount}
|
||||
membershipRole={membershipRole}
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
product={localProduct}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
|
||||
<PreviewSurvey
|
||||
survey={localSurvey}
|
||||
questionId={activeQuestionId}
|
||||
product={localProduct}
|
||||
styling={styling ?? null}
|
||||
setStyling={setStyling}
|
||||
localStylingChanges={localStylingChanges}
|
||||
setLocalStylingChanges={setLocalStylingChanges}
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "settings" && (
|
||||
<SettingsView
|
||||
environment={environment}
|
||||
organizationId={organizationId}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
responseCount={responseCount}
|
||||
membershipRole={membershipRole}
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
product={localProduct}
|
||||
previewType={
|
||||
localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"
|
||||
}
|
||||
languageCode={selectedLanguageCode}
|
||||
onFileUpload={async (file) => file.name}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
|
||||
<PreviewSurvey
|
||||
survey={localSurvey}
|
||||
questionId={activeQuestionId}
|
||||
product={localProduct}
|
||||
environment={environment}
|
||||
previewType={localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"}
|
||||
languageCode={selectedLanguageCode}
|
||||
onFileUpload={async (file) => file.name}
|
||||
/>
|
||||
</aside>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -178,11 +178,6 @@ export const isEndingCardValid = (
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
) => {
|
||||
if (card.type === "endScreen") {
|
||||
const parseResult = z.string().url().safeParse(card.buttonLink);
|
||||
if (card.buttonLabel !== undefined && !parseResult.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isContentValid(card.headline, surveyLanguages) &&
|
||||
isContentValid(card.subheader, surveyLanguages) &&
|
||||
@@ -193,6 +188,7 @@ export const isEndingCardValid = (
|
||||
if (parseResult.success) {
|
||||
return card.label?.trim() !== "";
|
||||
} else {
|
||||
toast.error("Invalid Redirect Url in Ending card");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { SegmentSettings } from "@formbricks/ee/advanced-targeting/components/segment-settings";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
|
||||
@@ -15,6 +16,7 @@ interface EditSegmentModalProps {
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
@@ -24,6 +26,7 @@ export const EditSegmentModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
currentSegment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
isAdvancedTargetingAllowed,
|
||||
@@ -33,6 +36,7 @@ export const EditSegmentModal = ({
|
||||
if (isAdvancedTargetingAllowed) {
|
||||
return (
|
||||
<SegmentSettings
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
initialSegment={currentSegment}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentTableDataRowContainer } from "./SegmentTableDataRowContainer";
|
||||
@@ -5,11 +6,13 @@ import { SegmentTableDataRowContainer } from "./SegmentTableDataRowContainer";
|
||||
type TSegmentTableProps = {
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTable = ({
|
||||
segments,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
isAdvancedTargetingAllowed,
|
||||
}: TSegmentTableProps) => {
|
||||
@@ -29,6 +32,7 @@ export const SegmentTable = ({
|
||||
<SegmentTableDataRowContainer
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { EditSegmentModal } from "./EditSegmentModal";
|
||||
@@ -11,12 +12,14 @@ type TSegmentTableDataRowProps = {
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTableDataRow = ({
|
||||
currentSegment,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
isAdvancedTargetingAllowed,
|
||||
@@ -62,6 +65,7 @@ export const SegmentTableDataRow = ({
|
||||
open={isEditSegmentModalOpen}
|
||||
setOpen={setIsEditSegmentModalOpen}
|
||||
currentSegment={currentSegment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={segments}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getSurveysBySegmentId } from "@formbricks/lib/survey/service";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentTableDataRow } from "./SegmentTableDataRow";
|
||||
@@ -8,12 +9,14 @@ type TSegmentTableDataRowProps = {
|
||||
currentSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTableDataRowContainer = async ({
|
||||
currentSegment,
|
||||
segments,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
isAdvancedTargetingAllowed,
|
||||
}: TSegmentTableDataRowProps) => {
|
||||
@@ -35,6 +38,7 @@ export const SegmentTableDataRowContainer = async ({
|
||||
inactiveSurveys,
|
||||
}}
|
||||
segments={segments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId
|
||||
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
||||
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
||||
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
@@ -12,10 +13,11 @@ import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const [environment, segments, attributeClasses, organization] = await Promise.all([
|
||||
const [environment, segments, attributeClasses, actionClasses, organization] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getSegments(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
getActionClasses(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
@@ -39,6 +41,7 @@ const Page = async ({ params }) => {
|
||||
isAdvancedTargetingAllowed ? (
|
||||
<CreateSegmentModal
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
segments={filteredSegments}
|
||||
/>
|
||||
@@ -57,6 +60,7 @@ const Page = async ({ params }) => {
|
||||
</PageHeader>
|
||||
<SegmentTable
|
||||
segments={filteredSegments}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
/>
|
||||
|
||||
@@ -232,7 +232,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Questions</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
|
||||
(question) => (
|
||||
|
||||
@@ -84,7 +84,7 @@ export const DisableTwoFactorModal = ({ open, setOpen }: TDisableTwoFactorModalP
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...field}
|
||||
/>
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ const ConfirmPasswordForm = ({
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...field}
|
||||
/>
|
||||
|
||||
|
||||
@@ -30,16 +30,6 @@ const formatAddressData = (responseValue: TResponseDataValue): Record<string, st
|
||||
: {};
|
||||
};
|
||||
|
||||
const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
};
|
||||
|
||||
const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
|
||||
let responseData: Record<string, any> = {};
|
||||
|
||||
@@ -54,9 +44,6 @@ const extractResponseData = (response: TResponse, survey: TSurvey): Record<strin
|
||||
case "address":
|
||||
responseData = { ...responseData, ...formatAddressData(responseValue) };
|
||||
break;
|
||||
case "contactInfo":
|
||||
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
|
||||
break;
|
||||
default:
|
||||
responseData[question.id] = responseValue;
|
||||
}
|
||||
|
||||
@@ -35,23 +35,6 @@ const getAddressFieldLabel = (field: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContactInfoFieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return "First Name";
|
||||
case "lastName":
|
||||
return "Last Name";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "phone":
|
||||
return "Phone";
|
||||
case "company":
|
||||
return "Company";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getQuestionColumnsData = (
|
||||
question: TSurveyQuestion,
|
||||
survey: TSurvey,
|
||||
@@ -105,30 +88,6 @@ const getQuestionColumnsData = (
|
||||
};
|
||||
});
|
||||
|
||||
case "contactInfo":
|
||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return contactInfoFields.map((contactInfoField) => {
|
||||
return {
|
||||
accessorKey: contactInfoField,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["contactInfo"]}</span>
|
||||
<span className="truncate">{getContactInfoFieldLabel(contactInfoField)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[contactInfoField];
|
||||
if (typeof responseValue === "string") {
|
||||
return <p className="text-slate-900">{responseValue}</p>;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
default:
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { ArrayResponse } from "@formbricks/ui/components/ArrayResponse";
|
||||
import { AddressResponse } from "@formbricks/ui/components/AddressResponse";
|
||||
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
@@ -27,7 +27,7 @@ export const AddressSummary = ({
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div>
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">User</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||
@@ -60,9 +60,11 @@ export const AddressSummary = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
{
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<AddressResponse value={response.value} />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { ArrayResponse } from "@formbricks/ui/components/ArrayResponse";
|
||||
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface ContactInfoSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryContactInfo;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export const ContactInfoSummary = ({
|
||||
questionSummary,
|
||||
environmentId,
|
||||
survey,
|
||||
attributeClasses,
|
||||
}: ContactInfoSummaryProps) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div>
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">User</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||
<div className="px-4 md:px-6">Time</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getPersonIdentifier(response.person, response.personAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -29,7 +29,6 @@ export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) =>
|
||||
? "Almost there! Install widget to start receiving responses."
|
||||
: "Congrats! Your survey is live.",
|
||||
{
|
||||
id: "survey-publish-success-toast",
|
||||
icon: isAppSurvey && !widgetSetupCompleted ? "🤏" : "🎉",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/survey
|
||||
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
||||
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
||||
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
||||
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
|
||||
import { DateQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary";
|
||||
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
||||
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
|
||||
@@ -279,17 +278,6 @@ export const SummaryList = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
return (
|
||||
<ContactInfoSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ export const PasswordResetForm = ({}) => {
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ResetPasswordForm = () => {
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark mt-2 block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand mt-2 block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -84,7 +84,7 @@ export const ResetPasswordForm = () => {
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark mt-2 block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand mt-2 block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export const SigninForm = ({
|
||||
required
|
||||
placeholder="work@email.com"
|
||||
defaultValue={searchParams?.get("email") || ""}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...formMethods.register("email", {
|
||||
required: true,
|
||||
pattern: /\S+@\S+\.\S+/,
|
||||
@@ -178,7 +178,7 @@ export const SigninForm = ({
|
||||
aria-placeholder="password"
|
||||
onFocus={() => setIsPasswordFocused(true)}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TwoFactorBackup = () => {
|
||||
id="totpBackup"
|
||||
required
|
||||
placeholder="XXXXX-XXXXX"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,9 @@ import { responses } from "@/app/lib/api/response";
|
||||
import fs from "fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = async (_: NextRequest, { params }: { params: { package: string } }) => {
|
||||
export const GET = async (_: NextRequest, { params }: { params: { slug: string } }) => {
|
||||
let path: string;
|
||||
const packageRequested = params.package;
|
||||
const packageRequested = params["package"];
|
||||
|
||||
switch (packageRequested) {
|
||||
case "app":
|
||||
@@ -21,7 +21,7 @@ export const GET = async (_: NextRequest, { params }: { params: { package: strin
|
||||
"package",
|
||||
packageRequested,
|
||||
true,
|
||||
"public, max-age=600, s-maxage=600, stale-while-revalidate=600, stale-if-error=600" // 10 minutes cache for not found
|
||||
"public, s-maxage=600, max-age=1800, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,26 +30,16 @@ export const GET = async (_: NextRequest, { params }: { params: { package: strin
|
||||
return new Response(packageSrcCode, {
|
||||
headers: {
|
||||
"Content-Type": "application/javascript",
|
||||
"Cache-Control":
|
||||
"public, max-age=3600, s-maxage=604800, stale-while-revalidate=3600, stale-if-error=3600",
|
||||
"Cache-Control": "public, s-maxage=600, max-age=1800, stale-while-revalidate=600, stale-if-error=600",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return responses.notFoundResponse(
|
||||
"package",
|
||||
packageRequested,
|
||||
true,
|
||||
"public, max-age=600, s-maxage=600, stale-while-revalidate=600, stale-if-error=600" // 10 minutes cache for file not found errors
|
||||
);
|
||||
} else {
|
||||
console.error("Error reading file:", error);
|
||||
return responses.internalServerErrorResponse(
|
||||
"internal server error",
|
||||
true,
|
||||
"public, max-age=600, s-maxage=600, stale-while-revalidate=600, stale-if-error=600" // 10 minutes cache for internal errors
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading file:", error);
|
||||
return responses.internalServerErrorResponse(
|
||||
"file not found:",
|
||||
true,
|
||||
"public, s-maxage=600, max-age=1800, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,27 +3,26 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/email";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { CRON_SECRET } from "@formbricks/lib/constants";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
import { webhookCache } from "@formbricks/lib/webhook/cache";
|
||||
import { TPipelineTrigger, ZPipelineInput } from "@formbricks/types/pipelines";
|
||||
import { TWebhook } from "@formbricks/types/webhooks";
|
||||
import { ZPipelineInput } from "@formbricks/types/pipelines";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
// Check authentication
|
||||
// check authentication with x-api-key header and CRON_SECRET env variable
|
||||
if (headers().get("x-api-key") !== CRON_SECRET) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const jsonInput = await request.json();
|
||||
const convertedJsonInput = convertDatesInObject(jsonInput);
|
||||
|
||||
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
|
||||
convertDatesInObject(jsonInput);
|
||||
|
||||
const inputValidation = ZPipelineInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
console.error(inputValidation.error);
|
||||
@@ -35,69 +34,52 @@ export const POST = async (request: Request) => {
|
||||
}
|
||||
|
||||
const { environmentId, surveyId, event, response } = inputValidation.data;
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
if (!product) return;
|
||||
|
||||
// Fetch webhooks
|
||||
const getWebhooksForPipeline = cache(
|
||||
async (environmentId: string, event: TPipelineTrigger, surveyId: string) => {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
triggers: { has: event },
|
||||
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
|
||||
// get all webhooks of this environment where event in triggers
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
triggers: {
|
||||
has: event,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
surveyIds: {
|
||||
has: surveyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return webhooks;
|
||||
{
|
||||
surveyIds: {
|
||||
isEmpty: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
[`getWebhooksForPipeline-${environmentId}-${event}-${surveyId}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
);
|
||||
const webhooks: TWebhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
||||
// Prepare webhook and email promises
|
||||
});
|
||||
|
||||
// Fetch with timeout of 5 seconds to prevent hanging
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, options),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: response,
|
||||
}),
|
||||
}).catch((error) => {
|
||||
console.error(`Webhook call to ${webhook.url} failed:`, error);
|
||||
// send request to all webhooks
|
||||
await Promise.all(
|
||||
webhooks.map(async (webhook) => {
|
||||
await fetch(webhook.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: response,
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations, survey, and responseCount in parallel
|
||||
const [integrations, survey, responseCount] = await Promise.all([
|
||||
getIntegrations(environmentId),
|
||||
getSurvey(surveyId),
|
||||
getResponseCountBySurveyId(surveyId),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
console.error(`Survey with id ${surveyId} not found`);
|
||||
return new Response("Survey not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (integrations.length > 0) {
|
||||
await handleIntegrations(integrations, inputValidation.data, survey);
|
||||
}
|
||||
|
||||
// Fetch users with notifications in a single query
|
||||
// TODO: add cache for this query. Not possible at the moment since we can't get the membership cache by environmentId
|
||||
const usersWithNotifications = await prisma.user.findMany({
|
||||
// check for email notifications
|
||||
// get all users that have a membership of this environment's organization
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
@@ -105,48 +87,74 @@ export const POST = async (request: Request) => {
|
||||
products: {
|
||||
some: {
|
||||
environments: {
|
||||
some: { id: environmentId },
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notificationSettings: {
|
||||
path: ["alert", surveyId],
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
const emailPromises = usersWithNotifications.map((user) =>
|
||||
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
|
||||
console.error(`Failed to send email to ${user.email}:`, error);
|
||||
})
|
||||
);
|
||||
const [integrations, surveyData] = await Promise.all([
|
||||
getIntegrations(environmentId),
|
||||
getSurvey(surveyId),
|
||||
]);
|
||||
const survey = surveyData ?? undefined;
|
||||
|
||||
// Update survey status if necessary
|
||||
if (survey.autoComplete && responseCount === survey.autoComplete) {
|
||||
survey.status = "completed";
|
||||
await updateSurvey(survey);
|
||||
if (integrations.length > 0 && survey) {
|
||||
await handleIntegrations(integrations, inputValidation.data, survey);
|
||||
}
|
||||
|
||||
// Await webhook and email promises with allSettled to prevent early rejection
|
||||
const results = await Promise.allSettled([...webhookPromises, ...emailPromises]);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.error("Promise rejected:", result.reason);
|
||||
// filter all users that have email notifications enabled for this survey
|
||||
const usersWithNotifications = users.filter((user) => {
|
||||
const notificationSettings: TUserNotificationSettings | null = user.notificationSettings;
|
||||
if (notificationSettings?.alert && notificationSettings.alert[surveyId]) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
|
||||
const results = await Promise.allSettled(webhookPromises);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.error("Promise rejected:", result.reason);
|
||||
|
||||
// Exclude current response
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
|
||||
if (usersWithNotifications.length > 0) {
|
||||
if (!survey) {
|
||||
console.error(`Pipeline: Survey with id ${surveyId} not found`);
|
||||
return new Response("Survey not found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
});
|
||||
// send email to all users
|
||||
await Promise.all(
|
||||
usersWithNotifications.map(async (user) => {
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount);
|
||||
})
|
||||
);
|
||||
}
|
||||
const updateSurveyStatus = async (surveyId: string) => {
|
||||
// Get the survey instance by surveyId
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (survey?.autoComplete) {
|
||||
// Get the number of responses to a survey
|
||||
const responseCount = await prisma.response.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
});
|
||||
if (responseCount === survey.autoComplete) {
|
||||
const updatedSurvey = { ...survey };
|
||||
updatedSurvey.status = "completed";
|
||||
await updateSurvey(updatedSurvey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await updateSurveyStatus(surveyId);
|
||||
}
|
||||
|
||||
return Response.json({ data: {} });
|
||||
|
||||
@@ -9,13 +9,17 @@ import { getDisplaysByUserId } from "@formbricks/lib/display/service";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { personCache } from "@formbricks/lib/person/cache";
|
||||
import { getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { getResponsesByUserId } from "@formbricks/lib/response/service";
|
||||
import { segmentCache } from "@formbricks/lib/segment/cache";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
|
||||
/**
|
||||
@@ -26,6 +30,7 @@ import { TJsPersonState } from "@formbricks/types/js";
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
* @throws {OperationNotAllowedError} - If the MAU limit is reached and the person has not been active this month
|
||||
*/
|
||||
export const getPersonState = async ({
|
||||
environmentId,
|
||||
@@ -51,21 +56,60 @@ export const getPersonState = async ({
|
||||
throw new ResourceNotFoundError(`organization`, environmentId);
|
||||
}
|
||||
|
||||
let isMauLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id);
|
||||
const monthlyMiuLimit = organization.billing.limits.monthly.miu;
|
||||
|
||||
isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit;
|
||||
}
|
||||
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (!person) {
|
||||
person = await prisma.person.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
if (isMauLimitReached) {
|
||||
// MAU limit reached: check if person has been active this month; only continue if person has been active
|
||||
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
|
||||
revalidatePerson = true;
|
||||
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
|
||||
if (!person) {
|
||||
// if it's a new person and MAU limit is reached, throw an error
|
||||
throw new OperationNotAllowedError(errorMessage);
|
||||
}
|
||||
|
||||
// check if person has been active this month
|
||||
const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id);
|
||||
if (!isPersonMonthlyActive) {
|
||||
throw new OperationNotAllowedError(errorMessage);
|
||||
}
|
||||
} else {
|
||||
// MAU limit not reached: create person if not exists
|
||||
if (!person) {
|
||||
person = await prisma.person.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePerson = true;
|
||||
}
|
||||
}
|
||||
|
||||
const personResponses = await getResponsesByUserId(environmentId, userId);
|
||||
|
||||
@@ -32,6 +32,7 @@ export const getPersonSegmentIds = (
|
||||
const isIncluded = await evaluateSegment(
|
||||
{
|
||||
attributes,
|
||||
actionIds: [],
|
||||
deviceType,
|
||||
environmentId,
|
||||
personId: person.id,
|
||||
|
||||
@@ -7,10 +7,11 @@ import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { createPerson, getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
@@ -83,31 +84,81 @@ export const GET = async (
|
||||
throw new Error("Organization does not exist");
|
||||
}
|
||||
|
||||
// check if response limit is reached
|
||||
let isAppSurveyResponseLimitReached = false;
|
||||
// check if MAU limit is reached
|
||||
let isMauLimitReached = false;
|
||||
let isMonthlyResponsesLimitReached = false;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const monthlyMiuLimit = organization.billing.limits.monthly.miu;
|
||||
|
||||
isAppSurveyResponseLimitReached =
|
||||
isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit;
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
isMonthlyResponsesLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isAppSurveyResponseLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: { monthly: { responses: monthlyResponseLimit, miu: null } },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (!person) {
|
||||
person = await createPerson(environmentId, userId);
|
||||
if (isMauLimitReached) {
|
||||
// MAU limit reached: check if person has been active this month; only continue if person has been active
|
||||
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
|
||||
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
|
||||
if (!person) {
|
||||
// if it's a new person and MAU limit is reached, throw an error
|
||||
return responses.tooManyRequestsResponse(
|
||||
errorMessage,
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
|
||||
// check if person has been active this month
|
||||
const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id);
|
||||
if (!isPersonMonthlyActive) {
|
||||
return responses.tooManyRequestsResponse(
|
||||
errorMessage,
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// MAU limit not reached: create person if not exists
|
||||
if (!person) {
|
||||
person = await createPerson(environmentId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
monthly: {
|
||||
miu: organization.billing.limits.monthly.miu,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
@@ -135,7 +186,7 @@ export const GET = async (
|
||||
|
||||
// creating state object
|
||||
let state: TJsAppStateSync = {
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
surveys: !isMonthlyResponsesLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, attributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
|
||||
@@ -32,7 +32,6 @@ const conditionOptions = {
|
||||
consent: ["is"],
|
||||
matrix: [""],
|
||||
address: ["is"],
|
||||
contactInfo: ["is"],
|
||||
ranking: ["is"],
|
||||
};
|
||||
const filterOptions = {
|
||||
@@ -43,7 +42,6 @@ const filterOptions = {
|
||||
tags: ["Applied", "Not applied"],
|
||||
consent: ["Accepted", "Dismissed"],
|
||||
address: ["Filled out", "Skipped"],
|
||||
contactInfo: ["Filled out", "Skipped"],
|
||||
ranking: ["Filled out", "Skipped"],
|
||||
};
|
||||
|
||||
@@ -275,11 +273,10 @@ export const getFormattedFilters = (
|
||||
if (!filters.data) filters.data = {};
|
||||
switch (questionType.questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo: {
|
||||
case TSurveyQuestionTypeEnum.Address: {
|
||||
if (filterType.filterComboBoxValue === "Filled out") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "filledOut",
|
||||
op: "submitted",
|
||||
};
|
||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
|
||||
@@ -16,8 +16,8 @@ export const LegalFooter = ({
|
||||
if (!IMPRINT_URL && !PRIVACY_URL && !IS_FORMBRICKS_CLOUD) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 z-[1500] h-10 w-full">
|
||||
<div className="mx-auto flex h-full max-w-lg items-center justify-center p-2 text-center text-xs text-slate-400 text-opacity-50">
|
||||
<div className="absolute bottom-0 h-10 w-full">
|
||||
<div className="mx-auto max-w-lg p-2 text-center text-xs text-slate-400 text-opacity-50">
|
||||
{IMPRINT_URL && (
|
||||
<Link href={IMPRINT_URL} target="_blank" className="hover:underline">
|
||||
Imprint
|
||||
|
||||
BIN
apps/web/images/onboarding-dance.gif
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/images/onboarding-lost.gif
Normal file
|
After Width: | Height: | Size: 415 KiB |
2
apps/web/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"lru-cache": "^11.0.0",
|
||||
"lucide-react": "^0.427.0",
|
||||
"mime": "^4.0.4",
|
||||
"next": "14.2.5",
|
||||
"next": "14.2.13",
|
||||
"next-safe-action": "^7.6.2",
|
||||
"optional": "^0.1.4",
|
||||
"otplib": "^12.0.1",
|
||||
@@ -56,7 +56,7 @@
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"redis": "^4.7.0",
|
||||
"sharp": "^0.33.4",
|
||||
|
||||
@@ -188,12 +188,6 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await page.getByPlaceholder(surveys.createAndSubmit.address.placeholder).fill("This is my Address");
|
||||
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Contact Info Question
|
||||
await expect(page.getByText(surveys.createAndSubmit.contactInfo.question)).toBeVisible();
|
||||
await expect(page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder)).toBeVisible();
|
||||
await page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder).fill("John Doe");
|
||||
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Ranking Question
|
||||
await expect(page.getByText(surveys.createAndSubmit.ranking.question)).toBeVisible();
|
||||
for (let i = 0; i < surveys.createAndSubmit.ranking.choices.length; i++) {
|
||||
@@ -711,10 +705,6 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
await test.step("Verify Survey Response", async () => {
|
||||
await page.goBack();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByRole("link").filter({ hasText: "Responses" }).click();
|
||||
await expect(page.getByRole("table")).toBeVisible();
|
||||
|
||||
@@ -286,24 +286,6 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
|
||||
|
||||
// Fill Contact Info Question
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Contact Info" }).click();
|
||||
await page.getByLabel("Question*").fill(params.contactInfo.question);
|
||||
await page.getByRole("row", { name: "Last Name" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Email" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Phone" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Company" }).getByRole("switch").nth(1).click();
|
||||
|
||||
// Fill Ranking question
|
||||
await page
|
||||
@@ -520,11 +502,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
|
||||
|
||||
// Adding logic
|
||||
// Open Text Question
|
||||
|
||||
@@ -157,10 +157,6 @@ export const surveys = {
|
||||
question: "Where do you live?",
|
||||
placeholder: "Address Line 1",
|
||||
},
|
||||
contactInfo: {
|
||||
question: "Contact Info",
|
||||
placeholder: "First Name",
|
||||
},
|
||||
ranking: {
|
||||
question: "What is most important for you in life?",
|
||||
choices: ["Work", "Money", "Travel", "Family", "Friends"],
|
||||
|
||||
@@ -16,16 +16,6 @@ Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
---
|
||||
|
||||
» 02-October-2024 by [@Jemeni11\_](https://x.com/Jemeni11_)
|
||||
|
||||
---
|
||||
|
||||
» 03-October-2024 by [@adityadeshlahre](https://x.com/adityadeshlahre/)
|
||||
» 03-October-2024 by [@HarshBhatX](https://x.com/HarshBhatX/status/HarshBhatX)
|
||||
---
|
||||
|
||||
» 03-October-2024 by [@Ionfinisher](https://x.com/ion_finisher/)
|
||||
» 01-October-2024 by X
|
||||
|
||||
---
|
||||
|
||||
@@ -17,5 +17,5 @@ Your turn 👇
|
||||
////////////////////////////
|
||||
|
||||
» 01-October-2024 by X
|
||||
» 03-October-2024 by [@Recent-Highlight-449](https://www.reddit.com/user/Recent-Highlight-449/)
|
||||
|
||||
---
|
||||
|
||||
@@ -19,10 +19,7 @@ Your turn 👇
|
||||
|
||||
////////////////////////////
|
||||
|
||||
» 03-Octobet-2024 by Harsh Bhat
|
||||
» Link 1: https://youtu.be/pcC4Dr6Wj2Q?si=j1Rftb57M0V6vfLS
|
||||
» Link 2: https://youtu.be/NQi1CdGo6dU?si=Dhe_sVaT27MOrYVj
|
||||
» Link 3: https://youtu.be/hI4RksyFE2k?si=tgREw0wQ_tirD41O
|
||||
|
||||
» 05-April-2024 by X
|
||||
» Link 1: https...
|
||||
|
||||
---
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions -- using template strings for logging */
|
||||
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { TBaseFilter, TBaseFilters } from "@formbricks/types/segment";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function removeActionFilters(filters: TBaseFilters): TBaseFilters {
|
||||
const cleanedFilters = filters.reduce((acc: TBaseFilters, filter: TBaseFilter) => {
|
||||
if (Array.isArray(filter.resource)) {
|
||||
// If it's a group, recursively clean it
|
||||
const cleanedGroup = removeActionFilters(filter.resource);
|
||||
if (cleanedGroup.length > 0) {
|
||||
acc.push({
|
||||
...filter,
|
||||
resource: cleanedGroup,
|
||||
});
|
||||
}
|
||||
// @ts-expect-error -- we're checking for an older type of filter
|
||||
} else if (filter.resource.root.type !== "action") {
|
||||
// If it's not an action filter, keep it
|
||||
acc.push(filter);
|
||||
}
|
||||
// Action filters are implicitly removed by not being added to acc
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Ensure the first filter in the group has a null connector
|
||||
return cleanedFilters.map((filter, index) => {
|
||||
if (index === 0) {
|
||||
return { ...filter, connector: null };
|
||||
}
|
||||
return filter;
|
||||
});
|
||||
}
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
console.log("Starting the data migration...");
|
||||
|
||||
const segmentsToUpdate = await tx.segment.findMany({});
|
||||
|
||||
console.log(`Found ${segmentsToUpdate.length} total segments`);
|
||||
|
||||
let changedFiltersCount = 0;
|
||||
|
||||
const updatePromises = segmentsToUpdate.map((segment) => {
|
||||
const updatedFilters = removeActionFilters(segment.filters);
|
||||
if (JSON.stringify(segment.filters) !== JSON.stringify(updatedFilters)) {
|
||||
changedFiltersCount++;
|
||||
}
|
||||
|
||||
return tx.segment.update({
|
||||
where: { id: segment.id },
|
||||
data: { filters: updatedFilters },
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
console.log(`Successfully updated ${changedFiltersCount} segments`);
|
||||
},
|
||||
{
|
||||
timeout: 180000, // 3 minutes
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,122 +0,0 @@
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
type TSurveyAddressQuestion,
|
||||
type TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
console.log("Starting data migration...");
|
||||
|
||||
await prisma.$transaction(
|
||||
async (transactionPrisma) => {
|
||||
const surveysWithAddressQuestion = await transactionPrisma.survey.findMany({
|
||||
where: {
|
||||
questions: {
|
||||
array_contains: [{ type: "address" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${surveysWithAddressQuestion.length.toString()} surveys with address questions`);
|
||||
|
||||
const updationPromises = [];
|
||||
for (const survey of surveysWithAddressQuestion) {
|
||||
const updatedQuestions = survey.questions.map((question: TSurveyQuestion) => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Address) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- addressLine1 is not defined for unmigrated surveys
|
||||
if (question.addressLine1 !== undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
isAddressLine1Required,
|
||||
isAddressLine2Required,
|
||||
isCityRequired,
|
||||
isStateRequired,
|
||||
isZipRequired,
|
||||
isCountryRequired,
|
||||
...rest
|
||||
} = question as TSurveyAddressQuestion & {
|
||||
isAddressLine1Required: boolean;
|
||||
isAddressLine2Required: boolean;
|
||||
isCityRequired: boolean;
|
||||
isStateRequired: boolean;
|
||||
isZipRequired: boolean;
|
||||
isCountryRequired: boolean;
|
||||
};
|
||||
|
||||
return {
|
||||
...rest,
|
||||
addressLine1: { show: true, required: isAddressLine1Required },
|
||||
addressLine2: { show: true, required: isAddressLine2Required },
|
||||
city: { show: true, required: isCityRequired },
|
||||
state: { show: true, required: isStateRequired },
|
||||
zip: { show: true, required: isZipRequired },
|
||||
country: { show: true, required: isCountryRequired },
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
});
|
||||
|
||||
const isUpdationNotRequired = updatedQuestions.some(
|
||||
(question: TSurveyQuestion | null) => question === null
|
||||
);
|
||||
|
||||
if (!isUpdationNotRequired) {
|
||||
updationPromises.push(
|
||||
transactionPrisma.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
questions: updatedQuestions.filter((question: TSurveyQuestion | null) => question !== null),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updationPromises.length === 0) {
|
||||
console.log("No surveys require migration... Exiting");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(updationPromises);
|
||||
|
||||
console.log("Total surveys updated: ", updationPromises.length.toString());
|
||||
},
|
||||
{
|
||||
timeout: TRANSACTION_TIMEOUT,
|
||||
}
|
||||
);
|
||||
|
||||
const endTime = Date.now();
|
||||
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
|
||||
}
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Action` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Action" DROP CONSTRAINT "Action_actionClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Action" DROP CONSTRAINT "Action_personId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Action";
|
||||
@@ -49,13 +49,10 @@
|
||||
"data-migration:remove-dismissed-value-inconsistency": "ts-node ./data-migrations/20240807120500_cta_consent_dismissed_inconsistency/data-migration.ts",
|
||||
"data-migration:v2.5": "pnpm data-migration:remove-dismissed-value-inconsistency",
|
||||
"data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts",
|
||||
"data-migration:address-question": "ts-node ./data-migrations/20240924123456_migrate_address_question/data-migration.ts",
|
||||
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts",
|
||||
"data-migration:segments-actions-cleanup": "ts-node ./data-migrations/20240904091113_removed_actions_table/data-migration.ts"
|
||||
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.18.0",
|
||||
"@prisma/extension-accelerate": "^1.1.0",
|
||||
"@prisma/client": "^5.20.0",
|
||||
"dotenv-cli": "^7.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -63,7 +60,7 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"prisma": "^5.18.0",
|
||||
"prisma": "^5.20.0",
|
||||
"prisma-dbml-generator": "^0.12.0",
|
||||
"prisma-json-types-generator": "^3.0.4",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -100,6 +100,7 @@ model Person {
|
||||
responses Response[]
|
||||
attributes Attribute[]
|
||||
displays Display[]
|
||||
actions Action[]
|
||||
|
||||
@@unique([environmentId, userId])
|
||||
@@index([environmentId])
|
||||
@@ -354,12 +355,30 @@ model ActionClass {
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
surveyTriggers SurveyTrigger[]
|
||||
actions Action[]
|
||||
|
||||
@@unique([key, environmentId])
|
||||
@@unique([name, environmentId])
|
||||
@@index([environmentId, createdAt])
|
||||
}
|
||||
|
||||
model Action {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
actionClass ActionClass @relation(fields: [actionClassId], references: [id], onDelete: Cascade)
|
||||
actionClassId String
|
||||
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
|
||||
personId String
|
||||
/// @zod.custom(imports.ZActionProperties)
|
||||
/// @zod.custom(imports.ZActionProperties)
|
||||
/// [ActionProperties]
|
||||
properties Json @default("{}")
|
||||
|
||||
@@index([personId, actionClassId, createdAt])
|
||||
@@index([actionClassId, createdAt])
|
||||
@@index([personId, createdAt])
|
||||
}
|
||||
|
||||
enum EnvironmentType {
|
||||
production
|
||||
development
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { withAccelerate } from "@prisma/extension-accelerate";
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient({
|
||||
datasources: { db: { url: process.env.DATABASE_URL } },
|
||||
...(process.env.DEBUG === "1" && {
|
||||
log: ["query", "info"],
|
||||
}),
|
||||
}).$extends(withAccelerate());
|
||||
const prismaClientSingleton = (): PrismaClient => {
|
||||
return new PrismaClient();
|
||||
};
|
||||
|
||||
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
|
||||
declare const globalThis: {
|
||||
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
|
||||
} & typeof global;
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClientSingleton | undefined;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- as stated by the Prisma documentation
|
||||
export const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "lucide-react";
|
||||
import { FingerprintIcon, MonitorSmartphoneIcon, MousePointerClick, TagIcon, Users2Icon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
@@ -19,11 +20,12 @@ interface TAddFilterModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
segments: TSegment[];
|
||||
}
|
||||
|
||||
type TFilterType = "attribute" | "segment" | "device" | "person";
|
||||
type TFilterType = "action" | "attribute" | "segment" | "device" | "person";
|
||||
|
||||
const handleAddFilter = ({
|
||||
type,
|
||||
@@ -31,15 +33,41 @@ const handleAddFilter = ({
|
||||
setOpen,
|
||||
attributeClassName,
|
||||
deviceType,
|
||||
actionClassId,
|
||||
segmentId,
|
||||
}: {
|
||||
type: TFilterType;
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
actionClassId?: string;
|
||||
attributeClassName?: string;
|
||||
segmentId?: string;
|
||||
deviceType?: string;
|
||||
}): void => {
|
||||
if (type === "action") {
|
||||
if (!actionClassId) return;
|
||||
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
resource: {
|
||||
id: createId(),
|
||||
root: {
|
||||
type,
|
||||
actionClassId,
|
||||
},
|
||||
qualifier: {
|
||||
metric: "occuranceCount",
|
||||
operator: "greaterThan",
|
||||
},
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
|
||||
onAddFilter(newFilter);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
if (type === "attribute") {
|
||||
if (!attributeClassName) return;
|
||||
|
||||
@@ -204,6 +232,7 @@ export function AddFilterModal({
|
||||
onAddFilter,
|
||||
open,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TAddFilterModalProps) {
|
||||
@@ -229,6 +258,14 @@ export function AddFilterModal({
|
||||
[]
|
||||
);
|
||||
|
||||
const actionClassesFiltered = useMemo(() => {
|
||||
if (!searchValue) return actionClasses;
|
||||
|
||||
return actionClasses.filter((actionClass) =>
|
||||
actionClass.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [actionClasses, searchValue]);
|
||||
|
||||
const attributeClassesFiltered = useMemo(() => {
|
||||
if (!attributeClasses) return [];
|
||||
|
||||
@@ -268,11 +305,18 @@ export function AddFilterModal({
|
||||
{
|
||||
attributes: attributeClassesFiltered,
|
||||
personAttributes: personAttributesFiltered,
|
||||
actions: actionClassesFiltered,
|
||||
segments: segmentsFiltered,
|
||||
devices: deviceTypesFiltered,
|
||||
},
|
||||
],
|
||||
[attributeClassesFiltered, deviceTypesFiltered, personAttributesFiltered, segmentsFiltered]
|
||||
[
|
||||
actionClassesFiltered,
|
||||
attributeClassesFiltered,
|
||||
deviceTypesFiltered,
|
||||
personAttributesFiltered,
|
||||
segmentsFiltered,
|
||||
]
|
||||
);
|
||||
|
||||
const getAllTabContent = () => {
|
||||
@@ -370,6 +414,35 @@ export function AddFilterModal({
|
||||
);
|
||||
};
|
||||
|
||||
const getActionsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{actionClassesFiltered.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>There are no actions yet!</p>
|
||||
</div>
|
||||
)}
|
||||
{actionClassesFiltered.map((actionClass) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "action",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
actionClassId: actionClass.id,
|
||||
});
|
||||
}}>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
<p>{actionClass.name}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getAttributesTabContent = () => {
|
||||
return (
|
||||
<AttributeTabContent
|
||||
@@ -439,6 +512,9 @@ export function AddFilterModal({
|
||||
case "all": {
|
||||
return getAllTabContent();
|
||||
}
|
||||
case "actions": {
|
||||
return getActionsTabContent();
|
||||
}
|
||||
case "attributes": {
|
||||
return getAttributesTabContent();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
@@ -36,6 +37,7 @@ interface UserTargetingAdvancedCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
segments: TSegment[];
|
||||
initialSegment?: TSegment;
|
||||
@@ -45,6 +47,7 @@ export function AdvancedTargetingCard({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
initialSegment,
|
||||
@@ -210,6 +213,7 @@ export function AdvancedTargetingCard({
|
||||
{Boolean(segment?.filters.length) && (
|
||||
<div className="w-full">
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
@@ -265,6 +269,7 @@ export function AdvancedTargetingCard({
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
@@ -297,6 +302,7 @@ export function AdvancedTargetingCard({
|
||||
{segmentEditorViewOnly && segment ? (
|
||||
<div className="opacity-60">
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import type { TActionClass } from "@formbricks/types/action-classes";
|
||||
import type { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -19,9 +20,15 @@ interface TCreateSegmentModalProps {
|
||||
environmentId: string;
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
actionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export function CreateSegmentModal({ environmentId, attributeClasses, segments }: TCreateSegmentModalProps) {
|
||||
export function CreateSegmentModal({
|
||||
environmentId,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
}: TCreateSegmentModalProps) {
|
||||
const router = useRouter();
|
||||
const initialSegmentState = {
|
||||
title: "",
|
||||
@@ -190,6 +197,7 @@ export function CreateSegmentModal({ environmentId, attributeClasses, segments }
|
||||
)}
|
||||
|
||||
<SegmentEditor
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
@@ -209,6 +217,7 @@ export function CreateSegmentModal({ environmentId, attributeClasses, segments }
|
||||
</Button>
|
||||
|
||||
<AddFilterModal
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
|
||||