Compare commits
36 Commits
integratio
...
tweak/than
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d1cb8f595 | ||
|
|
81e9ac0e12 | ||
|
|
be4534da2d | ||
|
|
f4a31ad563 | ||
|
|
82302360fa | ||
|
|
f6f45d74d5 | ||
|
|
04a47b3d0a | ||
|
|
cc64d7dfe9 | ||
|
|
0c30dfbcf3 | ||
|
|
3e9f61792f | ||
|
|
ad63be3005 | ||
|
|
1635297226 | ||
|
|
0ff7bb56ec | ||
|
|
f3666b8745 | ||
|
|
ff864e3c82 | ||
|
|
cc56584db6 | ||
|
|
440c12699c | ||
|
|
8002d3e71f | ||
|
|
0534421538 | ||
|
|
fa33460a16 | ||
|
|
f12dec7b8b | ||
|
|
f2ad7c4fbf | ||
|
|
e6ce5373a2 | ||
|
|
90f0614aac | ||
|
|
a0d7921c01 | ||
|
|
a1fa3d6dbb | ||
|
|
1d7d07b3c6 | ||
|
|
c376b12461 | ||
|
|
c5d9f63267 | ||
|
|
9ec5d668df | ||
|
|
659ef3f92c | ||
|
|
3e2452b10f | ||
|
|
1f79416367 | ||
|
|
d3e0e67bd9 | ||
|
|
abe98be561 | ||
|
|
f23b4f63fa |
@@ -22,6 +22,7 @@
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cp .env.example .env && sed -i '/^ENCRYPTION_KEY=/c\\ENCRYPTION_KEY='$(openssl rand -hex 32) .env && sed -i '/^NEXTAUTH_SECRET=/c\\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env && pnpm install && pnpm db:migrate:dev",
|
||||
"postAttachCommand": "pnpm dev --filter=web... --filter=demo...",
|
||||
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node"
|
||||
|
||||
14
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,3 +1,5 @@
|
||||
<!-- We require pull request titles to follow the Conventional Commits specification ( https://www.conventionalcommits.org/en/v1.0.0/#summary ). Please make sure your title follow these conventions -->
|
||||
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
@@ -8,18 +10,6 @@ Fixes # (issue)
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please mark the relevant points by using [x] -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] Enhancement (small improvements)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change adds a new database migration
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fence } from "@/components/shared/Fence";
|
||||
|
||||
export const metadata = {
|
||||
title: "Formbricks Responses API Documentation - Manage Your Survey Data Seamlessly",
|
||||
title: "Formbricks Actions API Documentation - Manage Your Survey Data Seamlessly",
|
||||
description:
|
||||
"Unlock the full potential of Formbricks' Client Actions API. Create Actions right from the API.",
|
||||
};
|
||||
@@ -13,8 +13,8 @@ export const metadata = {
|
||||
The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information.
|
||||
|
||||
This API can be used to:
|
||||
- [Add Action for User](#add-action-for-user)
|
||||
|
||||
- [Add Action for User](#add-action-for-user)
|
||||
|
||||
---
|
||||
|
||||
@@ -85,4 +85,3 @@ Adds an Actions for a given User by their User ID
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import Image from "next/image";
|
||||
import DemoPreview from "@/components/dummyUI/DemoPreview";
|
||||
import Image from "next/image";
|
||||
|
||||
import CreateChurnFlow from "./create-cancel-flow.webp";
|
||||
import ChangeText from "./change-text.webp";
|
||||
import TriggerInnerText from "./trigger-inner-text.webp";
|
||||
import TriggerCSS from "./trigger-css-selector.webp";
|
||||
import TriggerPageUrl from "./trigger-page-url.webp";
|
||||
import RecontactOptions from "./recontact-options.webp";
|
||||
import CreateChurnFlow from "./create-cancel-flow.webp";
|
||||
import PublishSurvey from "./publish-survey.webp";
|
||||
import RecontactOptions from "./recontact-options.webp";
|
||||
import SelectAction from "./select-action.webp";
|
||||
import TriggerCSS from "./trigger-css-selector.webp";
|
||||
import TriggerInnerText from "./trigger-inner-text.webp";
|
||||
import TriggerPageUrl from "./trigger-page-url.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Mastering Churn Surveys with Formbricks | Essential Tips & Steps",
|
||||
description: "Learn how to effectively utilize Formbricks' Churn Surveys to gain deeper insights into user departures. Dive into a step-by-step guide to craft, trigger, and optimize your churn surveys, ensuring you capture invaluable feedback at critical junctures",
|
||||
description:
|
||||
"Learn how to effectively utilize Formbricks' Churn Surveys to gain deeper insights into user departures. Dive into a step-by-step guide to craft, trigger, and optimize your churn surveys, ensuring you capture invaluable feedback at critical junctures",
|
||||
};
|
||||
|
||||
#### Best Practices
|
||||
@@ -39,15 +40,15 @@ The Churn Survey is among the most effective ways to identify weaknesses in your
|
||||
|
||||
To run the Churn Survey in your app you want to proceed as follows:
|
||||
|
||||
1. Create new Churn Survey at [app.formbricks.com](http://app.formbricks.com/)
|
||||
1. Create new Churn Survey at [app.formbricks.com](https://app.formbricks.com/)
|
||||
2. Set up the user action to display survey at right point in time
|
||||
3. Choose correct recontact options to never miss a feedback
|
||||
4. Prevent that churn!
|
||||
|
||||
<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 (takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
## 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
|
||||
(takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
|
||||
### 1. Create new Churn Survey
|
||||
@@ -60,7 +61,7 @@ Click on "Create Survey" and choose the template “Churn Survey”:
|
||||
src={CreateChurnFlow}
|
||||
alt="Create churn survey by template"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 2. Update questions (if you like)
|
||||
@@ -71,7 +72,7 @@ You’re free to update the question and answer options. However, based on our e
|
||||
src={ChangeText}
|
||||
alt="Change text content"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
_Want to change the button color? You can do so in the product settings._
|
||||
@@ -92,7 +93,7 @@ To create the trigger for your Churn Survey, you have two options to choose from
|
||||
src={TriggerInnerText}
|
||||
alt="Set the trigger by inner Text"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. **Trigger by CSS Selector:** In case you have more than one button saying “Cancel Subscription” in your app and only want to display the survey when one of them is clicked, you want to be more specific. The best way to do that is to give this button the HTML `id=“cancel-subscription”` and set your user action up like so:
|
||||
@@ -101,7 +102,7 @@ To create the trigger for your Churn Survey, you have two options to choose from
|
||||
src={TriggerCSS}
|
||||
alt="Set the trigger by CSS Selector"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. **Trigger by pageURL:** Lastly, you could also display your survey on a subpage “/subscription-cancelled” where you forward users once they cancelled the trial subscription. You can then create a user Action with the type `pageURL` with the following settings:
|
||||
@@ -110,7 +111,7 @@ To create the trigger for your Churn Survey, you have two options to choose from
|
||||
src={TriggerPageUrl}
|
||||
alt="Set the trigger by page URL"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Whenever a user visits this page, matches the filter conditions above and the recontact options (below) the survey will be displayed ✅
|
||||
@@ -118,10 +119,9 @@ Whenever a user visits this page, matches the filter conditions above and the re
|
||||
Here is our complete [Actions manual](/docs/actions/why) covering [Code](/docs/actions/code) and [No-Code](/docs/actions/no-code) Actions.
|
||||
|
||||
<Note>
|
||||
## Pre-churn flow coming soon
|
||||
We’re currently building full-screen survey pop-ups. You’ll be able to prevent users from closing the survey
|
||||
unless they respond to it. It’s certainly debatable if you want that but you could force them to click through
|
||||
the survey before letting them cancel 🤷
|
||||
## Pre-churn flow coming soon We’re currently building full-screen survey pop-ups. You’ll be able to prevent
|
||||
users from closing the survey unless they respond to it. It’s certainly debatable if you want that but you
|
||||
could force them to click through the survey before letting them cancel 🤷
|
||||
</Note>
|
||||
|
||||
### 5. Select Action in the “When to ask” card
|
||||
@@ -130,7 +130,7 @@ Here is our complete [Actions manual](/docs/actions/why) covering [Code](/docs/a
|
||||
src={SelectAction}
|
||||
alt="Select feedback button action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 6. Last step: Set Recontact Options correctly
|
||||
@@ -141,7 +141,7 @@ Lastly, scroll down to “Recontact Options”. Here you have to choose the corr
|
||||
src={RecontactOptions}
|
||||
alt="Set recontact options"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
These settings make sure the survey is always displayed, when a user wants to Cancel their subscription.
|
||||
@@ -152,13 +152,13 @@ These settings make sure the survey is always displayed, when a user wants to Ca
|
||||
src={PublishSurvey}
|
||||
alt="Publish survey"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Churn Survey in your app. Please follow [this
|
||||
tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey) to install the widget.
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Churn Survey
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
###
|
||||
|
||||
@@ -11,7 +11,8 @@ import SelectAction from "./select-action.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Setting Up Feature Chaser Surveys with Formbricks: A Comprehensive Guide",
|
||||
description: "Learn how to harness the power of Formbricks to gather targeted user feedback on specific features. Dive deep into creating, triggering, and publishing the Feature Chaser survey to enhance your product with actionable insights for specific users.",
|
||||
description:
|
||||
"Learn how to harness the power of Formbricks to gather targeted user feedback on specific features. Dive deep into creating, triggering, and publishing the Feature Chaser survey to enhance your product with actionable insights for specific users.",
|
||||
};
|
||||
|
||||
#### Best Practices
|
||||
@@ -38,13 +39,13 @@ Product analytics never tell you why a feature is used - and why not. Following
|
||||
|
||||
To run the Feature Chaser survey in your app you want to proceed as follows:
|
||||
|
||||
1. Create new Feature Chaser survey at [app.formbricks.com](http://app.formbricks.com/)
|
||||
1. Create new Feature Chaser survey at [app.formbricks.com](https://app.formbricks.com/)
|
||||
2. Setup a user action to display survey at the right 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 (takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
## 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
|
||||
(takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
|
||||
### 1. Create new Feature Chaser
|
||||
@@ -57,7 +58,7 @@ Click on "Create Survey" and choose the template “Feature Chaser”:
|
||||
src={CreateSurvey}
|
||||
alt="Create survey by template"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 2. Update questions
|
||||
@@ -68,7 +69,7 @@ The questions you want to ask are dependent on your feature and can be very spec
|
||||
src={ChangeText}
|
||||
alt="Change text content"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Save, and move over to where the magic happens: The “Audience” tab.
|
||||
@@ -87,7 +88,7 @@ There are two ways to track a button:
|
||||
src={ActionText}
|
||||
alt="Set the trigger by inner Text"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. **Trigger by CSS Selector:** In case you have more than one button saying “Export Report” in your app and only want to display the survey when one of them is clicked, you want to be more specific. The best way to do that is to give this button the HTML `id=“export-report-featurename”` and set your user action up like so:
|
||||
@@ -96,7 +97,7 @@ There are two ways to track a button:
|
||||
src={ActionCSS}
|
||||
alt="Set the trigger by CSS Selector"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Please follow our [Actions manual](/docs/actions/why) for an in-depth description of how Actions work.
|
||||
@@ -107,7 +108,7 @@ Please follow our [Actions manual](/docs/actions/why) for an in-depth descriptio
|
||||
src={SelectAction}
|
||||
alt="Select PMF trigger button action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 5. Last step: Set Recontact Options correctly
|
||||
@@ -118,17 +119,17 @@ Lastly, scroll down to “Recontact Options”. Here you have full freedom to de
|
||||
src={RecontactOptions}
|
||||
alt="Set recontact options"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 7. Congrats! You’re ready to publish your survey 💃
|
||||
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feature Chaser in your app. Please follow [this
|
||||
tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey) to install the widget.
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feature Chaser
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
###
|
||||
|
||||
@@ -11,7 +11,8 @@ import SelectAction from "./select-action.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Boost Your Trial Conversion Rates with Formbricks: Comprehensive Guide",
|
||||
description: "Unlock the secret to converting more trial users into paying customers using Formbricks. Understand insights behind trial cancellations and tailor your offering to fit user needs. Dive into our step-by-step tutorial and improve your conversion strategy today",
|
||||
description:
|
||||
"Unlock the secret to converting more trial users into paying customers using Formbricks. Understand insights behind trial cancellations and tailor your offering to fit user needs. Dive into our step-by-step tutorial and improve your conversion strategy today",
|
||||
};
|
||||
|
||||
#### Best Practices
|
||||
@@ -37,14 +38,14 @@ The better you understand why free users don’t convert to paid users, the high
|
||||
|
||||
To display the Trial Conversion Survey in your app you want to proceed as follows:
|
||||
|
||||
1. Create new Trial Conversion Survey at [app.formbricks.com](http://app.formbricks.com/)
|
||||
1. Create new Trial Conversion Survey at [app.formbricks.com](https://app.formbricks.com/)
|
||||
2. Set up the user action to display survey at right point in time
|
||||
3. Print that 💸
|
||||
|
||||
<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 (takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
## 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
|
||||
(takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
|
||||
### 1. Create new Trial Conversion Survey
|
||||
@@ -57,7 +58,7 @@ Click on "Create Survey" and choose the template “Improve Trial Conversion”:
|
||||
src={CreateSurvey}
|
||||
alt="Create survey by template"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 2. Update questions (if you like)
|
||||
@@ -68,7 +69,7 @@ You’re free to update the questions and answer options. However, based on our
|
||||
src={ChangeText}
|
||||
alt="Change text content"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
_Want to change the button color? You can do so in the product settings!_
|
||||
@@ -78,8 +79,8 @@ Save, and move over to the “Audience” tab.
|
||||
### 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.
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next days.
|
||||
</Note>
|
||||
|
||||
Pre-segmentation isn't relevant for this survey because you likely want to solve all people who cancel their trial. You probably have a specific user action e.g. clicking on "Cancel Trial" you can use to only display the survey to users trialing your product.
|
||||
@@ -94,7 +95,7 @@ How you trigger your survey depends on your product. There are two options:
|
||||
src={ActionPageurl}
|
||||
alt="Change text content"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Whenever a user visits this page, the survey will be displayed ✅
|
||||
@@ -105,7 +106,7 @@ Whenever a user visits this page, the survey will be displayed ✅
|
||||
src={ActionText}
|
||||
alt="Change text content"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Please have a look at our complete [Actions manual](/docs/actions/why) if you have questions.
|
||||
@@ -116,7 +117,7 @@ Please have a look at our complete [Actions manual](/docs/actions/why) if you ha
|
||||
src={SelectAction}
|
||||
alt="Select feedback button action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 6. Last step: Set Recontact Options correctly
|
||||
@@ -127,17 +128,17 @@ Lastly, scroll down to “Recontact Options”. Here you have to choose the corr
|
||||
src={RecontactOptions}
|
||||
alt="Set recontact options"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 7. Congrats! You’re ready to publish your survey 💃
|
||||
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feedback Box in your app. Please follow [this
|
||||
tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey) to install the widget.
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
###
|
||||
|
||||
@@ -14,7 +14,8 @@ import SelectAction from "./select-action.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Maximize User Interview Participation with In-app Interview Prompts",
|
||||
description: "Engage with your power users seamlessly using Formbricks' In-app Interview Prompt. Ditch traditional email invites and experience way more more respondents. Dive into our comprehensive guide on setting up auto-scheduled interviews today and enhance your user understanding",
|
||||
description:
|
||||
"Engage with your power users seamlessly using Formbricks' In-app Interview Prompt. Ditch traditional email invites and experience way more more respondents. Dive into our comprehensive guide on setting up auto-scheduled interviews today and enhance your user understanding",
|
||||
};
|
||||
|
||||
#### Best Practices
|
||||
@@ -42,14 +43,14 @@ Product analytics and in-app surveys are incomplete without user interviews. Set
|
||||
|
||||
To display an Interview Prompt in your app you want to proceed as follows:
|
||||
|
||||
1. Create new Interview Prompt at [app.formbricks.com](http://app.formbricks.com/)
|
||||
1. Create new Interview Prompt at [app.formbricks.com](https://app.formbricks.com/)
|
||||
2. Adjust content and settings
|
||||
3. That’s it! 🎉
|
||||
|
||||
<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).](/docs/getting-started/quickstart-in-app-survey)
|
||||
## 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).](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
|
||||
### 1. Create new Interview Prompt
|
||||
@@ -62,7 +63,7 @@ Click on "Create Survey" and choose the template “Interview Prompt”:
|
||||
src={CreatePrompt}
|
||||
alt="Create interview prompt by template"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 2. Update prompt and CTA
|
||||
@@ -73,7 +74,7 @@ Update the prompt, description and button text to match your products tonality.
|
||||
src={ChangeText}
|
||||
alt="Change text content"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
In the button settings you have to make sure it is set to “External URL”. In the URL field, copy your booking link (e.g. https://cal.com/company/user-interview). If you don’t have a booking link yet, head over to [cal.com](http://cal.com) and get one - they have the best free plan out there!
|
||||
@@ -82,7 +83,7 @@ In the button settings you have to make sure it is set to “External URL”. In
|
||||
src={InterviewExample}
|
||||
alt="Add CSS action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Save, and move over to the “Audience” tab.
|
||||
@@ -90,8 +91,8 @@ Save, and move over to the “Audience” tab.
|
||||
### 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 few days.
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next few days.
|
||||
</Note>
|
||||
|
||||
Once you clicked over to the “Audience” tab you can change the settings. In the **Who To Send** card, select “Filter audience by attribute”. This allows you to only show the prompt to a specific segment of your user base.
|
||||
@@ -104,12 +105,12 @@ Great, now only the “Power User” segment will see our Interview Prompt. But
|
||||
|
||||
To create the trigger to show your Interview Prompt, go to the “Audience” tab, find the “When to send” card and choose “Add Action”. We will now use our super cool No-Code User Action Tracker:
|
||||
|
||||
<Image src={AddAction} alt="Add action" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
|
||||
<Image src={AddAction} alt="Add action" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## You can also add actions in your code
|
||||
You can also create [Code Actions](/docs/actions/code) using `formbricks.track("Eventname")` - they will automatically
|
||||
appear in your Actions overview as long as the SDK is embedded.
|
||||
## You can also add actions in your code You can also create [Code Actions](/docs/actions/code) using
|
||||
`formbricks.track("Eventname")` - they will automatically appear in your Actions overview as long as the SDK
|
||||
is embedded.
|
||||
</Note>
|
||||
|
||||
Generally, we have two types of user actions: Page views and clicks. The Interview Prompt, you’ll likely want to display it on a page visit since you already filter who sees the prompt by attributes.
|
||||
@@ -120,18 +121,18 @@ Generally, we have two types of user actions: Page views and clicks. The Intervi
|
||||
src={ActionPageurl}
|
||||
alt="Add page URL action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. **innerText & CSS-Selector:** When a user clicks an element (like a button) with a specific text content or CSS selector, the prompt will be displayed as long as the other conditions also match.
|
||||
|
||||
<div className="flex max-w-full flex-col sm:max-w-3xl lg:gap-1">
|
||||
<Image src={ActionCSS} alt="Add CSS action" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
|
||||
<Image src={ActionCSS} alt="Add CSS action" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
<Image
|
||||
src={ActionInner}
|
||||
alt="Add inner text action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +142,7 @@ Generally, we have two types of user actions: Page views and clicks. The Intervi
|
||||
src={SelectAction}
|
||||
alt="Select feedback button action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 6. Set Recontact Options correctly
|
||||
@@ -152,17 +153,17 @@ Scroll down to “Recontact Options”. Here you have to choose the correct sett
|
||||
src={RecontactOptions}
|
||||
alt="Set recontact options"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 7. Congrats! You’re ready to publish your survey 💃 🤸
|
||||
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feedback Box in your app. Please follow [this
|
||||
tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey) to install the widget.
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
###
|
||||
|
||||
@@ -11,7 +11,8 @@ import SelectAction from "./select-action.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "How to Set Up a Product-Market Fit Survey Using Formbricks - Step-by-Step Guide",
|
||||
description: "Learn to leverage Formbricks to create and implement a Product-Market Fit survey in your web app. Follow our detailed step-by-step guide to measure and understand your PMF effectively. Ensure high data quality, efficient triggers, and actionable insights.",
|
||||
description:
|
||||
"Learn to leverage Formbricks to create and implement a Product-Market Fit survey in your web app. Follow our detailed step-by-step guide to measure and understand your PMF effectively. Ensure high data quality, efficient triggers, and actionable insights.",
|
||||
};
|
||||
|
||||
#### Best Practices
|
||||
@@ -36,14 +37,14 @@ Measuring and understanding your PMF is essential to build a large, successful b
|
||||
|
||||
To display the Product-Market Fit survey in your app you want to proceed as follows:
|
||||
|
||||
1. Create new Product-Market Fit survey at [app.formbricks.com](http://app.formbricks.com/)
|
||||
1. Create new Product-Market Fit survey at [app.formbricks.com](https://app.formbricks.com/)
|
||||
2. Setup pre-segmentation to assure high data quality
|
||||
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).](/docs/getting-started/quickstart-in-app-survey)
|
||||
## 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).](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
|
||||
### 1. Create new PMF survey
|
||||
@@ -56,7 +57,7 @@ Click on "Create Survey" and choose one of the PMF survey templates. The first o
|
||||
src={CreateSurvey}
|
||||
alt="Create survey by template"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 2. Update questions (if you like)
|
||||
@@ -67,7 +68,7 @@ You’re free to update the question and answer options. However, based on our e
|
||||
src={ChangeText}
|
||||
alt="Change text content"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
_Want to change the button color? You can do so in the product settings!_
|
||||
@@ -77,8 +78,8 @@ Save, and move over to where the magic happens: The “Audience” tab.
|
||||
### 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.
|
||||
## 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:
|
||||
@@ -96,25 +97,31 @@ This way you make sure that you separate potentially misleading opinions from va
|
||||
### 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](/docs/actions/why) if you are not sure how to set them up:
|
||||
|
||||
<Col>
|
||||
<div>
|
||||
<Image src={ActionCSS} alt="Add CSS action" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
|
||||
<Image
|
||||
src={ActionPageurl}
|
||||
alt="Add inner text action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Image
|
||||
src={ActionCSS}
|
||||
alt="Add CSS action"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
<Image
|
||||
src={ActionPageurl}
|
||||
alt="Add inner text action"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
### 5. Select Action in the “When to ask” card
|
||||
<Col>
|
||||
<Image
|
||||
src={SelectAction}
|
||||
alt="Select PMF trigger button action"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
/>
|
||||
<Image
|
||||
src={SelectAction}
|
||||
alt="Select PMF trigger button action"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
</Col>
|
||||
### 6. Last step: Set Recontact Options correctly
|
||||
|
||||
@@ -124,17 +131,17 @@ Lastly, scroll down to “Recontact Options”. Here you have to choose the corr
|
||||
src={RecontactOptions}
|
||||
alt="Set recontact options"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 7. Congrats! You’re ready to publish your survey 💃
|
||||
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feedback Box in your app. Please follow [this
|
||||
tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey) to install the widget.
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
###
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 76 KiB |
@@ -6,12 +6,9 @@ import GitpodPorts from "./gitpod/ports.webp";
|
||||
import GitpodPreparing from "./gitpod/preparing.webp";
|
||||
import GitpodRunning from "./gitpod/running.webp";
|
||||
|
||||
import GithubCodespaceEnvFile from "./github-codespaces/env.webp";
|
||||
import GithubCodespaceLoading from "./github-codespaces/loading.webp";
|
||||
import GithubCodespaceNew from "./github-codespaces/new.webp";
|
||||
import GithubCodespacePorts from "./github-codespaces/ports.webp";
|
||||
import GithubCodespaceRun from "./github-codespaces/run.webp";
|
||||
import GithubCodespaceTerminal from "./github-codespaces/terminal.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Formbricks Development Setup: Complete Guide to Local Environment Configuration for Dev",
|
||||
@@ -38,7 +35,7 @@ This will open a fully configured workspace in your browser with all the necessa
|
||||
|
||||
### [Github Codespaces](#Github-codespaces)
|
||||
|
||||
This will open a Github VSCode Interface on the cloud for you. This setup will have the Formbricks codebase and all the dependencies installed. Click the button below to configure your instance and open the project in Github Codespaces. For a detailed guide, visit the [Github Codespaces Setup Guide](#github-codespaces-guide) section below.
|
||||
This will open a Github VSCode Interface on the cloud for you. This setup will have the Formbricks codebase, all the dependencies installed & Formbricks running. Click the button below to configure your instance and open the project in Github Codespaces. For a detailed guide, visit the [Github Codespaces Setup Guide](#github-codespaces-guide) section below.
|
||||
|
||||
[](https://Github.com/codespaces/new?machine=standardLinux32gb&repo=500289888&ref=main&devcontainer_path=.devcontainer%2Fdevcontainer.json&location=EastUs2)
|
||||
|
||||
@@ -225,44 +222,7 @@ These URLs and port numbers represent various services and endpoints within your
|
||||
|
||||
3. Once the Codespace is loaded, you will be redirected to the VSCode editor. You can start working on your project in this environment.
|
||||
|
||||
4. Make the changes you want to, and now, to run the app, we first need to configure the .env file. Copy the .env.example and edit the variables as mentioned in the file itself.
|
||||
|
||||
<Image
|
||||
src={GithubCodespaceEnvFile}
|
||||
alt="Github Codespace Env File"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
5. Once you have configured the .env, it's now time to run the app and see the changes. Lets open the terminal first
|
||||
|
||||
<Image
|
||||
src={GithubCodespaceTerminal}
|
||||
alt="Github Codespace Open Terminal"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
6. Now, run the following command to run the app
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Run the entire Formbricks Stack">
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
<Image
|
||||
src={GithubCodespaceRun}
|
||||
alt="Run on Github Codespace"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
7. Monitor the logs in the terminal and once you see the following, you are good to go!
|
||||
4. Monitor the logs in the terminal and once you see the following, you are good to go!
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="The WebApp is running">
|
||||
@@ -280,7 +240,7 @@ pnpm dev
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
8. Right next to the Terminal, you will see a **Ports** tab, click on it to see the ports and their respective URLs. Now access the Forwarded Address for port 3000 and you should be able to visit your Formbricks App!
|
||||
5. Right next to the Terminal, you will see a **Ports** tab, click on it to see the ports and their respective URLs. Now access the Forwarded Address for port 3000 and you should be able to visit your Formbricks App!
|
||||
|
||||
<Image
|
||||
src={GithubCodespacePorts}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Libraries } from "@/components/docs/Libraries";
|
||||
import Image from "next/image";
|
||||
|
||||
import SetupChecklist from "./env-id.webp";
|
||||
import WidgetNotConnected from "./widget-not-connected.webp";
|
||||
import WidgetConnected from "./widget-connected.webp";
|
||||
import ReactApp from "./react-in-app-survey-app-popup-form.webp";
|
||||
import WidgetConnected from "./widget-connected.webp";
|
||||
import WidgetNotConnected from "./widget-not-connected.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Integrate Formbricks: Comprehensive Framework Guide & Integration Tutorial",
|
||||
@@ -222,9 +223,10 @@ Refer to our [Example NextJS App Directory project](https://github.com/formbrick
|
||||
|
||||
```tsx {{ title: 'Typescript' }}
|
||||
// other import
|
||||
import formbricks from "@formbricks/js";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.init({
|
||||
@@ -386,6 +388,43 @@ To this:
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Overwrite CSS Styles for In-App Surveys
|
||||
|
||||
You can overwrite the default CSS styles for the in-app surveys by adding the following CSS to your global CSS file (eg. `globals.css`):
|
||||
|
||||
Make sure that you do not change the CSS variable names as they are used by Formbricks to identify the CSS variables. You can change the values to your liking. We have filled in some sample values for you to change according to your desired appearance.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Overwrite Formbricks CSS">
|
||||
|
||||
```css
|
||||
/* Formbricks CSS */
|
||||
--fb-brand-color: red;
|
||||
--fb-brand-text-color: white;
|
||||
--fb-border-color: green;
|
||||
--fb-border-color-highlight: rgb(13, 13, 12);
|
||||
--fb-focus-color: red;
|
||||
--fb-heading-color: yellow;
|
||||
--fb-subheading-color: green;
|
||||
--fb-info-text-color: orange;
|
||||
--fb-signature-text-color: blue;
|
||||
--fb-survey-background-color: black;
|
||||
--fb-accent-background-color: rgb(13, 13, 12);
|
||||
--fb-accent-background-color-selected: red;
|
||||
--fb-placeholder-color: white;
|
||||
--fb-shadow-color: var(--fb-brand-color);
|
||||
--fb-rating-fill: rgb(13, 13, 12);
|
||||
--fb-rating-hover: green;
|
||||
--fb-back-btn-border: blue;
|
||||
--fb-submit-btn-border: transparent;
|
||||
--fb-rating-selected: black;
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
We have an example of this in our [Demo project](https://github.com/formbricks/formbricks/blob/main/apps/demo/styles/globals.css.) here.
|
||||
|
||||
**Can’t figure it out? [Join our Discord!](https://formbricks.com/discord)**
|
||||
|
||||
---
|
||||
|
||||
@@ -123,6 +123,7 @@ We will first create a Google Cloud Project and then enable the Google Sheets AP
|
||||
- `GOOGLE_SHEETS_CLIENT_ID` - Client ID
|
||||
- `GOOGLE_SHEETS_CLIENT_SECRET` - Client Secret
|
||||
16. Also use the **same Authorized redirect URI** in the `GOOGLE_SHEETS_REDIRECT_URL` environment variable.
|
||||
17. One last that we need to do is to **enable the Google Drive API** for the project. For that, go to the "**APIs & Services**" section and click on the "**Enable APIs and Services**" button and search for "**Google Drive API**" and enable it.
|
||||
|
||||
### By now, your environment variables should include the below ones as well:
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 45 KiB |
133
apps/formbricks-com/app/docs/integrations/wordpress/page.mdx
Normal file
@@ -0,0 +1,133 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import Img1 from "./1-wordpress-targeted-survey-on-website-free.webp";
|
||||
import Img2 from "./2-run-website-survey-wordpress-targeted-for-free.webp";
|
||||
import Img3 from "./3-wordpress-setup-survey-on-website-targeted-free-open-source.webp";
|
||||
import Img4 from "./4-wordpress-website-survey-target-visitor-free.webp";
|
||||
import Img5 from "./step-4-copy-to-wordpress-for-free-targeted-survey.webp";
|
||||
import Img6 from "./6-targeted-survey-on-wordpress-website-for-free.webp";
|
||||
import Img7 from "./7-wordpress-free-hotjar-survey-open-source-website-survey-hotjar.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Run targeted surveys on your WordPress page",
|
||||
description:
|
||||
"Target specific visitors with a survey on your WordPress page using Formbricks for free. Show survey on specific page or on button click.",
|
||||
};
|
||||
|
||||
#### WordPress
|
||||
|
||||
# Connect Formbricks with your WordPress page
|
||||
|
||||
If you want to run a targeted survey on your WordPress website, Formbricks is the way to go! With our generous free plan and open source tech, you get everything you need to get started and keep full control over your data.
|
||||
|
||||
## TLDR
|
||||
|
||||
1. Install the Formbricks WordPress plugin
|
||||
2. Create a [free Formbricks account](https://app.formbricks.com/auth/signup)
|
||||
3. Find and copy the `environment id`
|
||||
4. Copy the environment id into the right field in the plugin settings
|
||||
5. Create survey on trigger “New Session” to test it
|
||||
|
||||
## Step 1: Install the Formbricks WordPress plugin
|
||||
|
||||
As long as the Formbricks plugin is in review, please download it from our [GitHub repository directly.](https://github.com/formbricks/wordpress)
|
||||
|
||||
<Image
|
||||
src={Img1}
|
||||
alt="Run targeted website survye on any WordPress site"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Step 2: Create a Formbricks Account
|
||||
|
||||
This is super straight forward: Go to [app.formbricks.com/auth/signup](https://app.formbricks.com/auth/signup), create the account, verify your email and you’re in!
|
||||
|
||||
When you see this screen, you’re there:
|
||||
|
||||
<Image
|
||||
src={Img2}
|
||||
alt="Free HotJar survey alternative open source"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Step 3: Find and copy the environmentId
|
||||
|
||||
Go to Settings > Setup Checklist where you’ll find your environmentId:
|
||||
|
||||
<Image
|
||||
src={Img3}
|
||||
alt="Run targeted surveys for free on WordPress pages"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Step 4: Copy the environmentId to the WordPress Plugin Settings
|
||||
|
||||
In your WordPress instance, go to the Formbricks Plugin settings and copy the environmentId in the correct field:
|
||||
|
||||
<Image
|
||||
src={Img5}
|
||||
alt="Free and open source HotJar survey on WordPress page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Then click the button at the bottom to see if the connection worked.
|
||||
|
||||
<Note>If you don’t use our Cloud, you also have to update the API Host</Note>
|
||||
|
||||
Great!
|
||||
|
||||
## Step 5: Create survey on trigger “New Session”
|
||||
|
||||
Now that all is setup, we create a survey to display an example survey. Pick any template here:
|
||||
|
||||
<Image
|
||||
src={Img2}
|
||||
alt="Free HotJar survey alternative open source"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Keep the content for now, click on the Settings tab:
|
||||
|
||||
<Image
|
||||
src={Img4}
|
||||
alt="Free and open source HotJar survey on WordPress page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
Here we do three things:
|
||||
|
||||
1. Change survey type to “In-App Survey”
|
||||
2. Select trigger “New Session”
|
||||
3. Publish
|
||||
|
||||
<Image
|
||||
src={Img6}
|
||||
alt="Open Source survey on targeted website wordpress"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
When you see this page, you did it!
|
||||
|
||||
<Image
|
||||
src={Img7}
|
||||
alt="Run free an open source targeted survey on any page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Step 6: Reload your page to check out your survey 🤓
|
||||
|
||||
You did it! Reload the WordPress page and your survey should appear!
|
||||
|
||||
|
||||
## Doesn't work?
|
||||
Join our [Discord to get help 🤓](https://formbricks.com/discord)
|
||||
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,70 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import ShareLink from "./share-link.webp";
|
||||
import ViewResponse from "./view-response.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Source Tracking",
|
||||
description: "Track the source of your users in an easy & compliant way!",
|
||||
};
|
||||
|
||||
#### Link Surveys
|
||||
|
||||
# Source Tracking
|
||||
|
||||
Understand the source a survey respondent comes from when responding to your survey - all while keeping data privacy standards high!
|
||||
|
||||
Check out this video to learn more about source tracking in link surveys:
|
||||
|
||||
{/* Replace link below with our new link on Source Tracking */}
|
||||
<iframe width="700" height="450" src="https://www.youtube.com/embed/CytWhuyEMVI?si=t-SFB2A1l1RZDdAC" title="YouTube video player: Formbricks" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>
|
||||
|
||||
## Purpose
|
||||
|
||||
Source tracking for link surveys is essential when you:
|
||||
|
||||
- Want to analyze the origin of your survey respondents.
|
||||
- Aim to ensure compliance with tracking and data collection regulations.
|
||||
|
||||
## Code Example
|
||||
<Col>
|
||||
<CodeGroup title="Example Source as Google">
|
||||
|
||||
```sh
|
||||
https://formbricks.com/clin3dxja02k8l80hpwmx4bjy?source=Google
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## How it Works
|
||||
|
||||
To track the source of users in your link surveys effectively, follow these steps:
|
||||
|
||||
1. **Generate Survey URL**: Create a Link Survey and get the sharable link. Append `?source=YourSouce` to the link to reference it with your campaigns and sources.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Example Source as Google">
|
||||
|
||||
```sh
|
||||
https://formbricks.com/clin3dxja02k8l80hpwmx4bjy?source=Google
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
2. **Collect Data**: When users access the survey through these links, the URL parameters will capture the source information from which they were shared.
|
||||
|
||||
3. **View Responses**: Use the collected source data to analyze where your survey respondents are coming from. You can hover over the user icon in the responses tab to see the source of the user.
|
||||
|
||||
<Image
|
||||
src={ViewResponse}
|
||||
alt="View Source in Response"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. **Analyse Data**: Download all the responses as a CSV/Excel and get access to the source information. This can provide valuable insights into your audience.
|
||||
|
||||
Source tracking allows you to make informed decisions based on the origin of your survey participants, helping you tailor your surveys and marketing strategies accordingly.
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -218,6 +218,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Data Prefilling", href: "/docs/link-surveys/data-prefilling" },
|
||||
{ title: "Identify Users", href: "/docs/link-surveys/user-identification" },
|
||||
{ title: "Single Use Links", href: "/docs/link-surveys/single-use-links" },
|
||||
{ title: "Source Tracking", href: "/docs/link-surveys/source-tracking" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -240,6 +241,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Notion", href: "/docs/integrations/notion" },
|
||||
{ title: "Make.com", href: "/docs/integrations/make" },
|
||||
{ title: "n8n", href: "/docs/integrations/n8n" },
|
||||
{ title: "Wordpress", href: "/docs/integrations/wordpress" },
|
||||
{ title: "Zapier", href: "/docs/integrations/zapier" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import jsPackageJson from "@/../../packages/js/package.json";
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import { IoLogoHtml5, IoLogoNpm } from "react-icons/io5";
|
||||
@@ -45,7 +46,7 @@ export const SetupInstructions: React.FC = ({}) => {
|
||||
return (
|
||||
<div>
|
||||
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} />
|
||||
<div className="h-80 max-w-lg px-4 sm:max-w-lg md:max-w-lg">
|
||||
<div className="h-84 max-w-lg px-4 sm:max-w-lg md:max-w-lg">
|
||||
{activeTab === "npm" ? (
|
||||
<>
|
||||
<CodeBlock>npm install @formbricks/js</CodeBlock>
|
||||
@@ -61,7 +62,19 @@ if (typeof window !== "undefined") {
|
||||
</>
|
||||
) : activeTab === "html" ? (
|
||||
<CodeBlock>{`<script type="text/javascript">
|
||||
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.4.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init("claDadXk29dak92dK9","https://app.formbricks.com")},500)}();
|
||||
|
||||
!function(){
|
||||
var jsPackageJson = require('@/package.json'); // Make sure the path is correct
|
||||
var t = document.createElement("script");
|
||||
t.type = "text/javascript";
|
||||
t.async = true;
|
||||
t.src = "https://unpkg.com/@formbricks/js@^${jsPackageJson.version}/dist/index.umd.js";
|
||||
var e = document.getElementsByTagName("script")[0];
|
||||
e.parentNode.insertBefore(t, e);
|
||||
setTimeout(function(){
|
||||
window.formbricks.init("claDadXk29dak92dK9","https://app.formbricks.com")
|
||||
}, 500);
|
||||
}();
|
||||
</script>`}</CodeBlock>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -19,8 +20,10 @@ export default function MetaInformation({
|
||||
section,
|
||||
tags,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const pageTitle = `${title}`;
|
||||
const BASE_URL = `https://${process.env.VERCEL_URL}`;
|
||||
const canonicalLink = `${BASE_URL}${router.asPath}`;
|
||||
return (
|
||||
<Head>
|
||||
<title>{pageTitle}</title>
|
||||
@@ -30,7 +33,7 @@ export default function MetaInformation({
|
||||
<meta name="image" content={`https://${BASE_URL}/favicon.ico`} />
|
||||
<meta property="og:image" content={`https://${BASE_URL}/social-image.png`} />
|
||||
<link rel="icon" type="image/x-icon" href={`https://${BASE_URL}/favicon.ico`} />
|
||||
<link rel="canonical" href="https://formbricks.com/" />
|
||||
<link rel="canonical" href={canonicalLink} />
|
||||
<meta name="msapplication-TileColor" content="#00C4B8" />
|
||||
<meta name="msapplication-TileImage" content={`https://${BASE_URL}/favicon.ico`} />
|
||||
<meta property="og:image:alt" content="Open Source Experience Management, Privacy-first" />
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
export const handleFeedbackSubmit = async (YesNo: string, pageUrl: string | null) => {
|
||||
const response_data = {
|
||||
data: {
|
||||
isHelpful: YesNo,
|
||||
pageUrl: pageUrl,
|
||||
},
|
||||
isHelpful: YesNo,
|
||||
pageUrl: pageUrl,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
response: response_data,
|
||||
surveyId: process.env.NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID,
|
||||
finished: true,
|
||||
data: response_data,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FORMBRICKS_COM_API_HOST}/api/v1/client/environments/${process.env.NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID}/responses`,
|
||||
`${process.env.NEXT_PUBLIC_FORMBRICKS_COM_API_HOST}/api/v1/client/${process.env.NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID}/responses`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -111,10 +111,14 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
{
|
||||
name: "Langfuse",
|
||||
description:
|
||||
"Open source LLM engineering platform. Debug, analyze and iterate together.",
|
||||
description: "Open source LLM engineering platform. Debug, analyze and iterate together.",
|
||||
href: "https://langfuse.com",
|
||||
},
|
||||
{
|
||||
name: "Lost Pixel",
|
||||
description: "Open source visual regression testing alternative to Percy & Chromatic",
|
||||
href: "https://lost-pixel.com",
|
||||
},
|
||||
{
|
||||
name: "Mockoon",
|
||||
description: "Mockoon is the easiest and quickest way to design and run mock REST APIs.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import Image from "next/image";
|
||||
|
||||
import Appcues from "./best-feedback-app-2024-appcues-feedback-app.png";
|
||||
import Header from "./best-feedback-app-2024-free-in-app-header-image.webp";
|
||||
import Formbricks from "./formbricks-best-open-source-feedback-app.png";
|
||||
@@ -90,7 +91,7 @@ Among the plethora of tools available in today’s market, this section will gui
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
[Formbricks](http://formbricks.com/) is an open-source micro-survey solution designed to gather specific user feedback at the perfect moment in their journey. It allows you to create and deploy targeted surveys within your app without disrupting the user experience.
|
||||
[Formbricks](https://formbricks.com/) is an open-source micro-survey solution designed to gather specific user feedback at the perfect moment in their journey. It allows you to create and deploy targeted surveys within your app without disrupting the user experience.
|
||||
|
||||
Formbricks boasts a user-friendly interface, making it easy for both technical and non-technical users to create and deploy micro-surveys. The no-code editor removes the need for coding knowledge, while the intuitive design guides you through the process. Being the only open-source feedback app out there, privacy-focused users will love this!
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Let's have a look at the best HotJar alternatives in 2024, including open source
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
[Formbricks](http://formbricks.com/) is an open source micro-survey solution designed to gather specific user feedback at the perfect moment in the journey. It allows you to create and deploy **targeted surveys within your app or on public websites** without disrupting the user experience.
|
||||
[Formbricks](https://formbricks.com/) is an open source micro-survey solution designed to gather specific user feedback at the perfect moment in the journey. It allows you to create and deploy **targeted surveys within your app or on public websites** without disrupting the user experience.
|
||||
|
||||
It's super good at one thing: making your forms and survey experiences awesome. It shows you why people abandon your forms, which questions cause churn, and how to fix them to get more people to finish. No heatmaps or fancy recordings, just laser focus on surveys.
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import TitleImage from "./formbricks-sponsored-by-github-accelerator-2023.webp";
|
||||
import NewsletterSignup from "@/components/shared/NewsletterSignup";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import NewsletterSignup from "@/components/shared/NewsletterSignup";
|
||||
import Image from "next/image";
|
||||
|
||||
import TitleImage from "./formbricks-sponsored-by-github-accelerator-2023.webp";
|
||||
|
||||
export const meta = {
|
||||
title: "Formbricks Joins GitHub Accelerator's Inaugural Cohort 💃",
|
||||
@@ -15,14 +16,14 @@ export const meta = {
|
||||
tags: ["GitHub Accelerator", "Open-Source"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" author={"Johannes"}/>
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" author={"Johannes"} />
|
||||
|
||||
_We're getting ready to take our open-source experience management platform to new heights, thanks to being part of the first-ever GitHub Accelerator program!_
|
||||
|
||||
<Image
|
||||
src={TitleImage}
|
||||
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
|
||||
className="rounded-lg w-full"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
## Hey there,
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
"@storybook/react": "^7.6.7",
|
||||
"@storybook/react-vite": "^7.6.7",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||
"@typescript-eslint/parser": "^6.18.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"esbuild": "^0.19.11",
|
||||
"tsup": "^8.0.1",
|
||||
"vite": "^5.0.10"
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function ActionClassesTable({
|
||||
<AddNoCodeActionModal
|
||||
environmentId={environmentId}
|
||||
open={isAddActionModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
setOpen={setAddActionModalOpen}
|
||||
isViewer={isViewer}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,8 @@ interface AddNoCodeActionModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
setActionClassArray?;
|
||||
actionClasses: TActionClass[];
|
||||
setActionClasses?;
|
||||
isViewer: boolean;
|
||||
}
|
||||
|
||||
@@ -45,7 +46,8 @@ export default function AddNoCodeActionModal({
|
||||
environmentId,
|
||||
open,
|
||||
setOpen,
|
||||
setActionClassArray,
|
||||
actionClasses,
|
||||
setActionClasses,
|
||||
isViewer,
|
||||
}: AddNoCodeActionModalProps) {
|
||||
const { register, control, handleSubmit, watch, reset } = useForm();
|
||||
@@ -56,6 +58,7 @@ export default function AddNoCodeActionModal({
|
||||
const [testUrl, setTestUrl] = useState("");
|
||||
const [isMatch, setIsMatch] = useState("");
|
||||
const [type, setType] = useState("noCode");
|
||||
const actionClassNames = actionClasses.map((actionClass) => actionClass.name);
|
||||
|
||||
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
|
||||
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
|
||||
@@ -92,7 +95,12 @@ export default function AddNoCodeActionModal({
|
||||
throw new Error("You are not authorised to perform this action.");
|
||||
}
|
||||
setIsCreatingAction(true);
|
||||
if (data.name === "") throw new Error("Please give your action a name");
|
||||
if (!data.name || data.name?.trim() === "") {
|
||||
throw new Error("Please give your action a name");
|
||||
}
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(`Action with name ${data.name} already exist`);
|
||||
}
|
||||
if (type === "noCode") {
|
||||
if (!isPageUrl && !isCssSelector && !isInnerHtml)
|
||||
throw new Error("Please select at least one selector");
|
||||
@@ -119,11 +127,8 @@ export default function AddNoCodeActionModal({
|
||||
}
|
||||
|
||||
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
|
||||
if (setActionClassArray) {
|
||||
setActionClassArray((prevActionClassArray: TActionClass[]) => [
|
||||
...prevActionClassArray,
|
||||
newActionClass,
|
||||
]);
|
||||
if (setActionClasses) {
|
||||
setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]);
|
||||
}
|
||||
reset();
|
||||
resetAllStates(false);
|
||||
@@ -178,7 +183,7 @@ export default function AddNoCodeActionModal({
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<Label>What did your user do?</Label>
|
||||
<Input placeholder="E.g. Clicked Download" {...register("name", { required: true })} />
|
||||
<Input placeholder="E.g. Clicked Download" {...register("name")} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Label>Description</Label>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function HowToAddAttributesButton() {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="http://formbricks.com/docs/attributes/custom-attributes"
|
||||
href="https://formbricks.com/docs/attributes/custom-attributes"
|
||||
target="_blank">
|
||||
<QuestionMarkCircleIcon className="mr-2 h-4 w-4" />
|
||||
How to add attributes
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function Navigation({
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
[environment.id, pathname]
|
||||
[environment.id, pathname, isViewer]
|
||||
);
|
||||
|
||||
const dropdownnavigation = [
|
||||
@@ -312,7 +312,7 @@ export default function Navigation({
|
||||
{/* User Dropdown */}
|
||||
<div className="hidden lg:ml-6 lg:flex lg:items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DropdownMenuTrigger asChild id="userDropdownTrigger">
|
||||
<div tabIndex={0} className="flex cursor-pointer flex-row items-center space-x-5">
|
||||
{session.user.imageUrl ? (
|
||||
<Image
|
||||
@@ -335,7 +335,7 @@ export default function Navigation({
|
||||
<ChevronDownIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuContent className="w-56" id="userDropdownContentWrapper">
|
||||
<DropdownMenuLabel className="cursor-default break-all">
|
||||
<span className="ph-no-capture font-normal">Signed in as </span>
|
||||
{session?.user?.name && session?.user?.name.length > 30 ? (
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
interface FilterValue {
|
||||
questionType: Partial<QuestionOption>;
|
||||
@@ -60,7 +60,7 @@ function ResponseFilterProvider({ children }: { children: React.ReactNode }) {
|
||||
to: getTodayDate(),
|
||||
});
|
||||
|
||||
const resetState = () => {
|
||||
const resetState = useCallback(() => {
|
||||
setDateRange({
|
||||
from: undefined,
|
||||
to: getTodayDate(),
|
||||
@@ -69,7 +69,7 @@ function ResponseFilterProvider({ children }: { children: React.ReactNode }) {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ResponseFilterContext.Provider
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ResponseTimeline({
|
||||
|
||||
useEffect(() => {
|
||||
setSortedResponses(responsesAscending ? [...responses].reverse() : responses);
|
||||
}, [responsesAscending]);
|
||||
}, [responsesAscending, responses]);
|
||||
|
||||
return (
|
||||
<div className="md:col-span-2">
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function SettingsNavbar({
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
[environmentId, isFormbricksCloud, pathname]
|
||||
[environmentId, isFormbricksCloud, pathname, isPricingDisabled, isViewer]
|
||||
);
|
||||
|
||||
if (!navigation) return null;
|
||||
|
||||
@@ -79,15 +79,21 @@ export default function AddMemberModal({
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<Label>Full Name</Label>
|
||||
<Label htmlFor="memberNameInput">Full Name</Label>
|
||||
<Input
|
||||
id="memberNameInput"
|
||||
placeholder="e.g. Hans Wurst"
|
||||
{...register("name", { required: true, validate: (value) => value.trim() !== "" })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Email Adress</Label>
|
||||
<Input type="email" placeholder="hans@wurst.com" {...register("email", { required: true })} />
|
||||
<Label htmlFor="memberEmailInput">Email Address</Label>
|
||||
<Input
|
||||
id="memberEmailInput"
|
||||
type="email"
|
||||
placeholder="hans@wurst.com"
|
||||
{...register("email", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
{canDoRoleManagement && <AddMemberRole control={control} />}
|
||||
</div>
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
|
||||
return (
|
||||
<>
|
||||
{showDeleteButton && (
|
||||
<button onClick={() => setDeleteMemberModalOpen(true)}>
|
||||
<button id="deleteMemberButton" onClick={() => setDeleteMemberModalOpen(true)}>
|
||||
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</button>
|
||||
)}
|
||||
@@ -109,7 +109,8 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
|
||||
<button
|
||||
onClick={() => {
|
||||
handleShareInvite();
|
||||
}}>
|
||||
}}
|
||||
id="shareInviteButton">
|
||||
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -122,7 +123,8 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
|
||||
<button
|
||||
onClick={() => {
|
||||
handleResendInvite();
|
||||
}}>
|
||||
}}
|
||||
id="resendInviteButton">
|
||||
<PaperAirplaneIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -36,10 +36,10 @@ const MembersInfo = async ({
|
||||
const allMembers = [...members, ...invites];
|
||||
|
||||
return (
|
||||
<div className="grid-cols-20">
|
||||
<div className="grid-cols-20" id="membersInfoWrapper">
|
||||
{allMembers.map((member) => (
|
||||
<div
|
||||
className="grid-cols-20 grid h-auto w-full content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
|
||||
className="singleMemberInfo grid-cols-20 grid h-auto w-full content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
|
||||
key={member.email}>
|
||||
<div className="h-58 col-span-2 pl-4">
|
||||
{isInvitee(member) ? (
|
||||
|
||||
@@ -43,7 +43,8 @@ export default function ShareInviteModal({ inviteToken, open, setOpen }: ShareIn
|
||||
<p
|
||||
ref={linkTextRef}
|
||||
className="relative mt-3 w-full truncate rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
|
||||
onClick={() => handleTextSelection()}>
|
||||
onClick={() => handleTextSelection()}
|
||||
id="inviteLinkText">
|
||||
{`${window.location.protocol}//${window.location.host}/invite?token=${inviteToken}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import jsPackageJson from "@/../../packages/js/package.json";
|
||||
import packageJson from "@/package.json";
|
||||
import Link from "next/link";
|
||||
import "prismjs/themes/prism.css";
|
||||
@@ -109,7 +110,7 @@ if (typeof window !== "undefined") {
|
||||
</p>
|
||||
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.4.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
|
||||
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^${jsPackageJson.version}/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
|
||||
</script>
|
||||
<!-- END Formbricks Surveys -->`}</CodeBlock>
|
||||
<p className="text-lg font-semibold text-slate-800">You're done 🎉</p>
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getResponses } from "@formbricks/lib/response/service";
|
||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
|
||||
export default async function revalidateSurveyIdPath(environmentId: string, surveyId: string) {
|
||||
revalidatePath(`/environments/${environmentId}/surveys/${surveyId}`);
|
||||
}
|
||||
|
||||
export async function getMoreResponses(
|
||||
surveyId: string,
|
||||
page: number,
|
||||
batchSize?: number
|
||||
): Promise<TResponse[]> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
batchSize = batchSize ?? 10;
|
||||
const responses = await getResponses(surveyId, page, batchSize);
|
||||
return responses;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
|
||||
type Props = {
|
||||
@@ -14,7 +14,7 @@ export const generateMetadata = async ({ params }: Props): Promise<Metadata> =>
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
if (session) {
|
||||
const { responseCount } = await getAnalysisData(params.surveyId, params.environmentId);
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
return {
|
||||
title: `${responseCount} Responses | ${survey?.name} Results`,
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ const ResponsePage = ({
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
// get the filtered array when the selected filter value changes
|
||||
const filterResponses: TResponse[] = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getMoreResponses } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -29,42 +30,48 @@ export default function ResponseTimeline({
|
||||
environmentTags,
|
||||
responsesPerPage,
|
||||
}: ResponseTimelineProps) {
|
||||
const [displayedResponses, setDisplayedResponses] = useState<TResponse[]>([]);
|
||||
const loadingRef = useRef(null);
|
||||
const [fetchedResponses, setFetchedResponses] = useState<TResponse[]>(responses);
|
||||
const [page, setPage] = useState(2);
|
||||
const [hasMoreResponses, setHasMoreResponses] = useState<boolean>(responses.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedResponses(responses.slice(0, responsesPerPage));
|
||||
}, [responses]);
|
||||
const currentLoadingRef = loadingRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
const loadResponses = async () => {
|
||||
const newResponses = await getMoreResponses(survey.id, page);
|
||||
if (newResponses.length === 0) {
|
||||
setHasMoreResponses(false);
|
||||
} else {
|
||||
setPage(page + 1);
|
||||
}
|
||||
setFetchedResponses((prevResponses) => [...prevResponses, ...newResponses]);
|
||||
};
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setDisplayedResponses((prevResponses) => [
|
||||
...prevResponses,
|
||||
...responses.slice(prevResponses.length, prevResponses.length + responsesPerPage),
|
||||
]);
|
||||
if (hasMoreResponses) loadResponses();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.8 }
|
||||
);
|
||||
|
||||
if (loadingRef.current) {
|
||||
observer.observe(loadingRef.current);
|
||||
if (currentLoadingRef) {
|
||||
observer.observe(currentLoadingRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loadingRef.current) {
|
||||
observer.unobserve(loadingRef.current);
|
||||
if (currentLoadingRef) {
|
||||
observer.unobserve(currentLoadingRef);
|
||||
}
|
||||
};
|
||||
}, [responses]);
|
||||
}, [responses, responsesPerPage, page, survey.id, fetchedResponses.length, hasMoreResponses]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{survey.type === "web" && displayedResponses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
{survey.type === "web" && fetchedResponses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
) : displayedResponses.length === 0 ? (
|
||||
) : fetchedResponses.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
@@ -72,7 +79,7 @@ export default function ResponseTimeline({
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{displayedResponses.map((response) => {
|
||||
{fetchedResponses.map((response) => {
|
||||
return (
|
||||
<div key={response.id}>
|
||||
<SingleResponseCard
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
|
||||
import ResponsePage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
@@ -7,6 +6,8 @@ import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponses } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
@@ -16,13 +17,18 @@ export default async function Page({ params }) {
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const [{ responses, survey }, environment] = await Promise.all([
|
||||
getAnalysisData(params.surveyId, params.environmentId),
|
||||
const [responses, survey, environment] = await Promise.all([
|
||||
getResponses(params.surveyId, 1),
|
||||
getSurvey(params.surveyId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
const product = await getProductByEnvironmentId(environment.id);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DownloadIcon, FileIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
||||
import { TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
|
||||
@@ -81,29 +82,31 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
|
||||
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl, index) => (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a
|
||||
href={fileUrl as string}
|
||||
key={index}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
response.value.map((fileUrl, index) => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{fileUrl.split("/").pop()}
|
||||
</p>
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a
|
||||
href={fileUrl as string}
|
||||
key={index}
|
||||
download={fileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center p-2">
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ShareEmbedSurvey({
|
||||
product,
|
||||
user,
|
||||
}: ShareEmbedSurveyProps) {
|
||||
const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey]);
|
||||
const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey, webAppUrl]);
|
||||
const isSingleUseLinkSurvey = survey.singleUse?.enabled;
|
||||
const { email } = user;
|
||||
const { brandColor } = product;
|
||||
|
||||
@@ -150,12 +150,13 @@ export default function SummaryDropOffs({ responses, survey, displayCount }: Sum
|
||||
viewsCount: viewsArr,
|
||||
dropoffPercentage: dropoffPercentageArr,
|
||||
};
|
||||
}, [responses, survey.questions, displayCount, initialAvgTtc, avgTtc]);
|
||||
}, [responses, survey.questions, displayCount, initialAvgTtc, avgTtc, survey.welcomeCard.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const { newAvgTtc, dropoffCount, viewsCount, dropoffPercentage } = calculateMetrics();
|
||||
setAvgTtc(newAvgTtc);
|
||||
setDropoffMetrics({ dropoffCount, viewsCount, dropoffPercentage });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [responses]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -55,7 +55,7 @@ const SummaryPage = ({
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
// get the filtered array when the selected filter value changes
|
||||
const filterResponses: TResponse[] = useMemo(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DateRange,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getMoreResponses } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { fetchFile } from "@/app/lib/fetchFile";
|
||||
import { generateQuestionAndFilterOptions, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
@@ -141,7 +142,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
return "my_survey_responses";
|
||||
}, [survey]);
|
||||
|
||||
function extracMetadataKeys(obj, parentKey = "") {
|
||||
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (let key in obj) {
|
||||
@@ -153,11 +154,25 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getAllResponsesInBatches = useCallback(async () => {
|
||||
const BATCH_SIZE = 3000;
|
||||
const responses: TResponse[] = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const batchResponses = await getMoreResponses(survey.id, page, BATCH_SIZE);
|
||||
responses.push(...batchResponses);
|
||||
if (batchResponses.length < BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return responses;
|
||||
}, [survey.id]);
|
||||
|
||||
const downloadResponses = useCallback(
|
||||
async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
|
||||
const downloadResponse = filter === FilterDownload.ALL ? totalResponses : responses;
|
||||
const downloadResponse = filter === FilterDownload.ALL ? await getAllResponsesInBatches() : responses;
|
||||
|
||||
const questionNames = survey.questions?.map((question) => question.headline);
|
||||
const hiddenFieldIds = survey.hiddenFields.fieldIds;
|
||||
const hiddenFieldResponse = {};
|
||||
@@ -269,7 +284,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
},
|
||||
[downloadFileName, responses, totalResponses, survey]
|
||||
[downloadFileName, responses, survey, extracMetadataKeys, getAllResponsesInBatches]
|
||||
);
|
||||
|
||||
const handleDateHoveredChange = (date: Date) => {
|
||||
|
||||
@@ -64,6 +64,7 @@ const ResponseFilter = () => {
|
||||
if (!isOpen) {
|
||||
clearItem();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddNewFilter = () => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
|
||||
import { TSurveyCalQuestion } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface CalQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyCalQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
@@ -15,28 +17,24 @@ interface CalQuestionFormProps {
|
||||
}
|
||||
|
||||
export default function CalQuestionForm({
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: CalQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
const environmentId = localSurvey.environmentId;
|
||||
|
||||
return (
|
||||
<form>
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="headline">Question</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
autoFocus
|
||||
id="headline"
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QuestionFormInput
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
{showSubheader && (
|
||||
<>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { md } from "@formbricks/lib/markdownIt";
|
||||
@@ -48,6 +48,9 @@ export default function EditWelcomeCard({
|
||||
},
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
setFirstRender(true);
|
||||
}, [activeQuestionId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
|
||||
import { PlusIcon, TrashIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -24,6 +25,7 @@ interface FileUploadFormProps {
|
||||
}
|
||||
|
||||
export default function FileUploadQuestionForm({
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
@@ -102,21 +104,17 @@ export default function FileUploadQuestionForm({
|
||||
return 10;
|
||||
}, [billingInfo, billingInfoError, billingInfoLoading]);
|
||||
|
||||
const environmentId = localSurvey.environmentId;
|
||||
|
||||
return (
|
||||
<form>
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="headline">Question</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
autoFocus
|
||||
id="headline"
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QuestionFormInput
|
||||
environmentId={environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
{showSubheader && (
|
||||
<>
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function QuestionCard({
|
||||
|
||||
const updateEmptyNextButtonLabels = (labelValue: string) => {
|
||||
localSurvey.questions.forEach((q, index) => {
|
||||
if (index === localSurvey.questions.length - 1) return;
|
||||
if (!q.buttonLabel || q.buttonLabel?.trim() === "") {
|
||||
updateQuestion(index, { buttonLabel: labelValue });
|
||||
}
|
||||
@@ -282,6 +283,7 @@ export default function QuestionCard({
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Cal ? (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
@@ -318,6 +320,8 @@ export default function QuestionCard({
|
||||
updateQuestion(questionIdx, { buttonLabel: e.target.value });
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
//If it is the last question then do not update labels
|
||||
if (questionIdx === localSurvey.questions.length - 1) return;
|
||||
updateEmptyNextButtonLabels(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function QuestionsView({
|
||||
acc[question.id] = createId();
|
||||
return acc;
|
||||
}, {});
|
||||
}, []);
|
||||
}, [localSurvey.questions]);
|
||||
|
||||
const [backButtonLabel, setbackButtonLabel] = useState(null);
|
||||
|
||||
|
||||
@@ -248,7 +248,13 @@ export default function ResponseOptionsCard({
|
||||
setCloseOnDate(localSurvey.closeOnDate);
|
||||
setSurveyCloseOnDateToggle(true);
|
||||
}
|
||||
}, [localSurvey]);
|
||||
}, [
|
||||
localSurvey,
|
||||
singleUseMessage.heading,
|
||||
singleUseMessage.subheading,
|
||||
surveyClosedMessage.heading,
|
||||
surveyClosedMessage.subheading,
|
||||
]);
|
||||
|
||||
const handleCheckMark = () => {
|
||||
if (autoComplete) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function SettingsView({
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environment.id}
|
||||
actionClasses={actionClasses}
|
||||
propActionClasses={actionClasses}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface StylingCardProps {
|
||||
|
||||
export default function StylingCard({ localSurvey, setLocalSurvey, colours }: StylingCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const progressBarHidden = localSurvey.styling?.hideProgressBar ?? false;
|
||||
const { type, productOverwrites, styling } = localSurvey;
|
||||
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
|
||||
productOverwrites ?? {};
|
||||
@@ -175,6 +175,16 @@ export default function StylingCard({ localSurvey, setLocalSurvey, colours }: St
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProgressBarVisibility = () => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
styling: {
|
||||
...localSurvey.styling,
|
||||
hideProgressBar: !progressBarHidden,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -342,6 +352,23 @@ export default function StylingCard({ localSurvey, setLocalSurvey, colours }: St
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<div className="ml-2 flex items-center space-x-1">
|
||||
<Switch
|
||||
id="hideProgressBar"
|
||||
checked={progressBarHidden}
|
||||
onCheckedChange={toggleProgressBarVisibility}
|
||||
/>
|
||||
<Label htmlFor="hideProgressBar" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Hide Progress Bar</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Disable the visibility of survey progress
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function SurveyEditor({
|
||||
setActiveQuestionId(survey.questions[0].id);
|
||||
}
|
||||
}
|
||||
}, [survey]);
|
||||
}, [survey, localSurvey]);
|
||||
|
||||
// when the survey type changes, we need to reset the active question id to the first question
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ interface WhenToSendCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
propActionClasses: TActionClass[];
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ export default function WhenToSendCard({
|
||||
environmentId,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
actionClasses,
|
||||
propActionClasses,
|
||||
membershipRole,
|
||||
}: WhenToSendCardProps) {
|
||||
const [open, setOpen] = useState(localSurvey.type === "web" ? true : false);
|
||||
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [actionClassArray, setActionClassArray] = useState<TActionClass[]>(actionClasses);
|
||||
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const autoClose = localSurvey.autoClose !== null;
|
||||
@@ -55,7 +55,7 @@ export default function WhenToSendCard({
|
||||
const setTriggerEvent = useCallback(
|
||||
(idx: number, actionClassName: string) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
const newActionClass = actionClassArray!.find((actionClass) => {
|
||||
const newActionClass = actionClasses!.find((actionClass) => {
|
||||
return actionClass.name === actionClassName;
|
||||
});
|
||||
if (!newActionClass) {
|
||||
@@ -64,7 +64,7 @@ export default function WhenToSendCard({
|
||||
updatedSurvey.triggers[idx] = newActionClass.name;
|
||||
setLocalSurvey(updatedSurvey);
|
||||
},
|
||||
[actionClassArray, localSurvey, setLocalSurvey]
|
||||
[actionClasses, localSurvey, setLocalSurvey]
|
||||
);
|
||||
|
||||
const removeTriggerEvent = (idx: number) => {
|
||||
@@ -101,7 +101,7 @@ export default function WhenToSendCard({
|
||||
useEffect(() => {
|
||||
if (isAddEventModalOpen) return;
|
||||
if (activeIndex !== null) {
|
||||
const newActionClass = actionClassArray[actionClassArray.length - 1].name;
|
||||
const newActionClass = actionClasses[actionClasses.length - 1].name;
|
||||
const currentActionClass = localSurvey.triggers[activeIndex];
|
||||
|
||||
if (newActionClass !== currentActionClass) {
|
||||
@@ -110,7 +110,7 @@ export default function WhenToSendCard({
|
||||
|
||||
setActiveIndex(null);
|
||||
}
|
||||
}, [actionClassArray, activeIndex, setTriggerEvent]);
|
||||
}, [actionClasses, activeIndex, setTriggerEvent, isAddEventModalOpen, localSurvey.triggers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSurvey.type === "link") {
|
||||
@@ -200,7 +200,7 @@ export default function WhenToSendCard({
|
||||
Add Action
|
||||
</button>
|
||||
<SelectSeparator />
|
||||
{actionClassArray.map((actionClass) => (
|
||||
{actionClasses.map((actionClass) => (
|
||||
<SelectItem
|
||||
value={actionClass.name}
|
||||
key={actionClass.name}
|
||||
@@ -279,7 +279,8 @@ export default function WhenToSendCard({
|
||||
environmentId={environmentId}
|
||||
open={isAddEventModalOpen}
|
||||
setOpen={setAddEventModalOpen}
|
||||
setActionClassArray={setActionClassArray}
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
isViewer={isViewer}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -20,34 +20,42 @@ export default function Modal({
|
||||
const [show, setShow] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
const calculateScaling = () => {
|
||||
const scaleValue = (() => {
|
||||
if (windowWidth > 1600) return "1";
|
||||
else if (windowWidth > 1200) return ".9";
|
||||
else if (windowWidth > 900) return ".8";
|
||||
return "0.7";
|
||||
})();
|
||||
|
||||
const getPlacementClass = (() => {
|
||||
switch (placement) {
|
||||
case "bottomLeft":
|
||||
return "bottom left";
|
||||
case "bottomRight":
|
||||
return "bottom right";
|
||||
case "topLeft":
|
||||
return "top left";
|
||||
case "topRight":
|
||||
return "top right";
|
||||
default:
|
||||
return "";
|
||||
const calculateScaling = () => {
|
||||
let scaleValue = "1";
|
||||
|
||||
if (previewMode === "mobile") {
|
||||
scaleValue = "1";
|
||||
} else {
|
||||
if (windowWidth > 1600) {
|
||||
scaleValue = "1";
|
||||
} else if (windowWidth > 1200) {
|
||||
scaleValue = ".9";
|
||||
} else if (windowWidth > 900) {
|
||||
scaleValue = ".8";
|
||||
} else {
|
||||
scaleValue = "0.7";
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
let placementClass = "";
|
||||
|
||||
if (placement === "bottomLeft") {
|
||||
placementClass = "bottom left";
|
||||
} else if (placement === "bottomRight") {
|
||||
placementClass = "bottom right";
|
||||
} else if (placement === "topLeft") {
|
||||
placementClass = "top left";
|
||||
} else if (placement === "topRight") {
|
||||
placementClass = "top right";
|
||||
}
|
||||
|
||||
return {
|
||||
transform: `scale(${scaleValue})`,
|
||||
"transform-origin": getPlacementClass,
|
||||
"transform-origin": placementClass,
|
||||
};
|
||||
};
|
||||
|
||||
const scalingClasses = calculateScaling();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -91,7 +99,7 @@ export default function Modal({
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div aria-live="assertive" className="relative h-full w-full overflow-visible bg-slate-300">
|
||||
<div aria-live="assertive" className="relative h-full w-full bg-slate-300">
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{ ...highlightBorderColorStyle, ...scalingClasses }}
|
||||
|
||||
@@ -156,20 +156,6 @@ export default function PreviewSurvey({
|
||||
setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id);
|
||||
}
|
||||
|
||||
function animationTrigger() {
|
||||
let storePreviewMode = previewMode;
|
||||
setPreviewMode("null");
|
||||
setTimeout(() => {
|
||||
setPreviewMode(storePreviewMode);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.styling?.background?.bgType === "animation") {
|
||||
animationTrigger();
|
||||
}
|
||||
}, [survey.styling?.background?.bg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && environment.widgetSetupCompleted) {
|
||||
setWidgetSetupCompleted(true);
|
||||
@@ -300,7 +286,7 @@ export default function PreviewSurvey({
|
||||
</Modal>
|
||||
) : (
|
||||
<MediaBackground survey={survey} ContentRef={ContentRef} isEditorView>
|
||||
<div className="z-0 w-full max-w-md rounded-lg p-4">
|
||||
<div className="z-0 w-full max-w-md rounded-lg p-4">
|
||||
<SurveyInline
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
|
||||
@@ -35,7 +35,7 @@ const Greeting: React.FC<Greeting> = ({ next, skip, name, session }) => {
|
||||
button.removeEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [next]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full max-w-xl flex-col justify-around gap-8 px-8">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { PresentationChartLineIcon, InboxStackIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { InboxStackIcon, PresentationChartLineIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
interface SurveyResultsTabProps {
|
||||
activeId: string;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use server";
|
||||
import { createTag } from "@formbricks/lib/tag/service";
|
||||
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createTag } from "@formbricks/lib/tag/service";
|
||||
import { canUserAccessTagOnResponse } from "@formbricks/lib/tagOnResponse/auth";
|
||||
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
|
||||
export const createTagAction = async (environmentId: string, tagName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@@ -46,7 +46,7 @@ const ResponsePage = ({
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
// get the filtered array when the selected filter value changes
|
||||
const filterResponses: TResponse[] = useMemo(() => {
|
||||
|
||||
@@ -31,9 +31,10 @@ export default function ResponseTimeline({
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedResponses(responses.slice(0, responsesPerPage));
|
||||
}, [responses]);
|
||||
}, [responses, responsesPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentLoadingRef = loadingRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
@@ -46,16 +47,16 @@ export default function ResponseTimeline({
|
||||
{ threshold: 0.8 }
|
||||
);
|
||||
|
||||
if (loadingRef.current) {
|
||||
observer.observe(loadingRef.current);
|
||||
if (currentLoadingRef) {
|
||||
observer.observe(currentLoadingRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loadingRef.current) {
|
||||
observer.unobserve(loadingRef.current);
|
||||
if (currentLoadingRef) {
|
||||
observer.unobserve(currentLoadingRef);
|
||||
}
|
||||
};
|
||||
}, [responses]);
|
||||
}, [responses, responsesPerPage]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -49,7 +49,7 @@ const SummaryPage = ({
|
||||
if (!searchParams?.get("referer")) {
|
||||
resetState();
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
// get the filtered array when the selected filter value changes
|
||||
const filterResponses: TResponse[] = useMemo(() => {
|
||||
|
||||
@@ -140,7 +140,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
return "my_survey_responses";
|
||||
}, [survey]);
|
||||
|
||||
function extracMetadataKeys(obj, parentKey = "") {
|
||||
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (let key in obj) {
|
||||
@@ -152,7 +152,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const downloadResponses = useCallback(
|
||||
async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
|
||||
@@ -268,7 +268,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
},
|
||||
[downloadFileName, responses, totalResponses, survey]
|
||||
[downloadFileName, responses, totalResponses, survey, extracMetadataKeys]
|
||||
);
|
||||
|
||||
const handleDateHoveredChange = (date: Date) => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { FaMicrosoft } from "react-icons/fa";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
@@ -9,24 +7,24 @@ import { Button } from "@formbricks/ui/Button";
|
||||
export const AzureButton = ({
|
||||
text = "Continue with Azure",
|
||||
inviteUrl,
|
||||
directRedirect,
|
||||
directRedirect = false,
|
||||
}: {
|
||||
text?: string;
|
||||
inviteUrl?: string | null;
|
||||
directRedirect?: boolean | false;
|
||||
directRedirect?: boolean;
|
||||
}) => {
|
||||
const handleLogin = async () => {
|
||||
const handleLogin = useCallback(async () => {
|
||||
await signIn("azure-ad", {
|
||||
redirect: true,
|
||||
callbackUrl: inviteUrl ? inviteUrl : "/",
|
||||
});
|
||||
};
|
||||
}, [inviteUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (directRedirect) {
|
||||
handleLogin();
|
||||
}
|
||||
}, []);
|
||||
}, [directRedirect, handleLogin]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -96,7 +96,7 @@ export const SigninForm = ({
|
||||
if (error) {
|
||||
setSignInError(error);
|
||||
}
|
||||
}, []);
|
||||
}, [error]);
|
||||
|
||||
const formLabel = useMemo(() => {
|
||||
if (totpBackup) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
@@ -22,6 +23,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
responseInput.personId = null;
|
||||
}
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
|
||||
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -60,6 +62,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
device: agent?.device.type,
|
||||
os: agent?.os.name,
|
||||
},
|
||||
country: country,
|
||||
};
|
||||
|
||||
// check if personId is anonymous
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
@@ -45,6 +46,7 @@ export async function POST(request: Request, context: Context): Promise<NextResp
|
||||
}
|
||||
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
|
||||
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -83,6 +85,7 @@ export async function POST(request: Request, context: Context): Promise<NextResp
|
||||
device: agent?.device.type,
|
||||
os: agent?.os.name,
|
||||
},
|
||||
country: country,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Metadata } from "next";
|
||||
|
||||
import "./globals.css";
|
||||
@@ -13,6 +14,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
||||
<body className="flex h-screen flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function LinkSurvey({
|
||||
},
|
||||
surveyState
|
||||
),
|
||||
[webAppUrl]
|
||||
[webAppUrl, survey.environmentId, surveyState]
|
||||
);
|
||||
const [autoFocus, setAutofocus] = useState(false);
|
||||
const hasFinishedSingleUseResponse = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
@@ -19,6 +19,17 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
|
||||
isMobilePreview = false,
|
||||
ContentRef,
|
||||
}) => {
|
||||
const animatedBackgroundRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.styling?.background?.bgType === "animation") {
|
||||
if (animatedBackgroundRef.current && survey.styling?.background?.bg) {
|
||||
animatedBackgroundRef.current.src = survey.styling?.background?.bg;
|
||||
animatedBackgroundRef.current.play();
|
||||
}
|
||||
}
|
||||
}, [survey.styling?.background?.bg, survey.styling?.background?.bgType]);
|
||||
|
||||
const getFilterStyle = () => {
|
||||
return survey.styling?.background?.brightness
|
||||
? `brightness(${survey.styling?.background?.brightness}%)`
|
||||
@@ -40,6 +51,7 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
|
||||
case "animation":
|
||||
return (
|
||||
<video
|
||||
ref={animatedBackgroundRef}
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile";
|
||||
@@ -11,7 +12,7 @@ import { ZStorageRetrievalParams } from "@formbricks/types/storage";
|
||||
import getFile from "./lib/getFile";
|
||||
|
||||
export async function GET(
|
||||
_: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: { environmentId: string; accessType: string; fileName: string } }
|
||||
) {
|
||||
const paramValidation = ZStorageRetrievalParams.safeParse(params);
|
||||
@@ -32,22 +33,28 @@ export async function GET(
|
||||
return await getFile(environmentId, accessType, fileName);
|
||||
}
|
||||
|
||||
// auth and download private file
|
||||
// if the user is authenticated via the session
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
// check for api key auth
|
||||
const res = await authenticateRequest(request);
|
||||
|
||||
if (!res) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
return await getFile(environmentId, accessType, fileName);
|
||||
} else {
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return await getFile(environmentId, accessType, fileName);
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const file = await getFile(environmentId, accessType, fileName);
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function DELETE(_: NextRequest, { params }: { params: { fileName: string } }) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -19,38 +19,39 @@
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^2.1.1",
|
||||
"@json2csv/node": "^7.0.4",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-email/components": "^0.0.12",
|
||||
"@sentry/nextjs": "^7.91.0",
|
||||
"@sentry/nextjs": "^7.93.0",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"@vercel/speed-insights": "^1.0.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"encoding": "^0.1.13",
|
||||
"framer-motion": "10.17.4",
|
||||
"googleapis": "^129.0.0",
|
||||
"framer-motion": "10.18.0",
|
||||
"googleapis": "^130.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^10.1.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"lucide-react": "^0.309.0",
|
||||
"mime": "^4.0.1",
|
||||
"next": "14.0.4",
|
||||
"nodemailer": "^6.9.8",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.96.1",
|
||||
"posthog-js": "^1.98.2",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-email": "^1.10.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-icons": "^5.0.1",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"webpack": "^5.89.0",
|
||||
"xlsx": "^0.18.5"
|
||||
|
||||
112
apps/web/playwright/team.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
import { login, signUpAndLogin, signupUsingInviteToken, skipOnboarding } from "./utils/helper";
|
||||
import { invites, users } from "./utils/mock";
|
||||
|
||||
test.describe("Invite, accept and remove team member", async () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
const { email, password, name } = users.team[0];
|
||||
let inviteLink: string;
|
||||
|
||||
test("Invite team member", async ({ page }) => {
|
||||
await signUpAndLogin(page, name, email, password);
|
||||
await skipOnboarding(page);
|
||||
|
||||
const dropdownTrigger = page.locator("#userDropdownTrigger");
|
||||
await expect(dropdownTrigger).toBeVisible();
|
||||
await dropdownTrigger.click();
|
||||
|
||||
const dropdownContentWrapper = page.locator("#userDropdownContentWrapper");
|
||||
await expect(dropdownContentWrapper).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Team" }).click();
|
||||
|
||||
// Add member button
|
||||
await expect(page.getByRole("button", { name: "Add Member" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Add Member" }).click();
|
||||
|
||||
// Fill the member name and email form
|
||||
await expect(page.getByLabel("Email")).toBeVisible();
|
||||
await page.getByLabel("Full Name").fill(invites.addMember.name);
|
||||
|
||||
await expect(page.getByLabel("Email Address")).toBeVisible();
|
||||
await page.getByLabel("Email Address").fill(invites.addMember.email);
|
||||
|
||||
await page.getByRole("button", { name: "Send Invitation", exact: true }).click();
|
||||
});
|
||||
|
||||
test("Copy Invite Link", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
const dropdownTrigger = page.locator("#userDropdownTrigger");
|
||||
await expect(dropdownTrigger).toBeVisible();
|
||||
await dropdownTrigger.click();
|
||||
|
||||
const dropdownContentWrapper = page.locator("#userDropdownContentWrapper");
|
||||
await expect(dropdownContentWrapper).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Team" }).click();
|
||||
|
||||
await expect(page.locator("#membersInfoWrapper")).toBeVisible();
|
||||
|
||||
const lastMemberInfo = page.locator("#membersInfoWrapper > .singleMemberInfo:last-child");
|
||||
await expect(lastMemberInfo).toBeVisible();
|
||||
|
||||
const pendingSpan = lastMemberInfo.locator("span").filter({ hasText: "Pending" });
|
||||
await expect(pendingSpan).toBeVisible();
|
||||
|
||||
const shareInviteButton = page.locator("#shareInviteButton");
|
||||
await expect(shareInviteButton).toBeVisible();
|
||||
|
||||
await shareInviteButton.click();
|
||||
|
||||
const inviteLinkText = page.locator("#inviteLinkText");
|
||||
await expect(inviteLinkText).toBeVisible();
|
||||
|
||||
// invite link text is a paragraph, and we need the text inside it
|
||||
const inviteLinkTextContent = await inviteLinkText.textContent();
|
||||
if (inviteLinkTextContent) {
|
||||
inviteLink = inviteLinkTextContent;
|
||||
}
|
||||
});
|
||||
|
||||
test("Accept Invite", async ({ page }) => {
|
||||
const { email, name, password } = users.team[1];
|
||||
page.goto(inviteLink);
|
||||
|
||||
await page.waitForURL(/\/invite\?token=[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+/);
|
||||
|
||||
// Create account button
|
||||
await expect(page.getByRole("link", { name: "Create account" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "Create account" }).click();
|
||||
|
||||
await signupUsingInviteToken(page, name, email, password);
|
||||
await skipOnboarding(page);
|
||||
});
|
||||
|
||||
test("Remove Member", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
const dropdownTrigger = page.locator("#userDropdownTrigger");
|
||||
await expect(dropdownTrigger).toBeVisible();
|
||||
await dropdownTrigger.click();
|
||||
|
||||
const dropdownContentWrapper = page.locator("#userDropdownContentWrapper");
|
||||
await expect(dropdownContentWrapper).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Team" }).click();
|
||||
|
||||
await expect(page.locator("#membersInfoWrapper")).toBeVisible();
|
||||
|
||||
const lastMemberInfo = page.locator("#membersInfoWrapper > .singleMemberInfo:last-child");
|
||||
await expect(lastMemberInfo).toBeVisible();
|
||||
|
||||
const deleteMemberButton = lastMemberInfo.locator("#deleteMemberButton");
|
||||
await expect(deleteMemberButton).toBeVisible();
|
||||
|
||||
await deleteMemberButton.click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Delete", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
});
|
||||
});
|
||||
@@ -51,3 +51,23 @@ export const replaceEnvironmentIdInHtml = (filePath: string, environmentId: stri
|
||||
writeFileSync(filePath, htmlContent);
|
||||
return "file:///" + filePath;
|
||||
};
|
||||
|
||||
export const signupUsingInviteToken = async (page: Page, name: string, email: string, password: string) => {
|
||||
await page.getByRole("button", { name: "Continue with Email" }).click();
|
||||
await page.getByPlaceholder("Full Name").fill(name);
|
||||
await page.getByPlaceholder("Full Name").press("Tab");
|
||||
|
||||
// the email is already filled in the input field
|
||||
const inputValue = await page.getByPlaceholder("work@email.com").inputValue();
|
||||
expect(inputValue).toEqual(email);
|
||||
|
||||
await page.getByPlaceholder("work@email.com").press("Tab");
|
||||
await page.getByPlaceholder("*******").fill(password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
await page.getByRole("link", { name: "Login" }).click();
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
await page.getByPlaceholder("work@email.com").fill(email);
|
||||
await page.getByPlaceholder("*******").click();
|
||||
await page.getByPlaceholder("*******").fill(password);
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
};
|
||||
|
||||
@@ -32,6 +32,18 @@ export const users = {
|
||||
password: "XpP%X9UU3efj8vJa",
|
||||
},
|
||||
],
|
||||
team: [
|
||||
{
|
||||
name: "Team User 1",
|
||||
email: "team1@formbricks.com",
|
||||
password: "Test#1234",
|
||||
},
|
||||
{
|
||||
name: "Team User 2",
|
||||
email: "team2@formbricks.com",
|
||||
password: "Test#1234",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const teams = {
|
||||
@@ -97,3 +109,10 @@ export const surveys = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const invites = {
|
||||
addMember: {
|
||||
name: "Team User 2",
|
||||
email: "team2@formbricks.com",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"terser": "^5.26.0",
|
||||
"vite": "^5.0.10",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-dts": "^3.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class StorageAPI {
|
||||
const json = await response.json();
|
||||
|
||||
const { data } = json;
|
||||
const { signedUrl, fileUrl, signingData, presignedFields } = data;
|
||||
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
|
||||
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
|
||||
@@ -51,7 +51,7 @@ export class StorageAPI {
|
||||
|
||||
requestHeaders = {
|
||||
"X-File-Type": file.type,
|
||||
"X-File-Name": encodeURIComponent(file.name),
|
||||
"X-File-Name": encodeURIComponent(updatedFileName),
|
||||
"X-Survey-ID": surveyId ?? "",
|
||||
"X-Signature": signature,
|
||||
"X-Timestamp": String(timestamp),
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"predev": "pnpm generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@prisma/client": "^5.8.0",
|
||||
"@prisma/extension-accelerate": "^0.6.2",
|
||||
"dotenv-cli": "^7.3.0"
|
||||
},
|
||||
@@ -33,7 +33,7 @@
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"prisma": "^5.7.1",
|
||||
"prisma": "^5.8.0",
|
||||
"prisma-dbml-generator": "^0.10.0",
|
||||
"prisma-json-types-generator": "^3.0.3",
|
||||
"zod": "^3.22.4",
|
||||
|
||||
@@ -55,6 +55,7 @@ export const createSubscription = async (
|
||||
billing_cycle_anchor: getFirstOfNextMonthTimestamp(),
|
||||
metadata: { teamId },
|
||||
},
|
||||
automatic_tax: { enabled: true },
|
||||
});
|
||||
|
||||
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"stripe": "^14.10.0"
|
||||
"stripe": "^14.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
@@ -40,7 +40,7 @@
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.7",
|
||||
"@babel/preset-env": "^7.23.7",
|
||||
"@babel/preset-env": "^7.23.8",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||
"@typescript-eslint/parser": "^6.18.1",
|
||||
"babel-jest": "^29.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
@@ -57,7 +57,7 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"terser": "^5.26.0",
|
||||
"vite": "^5.0.10",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-dts": "^3.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { wrapThrowsAsync } from "@formbricks/types/errorHandlers";
|
||||
|
||||
import { ErrorHandler, Result } from "./errors";
|
||||
import { checkInitialized } from "./initialize";
|
||||
|
||||
@@ -47,11 +49,23 @@ export class CommandQueue {
|
||||
if (initResult && initResult.ok !== true) errorHandler.handle(initResult.error);
|
||||
}
|
||||
|
||||
const result = (await currentItem?.command.apply(null, currentItem?.commandArgs)) as Result<void, any>;
|
||||
const executeCommand = async () => {
|
||||
return (await currentItem?.command.apply(null, currentItem?.commandArgs)) as Result<void, any>;
|
||||
};
|
||||
|
||||
const result = await wrapThrowsAsync(executeCommand)();
|
||||
|
||||
if (!result) continue;
|
||||
|
||||
if (result.ok !== true) errorHandler.handle(result.error);
|
||||
if (result.ok) {
|
||||
if (!result.data.ok) {
|
||||
errorHandler.handle(result.data.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok !== true) {
|
||||
errorHandler.handle(result.error);
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
if (this.resolvePromise) {
|
||||
|
||||
@@ -132,6 +132,7 @@ export class ErrorHandler {
|
||||
}
|
||||
|
||||
public handle(error: any): void {
|
||||
console.warn("🧱 Formbricks - Global error: ", error);
|
||||
this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const selectDisplay = {
|
||||
surveyId: true,
|
||||
responseId: true,
|
||||
personId: true,
|
||||
status: true,
|
||||
};
|
||||
|
||||
export const getDisplay = async (displayId: string): Promise<TDisplay | null> => {
|
||||
@@ -42,7 +43,7 @@ export const getDisplay = async (displayId: string): Promise<TDisplay | null> =>
|
||||
validateInputs([displayId, ZId]);
|
||||
|
||||
try {
|
||||
const display = await prisma.response.findUnique({
|
||||
const display = await prisma.display.findUnique({
|
||||
where: {
|
||||
id: displayId,
|
||||
},
|
||||
@@ -143,7 +144,6 @@ export const updateDisplayLegacy = async (
|
||||
data,
|
||||
select: selectDisplay,
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
surveyId: display.surveyId,
|
||||
@@ -164,7 +164,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
|
||||
validateInputs([displayInput, ZDisplayCreateInput]);
|
||||
|
||||
const { environmentId, userId, surveyId } = displayInput;
|
||||
|
||||
try {
|
||||
let person;
|
||||
if (userId) {
|
||||
@@ -191,13 +190,11 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
|
||||
},
|
||||
select: selectDisplay,
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
personId: display.personId,
|
||||
surveyId: display.surveyId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -337,7 +334,6 @@ export const deleteDisplayByResponseId = async (
|
||||
personId: display.personId,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
76
packages/lib/display/tests/__mocks__/data.mock.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
TDisplay,
|
||||
TDisplayCreateInput,
|
||||
TDisplayLegacyCreateInput,
|
||||
TDisplayLegacyUpdateInput,
|
||||
TDisplayUpdateInput,
|
||||
} from "@formbricks/types/displays";
|
||||
|
||||
export const mockEnvironmentId = "clqkr5961000108jyfnjmbjhi";
|
||||
export const mockSingleUseId = "qj57j3opsw8b5sxgea20fgcq";
|
||||
export const mockSurveyId = "clqkr8dlv000308jybb08evgr";
|
||||
export const mockUserId = "qwywazmugeezyfr3zcg9jk8a";
|
||||
export const mockDisplayId = "clqkr5smu000208jy50v6g5k4";
|
||||
export const mockId = "ars2tjk8hsi8oqk1uac00mo8";
|
||||
export const mockPersonId = "clqnj99r9000008lebgf8734j";
|
||||
export const mockResponseId = "clqnfg59i000208i426pb4wcv";
|
||||
|
||||
function createMockDisplay(overrides = {}) {
|
||||
return {
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: mockSurveyId,
|
||||
responseId: null,
|
||||
personId: null,
|
||||
status: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockDisplay: TDisplay = createMockDisplay();
|
||||
|
||||
export const mockDisplayWithPersonId: TDisplay = createMockDisplay({ personId: mockPersonId });
|
||||
|
||||
export const mockDisplayWithResponseId: TDisplay = createMockDisplay({
|
||||
personId: mockPersonId,
|
||||
responseId: mockResponseId,
|
||||
});
|
||||
|
||||
export const mockDisplayInput: TDisplayCreateInput = {
|
||||
environmentId: mockEnvironmentId,
|
||||
surveyId: mockSurveyId,
|
||||
};
|
||||
export const mockDisplayInputWithUserId: TDisplayCreateInput = {
|
||||
...mockDisplayInput,
|
||||
userId: mockUserId,
|
||||
};
|
||||
export const mockDisplayInputWithResponseId: TDisplayCreateInput = {
|
||||
...mockDisplayInputWithUserId,
|
||||
responseId: mockResponseId,
|
||||
};
|
||||
|
||||
export const mockDisplayLegacyInput: TDisplayLegacyCreateInput = {
|
||||
responseId: mockResponseId,
|
||||
surveyId: mockSurveyId,
|
||||
};
|
||||
export const mockDisplayLegacyInputWithPersonId: TDisplayLegacyCreateInput = {
|
||||
...mockDisplayLegacyInput,
|
||||
personId: mockPersonId,
|
||||
};
|
||||
|
||||
export const mockDisplayUpdate: TDisplayUpdateInput = {
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
responseId: mockResponseId,
|
||||
};
|
||||
|
||||
export const mockDisplayLegacyUpdateInput: TDisplayLegacyUpdateInput = {
|
||||
personId: mockPersonId,
|
||||
responseId: mockResponseId,
|
||||
};
|
||||
|
||||
export const mockDisplayLegacyWithRespondedStatus: TDisplay = {
|
||||
...mockDisplayWithPersonId,
|
||||
status: "responded",
|
||||
};
|
||||
287
packages/lib/display/tests/display.unit.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { mockPerson } from "../../response/tests/__mocks__/data.mock";
|
||||
import {
|
||||
mockDisplay,
|
||||
mockDisplayInput,
|
||||
mockDisplayInputWithUserId,
|
||||
mockDisplayLegacyInput,
|
||||
mockDisplayLegacyInputWithPersonId,
|
||||
mockDisplayLegacyUpdateInput,
|
||||
mockDisplayLegacyWithRespondedStatus,
|
||||
mockDisplayUpdate,
|
||||
mockDisplayWithPersonId,
|
||||
mockDisplayWithResponseId,
|
||||
mockResponseId,
|
||||
mockSurveyId,
|
||||
} from "./__mocks__/data.mock";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { prismaMock } from "@formbricks/database/src/jestClient";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
|
||||
import {
|
||||
createDisplay,
|
||||
createDisplayLegacy,
|
||||
deleteDisplayByResponseId,
|
||||
getDisplay,
|
||||
getDisplayCountBySurveyId,
|
||||
getDisplaysByPersonId,
|
||||
markDisplayRespondedLegacy,
|
||||
updateDisplay,
|
||||
updateDisplayLegacy,
|
||||
} from "../service";
|
||||
|
||||
const testInputValidation = async (service: Function, ...args: any[]): Promise<void> => {
|
||||
it("it should throw a ValidationError if the inputs are invalid", async () => {
|
||||
await expect(service(...args)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prismaMock.person.findFirst.mockResolvedValue(mockPerson);
|
||||
});
|
||||
|
||||
describe("Tests for getDisplay", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns display associated with a given display ID", async () => {
|
||||
prismaMock.display.findUnique.mockResolvedValue(mockDisplay);
|
||||
|
||||
const display = await getDisplay(mockDisplay.id);
|
||||
expect(display).toEqual(mockDisplay);
|
||||
});
|
||||
|
||||
it("Returns all displays associated with a given person ID", async () => {
|
||||
prismaMock.display.findMany.mockResolvedValue([mockDisplayWithPersonId]);
|
||||
|
||||
const displays = await getDisplaysByPersonId(mockPerson.id);
|
||||
expect(displays).toEqual([mockDisplayWithPersonId]);
|
||||
});
|
||||
|
||||
it("Returns an empty array when no displays are found for the given person ID", async () => {
|
||||
prismaMock.display.findMany.mockResolvedValue([]);
|
||||
|
||||
const displays = await getDisplaysByPersonId(mockPerson.id);
|
||||
expect(displays).toEqual([]);
|
||||
});
|
||||
|
||||
it("Returns display count for the given survey ID", async () => {
|
||||
prismaMock.display.count.mockResolvedValue(1);
|
||||
|
||||
const displaCount = await getDisplayCountBySurveyId(mockSurveyId);
|
||||
expect(displaCount).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getDisplaysByPersonId, "123", 1);
|
||||
|
||||
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prismaMock.display.findMany.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysByPersonId(mockPerson.id)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for unexpected exceptions", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prismaMock.display.findMany.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(getDisplaysByPersonId(mockPerson.id)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for createDisplay service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Creates a new display when a userId exists", async () => {
|
||||
prismaMock.display.create.mockResolvedValue(mockDisplayWithPersonId);
|
||||
|
||||
const display = await createDisplay(mockDisplayInputWithUserId);
|
||||
expect(display).toEqual(mockDisplayWithPersonId);
|
||||
});
|
||||
|
||||
it("Creates a new display when a userId does not exists", async () => {
|
||||
prismaMock.display.create.mockResolvedValue(mockDisplay);
|
||||
|
||||
const display = await createDisplay(mockDisplayInput);
|
||||
expect(display).toEqual(mockDisplay);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(createDisplay, "123");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prismaMock.display.create.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(createDisplay(mockDisplayInputWithUserId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for other exceptions", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prismaMock.display.create.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(createDisplay(mockDisplayInput)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for updateDisplay Service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Updates a display (responded)", async () => {
|
||||
prismaMock.display.update.mockResolvedValue(mockDisplayWithResponseId);
|
||||
|
||||
const display = await updateDisplay(mockDisplay.id, mockDisplayUpdate);
|
||||
expect(display).toEqual(mockDisplayWithResponseId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(updateDisplay, "123", "123");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prismaMock.display.update.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(updateDisplay(mockDisplay.id, mockDisplayUpdate)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for other unexpected issues", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prismaMock.display.update.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(updateDisplay(mockDisplay.id, mockDisplayUpdate)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for createDisplayLegacy service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Creates a display when a person ID exist", async () => {
|
||||
prismaMock.display.create.mockResolvedValue(mockDisplayWithPersonId);
|
||||
|
||||
const display = await createDisplayLegacy(mockDisplayLegacyInputWithPersonId);
|
||||
expect(display).toEqual(mockDisplayWithPersonId);
|
||||
});
|
||||
it("Creates a display when a person ID does not exist", async () => {
|
||||
prismaMock.display.create.mockResolvedValue(mockDisplay);
|
||||
|
||||
const display = await createDisplayLegacy(mockDisplayLegacyInput);
|
||||
expect(display).toEqual(mockDisplay);
|
||||
});
|
||||
});
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(createDisplayLegacy, "123");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prismaMock.display.create.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(createDisplayLegacy(mockDisplayLegacyInputWithPersonId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for other exceptions", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prismaMock.display.create.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(createDisplayLegacy(mockDisplayLegacyInputWithPersonId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for updateDisplayLegacy Service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Updates a display", async () => {
|
||||
prismaMock.display.update.mockResolvedValue(mockDisplayWithPersonId);
|
||||
|
||||
const display = await updateDisplayLegacy(mockDisplay.id, mockDisplayLegacyUpdateInput);
|
||||
expect(display).toEqual(mockDisplayWithPersonId);
|
||||
});
|
||||
|
||||
it("marks display as responded legacy", async () => {
|
||||
prismaMock.display.update.mockResolvedValue(mockDisplayLegacyWithRespondedStatus);
|
||||
|
||||
const display = await markDisplayRespondedLegacy(mockDisplay.id);
|
||||
expect(display).toEqual(mockDisplayLegacyWithRespondedStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(updateDisplayLegacy, "123", "123");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prismaMock.display.update.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(updateDisplayLegacy(mockDisplay.id, mockDisplayLegacyUpdateInput)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for other unexpected issues", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prismaMock.display.update.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(updateDisplayLegacy(mockDisplay.id, mockDisplayLegacyUpdateInput)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for deleteDisplayByResponseId service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Deletes a display when a response associated to it is deleted", async () => {
|
||||
prismaMock.display.delete.mockResolvedValue(mockDisplayWithResponseId);
|
||||
|
||||
const display = await deleteDisplayByResponseId(mockResponseId, mockSurveyId);
|
||||
expect(display).toEqual(mockDisplayWithResponseId);
|
||||
});
|
||||
});
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(createDisplayLegacy, "123");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
prismaMock.display.delete.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(deleteDisplayByResponseId(mockResponseId, mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("Throws a generic Error for other exceptions", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prismaMock.display.delete.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(deleteDisplayByResponseId(mockResponseId, mockSurveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,34 +14,34 @@
|
||||
"test": "jest -ci --coverage --no-cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/s3-presigned-post": "3.485.0",
|
||||
"@aws-sdk/client-s3": "3.485.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.485.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"mime": "4.0.1",
|
||||
"@aws-sdk/s3-presigned-post": "3.490.0",
|
||||
"@aws-sdk/client-s3": "3.490.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.490.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.3",
|
||||
"@formbricks/api": "*",
|
||||
"@formbricks/database": "*",
|
||||
"@formbricks/types": "*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"aws-crt": "^1.20.1",
|
||||
"date-fns": "^3.0.6",
|
||||
"date-fns": "^3.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"markdown-it": "^14.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.0.4",
|
||||
"next-auth": "^4.24.5",
|
||||
"nodemailer": "^6.9.8",
|
||||
"posthog-node": "^3.3.0",
|
||||
"posthog-node": "^3.5.0",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "*",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/mime": "3.0.4",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"babel-jest": "^29.7.0",
|
||||
"jest-mock-extended": "^3.0.5",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"jest": "^29.7.0",
|
||||
"jest-mock-extended": "^3.0.5",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
|
||||