Merge remote-tracking branch 'upstream/main' into feature/survey-back-button

This commit is contained in:
tykerr
2023-07-17 20:31:09 +07:00
116 changed files with 6219 additions and 2299 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@formbricks/formbricks-com"]
"ignore": ["@formbricks/formbricks-com", "@formbricks/demo"]
}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
if: ${{ secrets.APP_URL && secrets.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
-X POST \
@@ -0,0 +1,40 @@
name: Release Formbricks Image on Dockerhub
on:
push:
tags:
- "v*"
jobs:
release-image-on-dockerhub:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
${{ secrets.DOCKER_USERNAME }}/formbricks:latest
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
node-version: 18.x
- name: Install pnpm
uses: pnpm/action-setup@v2.2.2
uses: pnpm/action-setup@v2.2.4
- name: Install Dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
+1 -1
View File
@@ -13,7 +13,7 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@heroicons/react": "^2.0.18",
"next": "13.4.8",
"next": "13.4.9",
"react": "18.2.0",
"react-dom": "18.2.0"
},
+4 -4
View File
@@ -21,12 +21,12 @@
"@mapbox/rehype-prism": "^0.8.0",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "^13.4.8",
"@next/mdx": "^13.4.9",
"@paralleldrive/cuid2": "^2.2.1",
"clsx": "^1.2.1",
"lottie-web": "^5.12.2",
"next": "13.4.8",
"next-plausible": "^3.8.0",
"next": "13.4.9",
"next-plausible": "^3.9.1",
"next-sitemap": "^4.1.8",
"prism-react-renderer": "^2.0.6",
"prismjs": "^1.29.0",
@@ -35,7 +35,7 @@
"react-icons": "^4.10.1",
"react-responsive-embed": "^2.1.0",
"remark-gfm": "^3.0.1",
"sharp": "^0.32.1"
"sharp": "^0.32.2"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,171 @@
import Image from "next/image";
import LayoutMdx from "@/components/shared/LayoutMdx";
import { Callout } from "@/components/shared/Callout";
import TweetPeer from "./peer-tweet-typeform-open-source.png";
import SnoopForms from "./snoopforms-open-source-typeform-alternative.png";
import TwitterResult from "./twitter-results-PMF-cal.png";
import EmailResult from "./email-results-PMF-cal.png";
import TitleImage from "../github-accelerator-experience/formbricks-sponsored-by-github-accelerator-2023.webp";
import PMFDashboard from "./pmf-survey-dashboard.png";
import MattiJojo from "./matti-jojo.jpg";
export const meta = {
title: "Formbricks v1 - How we got here 🤸",
description:
"A lot has happened since Matti and I had a chat about open-source surveys in May last year. The release of Formbricks v1.0 is a perfect opportunity to look back on how it all started.",
date: "2023-07-14",
};
_A lot has happened since Matti and I had a chat about open-source surveys in May last year. The release of Formbricks v1.0 is a perfect opportunity to look back on how it all started._
Funnily enough, it started with a tweet:
<Image
src={TweetPeer}
alt="Peer tweets about the best open-source Typeform alternative"
className="rounded-lg"
/>
When I read this tweet, I was sitting in a WeWork in Mexico City, feeling a bit guilty for being such a cliché digital nomad. Sipping on my decaffeinated matcha with lactose-free goat milk, I slid into Matti's DMs. I knew he had built an open-source survey tool a few months ago and suggested he comment on the tweet. We started chatting.
Both Matti and I had been freelancing for several months. While the money was good, we missed working on a project of our own. We met a couple of years ago while working on our startups. I had an app and wanted to be GDPR compliant; Matti offered to help out. We've stayed in contact ever since, giving feedback on projects and ideas.
So we chatted about the opportunity of building a commercial open-source alternative to Typeform. After reading up on the advantages - and challenges - of open-source, we decided to build a side project together: **[snoopForms: The Open-Source Typeform Alternative](https://snoopforms.com/)** 🦝
<Image
src={SnoopForms}
alt="SnoopForms was the OS Typeform alternative with a twist"
className="rounded-lg"
/>
### **snoopForms and why OS Typeform isnt a good business**
We shipped a first version of the product and the landing page and shared it on ProductHunt and HackerNews. It stirred up quite some interest—1.5k people signed up over a few weeks. During the launch, we sold 10 early bird deals for $179 each. We got excited! Is it really that easy?
Well no, it is not. Apart from the Early Birds (🤍) we attracted a looooooooot of people who liked Typeform, but didnt want to pay the high price tag. Generally, it's not the best idea to build a product with high competition (100s of survey tools) for a price-sensitive target group. Even data privacy isnt a good selling point when Jotform offers self-hosting and Typeform is an EU-based company (GDPR). Weve written **[in-depth](https://formbricks.com/blog/open-source-qualtrics-beats-typeform)** about why we eventually decided to shift our focus away from open-source Typeform.
Nonetheless, we were hooked on open-source surveys; we just hadn't found the right application yet.
### **snoopForms → Formbricks, a detour, and going full-time**
We decided to wrap up our freelance gigs and go in full-time, to find the right angle quicker. We built a MVP for _Building Bricks for Forms and Surveys_, but while the idea made sense, we struggled to build an easy, opionated solution which can be used for several use cases. To help engineers build form and survey applications faster, it needed to pack form creation, form analytics, graphs, data handling, data storage, analysis, integrations, and ways to act on the insights for a variety of use cases 🤷 There's a reason why successful dev tools focus on one of these aspects and do them well. So we kept looking…
### **Data Processing vs. Experience Management**
We had talked to and surveyed a lot of people. Zooming out, we saw that there are two applications for forms: data processing and experience management.
**Data processing** is essentially getting information out of a human brain into a digital system. The focus here lies on high integrability with (legacy) systems, versatility, and a great developer experience. Libraries like React Hook Forms or the more productized form.io fall into this category. While there certainly is a need here, it was a less inspiring field for us, especially after reading how [tedious](https://medium.com/@jgee/what-i-learned-in-two-years-of-moving-government-forms-online-1edc4c2aa089) it can be to bring [legacy](https://medium.com/san-francisco-digital-services/how-to-make-a-form-d1d1b67d95d7) form systems up to speed.
**Experience Management** on the other hand is a lot more exciting! Helping teams build better products, services, and organizations is a mission both Matti and I could get behind ❤️
<Callout title="In a nutshell" type="note">
Experience Management, in a nutshell, is enabling teams to gather, analyze, and act on qualitative data
collected from their users, customers, and employees.
</Callout>
We researched what was out there and talked to more founders. Initially, we wanted to build a solution for a problem we had ourselves: Gathering qualitative insights along the user journey of early adopters. Its common sense to never launch without analytics, but why does everyone launch without a system to gather qualitative user insights continuously? Mostly, because there's no easy, cheap and quick way to do it (yet).
### **The Product-Market Fit Survey Tool**
Following Paul Graham's advice to start with a **[narrow but deep hole](http://paulgraham.com/startupideas.html)**, we narrowed it down to one specific problem every founder has: Measuring Product-Market Fit. Many know the **[article](https://review.firstround.com/how-superhuman-built-an-engine-to-find-product-market-fit)** on how Superhuman built an engine to measure and optimize PMF. However, to run it correctly within your product, you do have to do quite some custom coding. So we decided to offer a solution which works out of the box.
<Image
src={PMFDashboard}
alt="Peer tweets about the best open-source Typeform alternative"
className="rounded-lg"
/>
_We built a custom dashboard just to visualize the PMF survey results_
We shipped an MVP, launched it, and picked up the conversations with the founders who had tried to measure PMF
before. We quickly learned this:
- Our MVP was scoped so rigidly that we had to do quite some custom coding ourselves to get it implemented. Setting it up and maintaining it stood in no relation to the value it provided because…
- The PMF survey is for teams that "kinda" have PMF, not for teams which are just starting out. You need a few hundred recurring users in your app to be able to reach statistical significance and run the survey every 3 months without asking anyone twice. For teams who dont have that, talking to users 1:1 will remain the best way to go.
- Founders were reluctant to add another tool just for one survey and purpose.
After the rather disappointing launch of the PMF survey tool, we hit a low. The excitement about the initial snoopForm traction had passed. Since November, we had been working full-time for four months iterating on different products in the forms and survey space, and it didnt feel like we made much progress. But that wasnt really true.
### **"The essence of strategy is choosing what not to do.”**
It was quite a journey since Matti wrote the first line of code in July last year. Even though it didnt _feel_ like much progress, we had learned a lot. Most importantly, we knew what **not** to build.
With snoopForms, we learned that there is a latent need for an open-source standalone survey tool, with a Typeform-like feature set: Its a great opportunity to build a community and **not** a great business opportunity. The PMF survey tool taught us about the needs of early-stage teams and that surveys are **not** the best solution to their problems. However, they do need some form to gather qualitative feedback and a smooth way to work with it 😏
We took everything we knew and wrote a Masterplan. And then we sat down coded.
### **Open-source in-product surveys**
March and the first half of April flew by as we hacked together the MVP of what Formbricks is today: A tool which enables teams to target specific segments of their user base with versatile micro-surveys. Matti solved some tricky technical challenges around the Formbricks Widget and shipped the backend. ChatGPT and I warmed up with next.js and built frontend. Seeing the product come together was fun and felt like progress - exactly what we needed!
<Image
src={TitleImage}
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
className="rounded-lg"
/>
A welcomed motivator came in the form of an email from GitHub: We we were chosen to take part in the first-ever batch of the GitHub Open-Source Accelerator! It was a really fun programme and we learned a lot! You can read about our experience [here.](https://formbricks.com/blog/github-accelerator-experience)
### **On the right track 🚆**
Once we were ready to share our MVP with the world, we knew we were on the right track! The feedback was really good and usage picked up right from the start. One morning we woke up to our analytics dashboard showing thousands of survey displays with 500+ responses in the past couple of hours! This is when we knew were onto something 🚀
A couple of weeks later, Peer from [Cal.com](http://Cal.com) came back to northern Germany to visit his family and friends. We hung out in the co-working space, where he did his first steps as an entrepreneur. As we were chatting about how to separate opinion from experience, Peer decided to shoot an email with a survey to the 12k most active users of Cal.com. A few days before, he asked his Twitter following the PMF question:
<Image
src={TwitterResult}
alt="SnoopForms was the OS Typeform alternative with a twist"
className="rounded-lg"
/>
We wanted to compare how the publicly asked survey (Twitter) compared to a survey among the community of users. The results were eye-opening:
<Image
src={EmailResult}
alt="SnoopForms was the OS Typeform alternative with a twist"
className="rounded-lg"
/>
_Anything above 40% is considered PMF, 60% is 🔥🔥🔥_
For us, this was not only a good test for our system but it was a great example that Experience Management does not only happen within apps, but on several touch points right from the start. If we want to offer a solution which can comprehensively measure and evaluate experiences, it cannot be confined to in-product surveys for too long.
### **Typeform sneaking back in?**
Given our early traction with snoopForms, its not a big surprise that community requests for a Typeform-like standalone survey popped up more and more. Since we had all the pieces in place, we shipped standalone surveys in a day. However, its not our focus right now, so we hand over most of the feature requests to the community. And the community ships! Just in the past couple of weeks, we got:
- **Prefill Data in Link Surveys** by Piyush - [Docs](https://formbricks.com/docs/link-surveys/data-prefilling)
- **Identify Users Link identification** by Zorig - [Docs](https://formbricks.com/docs/link-surveys/user-identification)
- **Close Survey on Date** by Piyush
- **Close Survey after x Responses** by Pradumn
- **Redirect on Link Survey Completion** by Dhruwang
- **Embed Link Survey via iframe** by Ankur
- **Set Custom Survey Closed Message** by Pradumn
- **Randomize Answer Options** by Shubdeep
We absolutely love the idea of being the platform for a community-developed standalone survey experience! Its a win-win: The community gets a free, self-hostable standalone survey product and we get a growing upper funnel for our Experience Management solution 🤸
### **Its coming together 🔥**
Looking back at the amount of value Formbricks can deliver after not even 5 months fills us with excitement. Matti and I have been hacking it together for the most part but we wouldnt be anywhere nearly as close without the support of our community and friends - thank you!
And were only getting started: We were able to hire two engineers from our community of contributors to build up more shipping velocity. We also started working with Kristian who runs our [open-source Design repo](https://github.com/formbricks/design) more closely and are super excited about what were cooking up. Well make sure Formbricks stays easy-to-use, accessible and simply beautiful as it grows more powerful! We can't tell you how excited we are about what's coming 😁
If youre curious to learn about all the things you can do with Formbricks today, weve written and keep updating a list of Best Practices incl. survey templates in our [Docs](https://formbricks.com/docs/best-practices/cancel-subscription).
### Sounds good? **Build with us!**
- **Eager to contribute?** [Join our Discord](https://formbricks.com/discord) and say Hi! Were more than happy to find the perfect issue for your level of experience 🙌 😊
- **Got an experience to measure?** Open-source is the way to go. [Lets have a chat 🤙](https://cal.com/johannes/15)
<Image
src={MattiJojo}
alt="SnoopForms was the OS Typeform alternative with a twist"
className="rounded-lg w-1/2"
/>
### All the best!
Johannes & Matti
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -36,22 +36,24 @@ pnpm add @formbricks/js
## Integrating with Next.js 13 App Directory
The Next.js 13 app directory requires us to initialize Formbricks differently than the pages directory. Specifically, the app directory server-side renders components by default, and the formbricks-js library is a client-side library. To make these work together, create a `formbricks.js` file (or formbricks.ts if you are using Typescript) and set up the FormbricksProvider with the 'use client' directive:
The Next.js 13 app directory requires us to initialize Formbricks differently than the pages directory. Specifically, the app directory server-side renders components by default, and the formbricks-js library is a client-side library. To make these work together, create a `formbricks.tsx` file (or `formbricks.js` if you don't use Typescript) and set up the FormbricksProvider with the 'use client' directive:
```tsx
// app/formbricks.js
// app/formbricks.tsx
"use client";
import formbricks from "@formbricks/js";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
environmentId: "clj66eqzu00m5qu0g8leglrns",
apiHost: "https://app.formbricks.com", // e.g. https://app.formbricks.com
debug: true, // remove when in production
});
}
export default function Providers({ Component, pageProps }: AppProps) {
export default function FormbricksProvider() {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -63,21 +65,17 @@ export default function Providers({ Component, pageProps }: AppProps) {
}
```
Once we do this, we can then import the `provider.js` file in our `app/layout.js` file, and wrap our app in the Formbricks provider.
Once we do this, we can then import the `formbricks.tsx` file in our `app/layout.tsx` file, and wrap our app in the Formbricks provider.
```tsx
// app/layout.js
import Providers from "./formbricks";
// app/layout.tsx
import FormbricksProvider from "./formbricks";
import "./globals.css";
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<Providers />
<FormbricksProvider />
<body>{children}</body>
</html>
);
@@ -8,68 +8,151 @@ export const meta = {
"Utilize Docker-Compose for easy deployment on your machine. Clone the repo, configure settings, and build the image to access the app on localhost.",
};
<Callout title="Public Beta" type="warning">
Formbricks is not yet stable. Please reach out to us when you deploy Formbricks in production:
hola@formbricks.com
</Callout>
At Formbricks, we understand that different users have different needs, and we strive to cater to a wide variety of situations. This is why we provide two ways of running our application using Docker:
## Deploy with Docker-Compose
1. **Fast Setup with a Pre-built Docker Image:** This method is designed for those who want to quickly set up and start using Formbricks without getting into the technicalities of Docker or the build process. When you choose this method, you're using an image that we've already built for you. The pre-built image is ready-to-run, and it only requires minimal configuration on your part. This approach is perfect for getting a functional instance of Formbricks up and running with minimal hassle. It's as easy as downloading the Docker image and firing up the container.
The easiest way to deploy Formbricks on your own machine is using Docker. This requires Docker and the docker compose plugin on your system to work.
2. **Manual Setup by Building the Docker Image from Source:** This approach provides the flexibility to configure every aspect of your Formbricks instance, including environment variables that need to be set at build time. While we don't recommend changing the source code of Formbricks, this method allows you to set your own configuration that might be necessary for specific deployment needs. Keep in mind that this method requires a more in-depth understanding of Docker and the build process. However, the trade-off is the additional control and flexibility you gain, making it worth considering if you're a more advanced user or have very specific configuration needs.
Clone the repository:
Please note that regardless of the method you choose, Formbricks is designed to be easy-to-use and flexible. So choose the method that best fits your comfort level and requirements, and start leveraging the power of Formbricks today!
```bash
git clone https://github.com/formbricks/formbricks.git && cd formbricks
```
---
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and Formbricks works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings in the `.env` file. If you configured your email credentials, you can also comment the following lines to enable email verification (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1`)
## Requirements
Copy the `.env.docker` file to `.env` and edit it with an editor of your choice if needed.
Ensure `docker` & `docker compose` are installed on your server/system. Both are typically included with Docker utilities, like Docker Desktop and Rancher Desktop.
```bash
cp .env.docker .env
```
**Note**: `docker compose` without the hyphen is now the primary method of using docker-compose, according to the Docker documentation.
Note: The environment variables are used at build time. When you change environment variables later, you need to rebuild the image with `docker compose build` for the changes to take effect.
## (Most users) Running the pre-built Docker Image
Finally start the docker compose process to build and spin up the Formbricks container as well as the PostgreSQL database.
This is suitable for those who are testing Formbricks or running it with minimal to no modifications. For this we use the [public Docker image](https://hub.docker.com/r/formbricks/formbricks) and a simple docker-compose file.
```bash
docker compose up -d
# (use docker-compose if you are on an older docker version)
```
1. **Create a New Directory for Formbricks**
You can now access the app on [http://localhost:3000](http://localhost:3000). You will be automatically redirected to the login. To use your local installation of Formbricks, create a new account.
Open a terminal and create a new directory for Formbricks, then navigate into this new directory:
## Stop the containers
```bash
mkdir formbricks-quickstart && cd formbricks-quickstart
```
To stop the containers, run:
2. **Download the Docker-Compose File**
```bash
docker compose down
```
Download the docker-compose file directly from the Formbricks repository:
## Update Formbricks
```bash
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/main/docker/docker-compose.yml
```
To update Formbricks, pull the latest changes from the repository:
3. **Generate NextAuth Secret**
```bash
git pull
```
Next, you need to generate a NextAuth secret. This will be used for session signing and encryption. The `sed` command below generates a random string using `openssl`, then replaces the `NEXTAUTH_SECRET:` placeholder in the `docker-compose.yml` file with this generated secret:
and rebuild the image:
```bash
sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.\*/NEXTAUTH_SECRET: $(openssl rand -base64 32)/" docker-compose.yml
```
```bash
docker compose build
```
4. **Start the Docker Setup**
Then you can stop and restart the containers again:
You're now ready to start the Formbricks Docker setup. The following command will start Formbricks together with a postgreSQL database using Docker Compose:
```bash
docker compose down
docker compose up -d
```
```bash
docker compose up -d
```
The `-d` flag will run the containers in detached mode, meaning they'll run in the background.
5. **Visit Formbricks in Your Browser**
After starting the Docker setup, visit http://localhost:3000 in your browser to interact with the Formbricks application. The first time you access this page, you'll be greeted by a setup wizard. Follow the prompts to define your first user and get started.
## Updating Formbricks
1. Stop the Formbricks stack
```bash
docker compose down
```
2. Pull the latest changes
```bash
docker compose pull
```
3. Update env vars as necessary.
4. Re-start the Formbricks stack
```bash
docker compose up -d
```
## (Advanced users) Build and Run Formbricks
1. Clone the repository:
```bash
git clone https://github.com/formbricks/formbricks.git && cd formbricks
```
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and Formbricks works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings in the `.env` file. If you configured your email credentials, you can also comment the following lines to enable email verification (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1`)
2. Copy the `.env.docker` file to `.env` and edit it with an editor of your choice if needed.
```bash
cp .env.docker .env
```
Note: The environment variables are used at build time. When you change environment variables later, you need to rebuild the image with `docker compose build` for the changes to take effect.
3. Finally start the docker compose process to build and spin up the Formbricks container as well as the PostgreSQL database.
```bash
docker compose up -d
# (use docker-compose if you are on an older docker version)
```
You can now access the app on [http://localhost:3000](http://localhost:3000). You will be automatically redirected to the login. To use your local installation of Formbricks, create a new account.
Certainly, here is the reformatted version for Formbricks environment variables.
### Important Run-time Variables
These variables must also be provided at runtime.
| Variable | Description | Required | Default |
| ---------------------- | --------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------- |
| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
### Build-time Variables
These variables must be provided at the time of the docker build and can be provided by updating the `.env` file.
| Variable | Description | Required | Default |
| --------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files. | required | `http://localhost:3000` |
| PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | |
| NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
| NEXT_PUBLIC_PASSWORD_RESET_DISABLED | Disables password reset functionality if set to `1`. | optional | |
| NEXT_PUBLIC_SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
| NEXT_PUBLIC_INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
| NEXT_PUBLIC_PRIVACY_URL | URL for privacy policy. | optional | |
| NEXT_PUBLIC_TERMS_URL | URL for terms of service. | optional | |
| NEXT_PUBLIC_IMPRINT_URL | URL for imprint. | optional | |
| SENTRY_IGNORE_API_RESOLUTION_ERROR | Disables Sentry warning if set to `1`. | optional | |
| NEXT_PUBLIC_SENTRY_DSN | DSN for Sentry error tracking. | optional | |
| NEXT_PUBLIC_GITHUB_AUTH_ENABLED | Enables GitHub login if set to `1`. | optional | |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| NEXT_PUBLIC_GOOGLE_AUTH_ENABLED | Enables Google login if set to `1`. | optional | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| CRON_SECRET | Secret for running cron jobs. | optional | |
Please refer to the [Formbricks Instructions](https://github.com/formbricks/formbricks) for more details on generating these variables.
## Debugging
+9 -2
View File
@@ -5,7 +5,7 @@ WORKDIR /app
COPY . .
# Copy .env file because Docker don't follow symlinks
COPY .env /app/apps/web/
COPY .env.docker /app/apps/web/.env
RUN pnpm install
@@ -34,4 +34,11 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/publ
COPY --from=installer --chown=nextjs:nodejs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nodejs /app/packages/database/migrations ./packages/database/migrations
CMD pnpm dlx prisma migrate deploy && node apps/web/server.js
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
CMD if [ "$NEXTAUTH_SECRET" != "RANDOM_STRING" ]; then \
pnpm dlx prisma migrate deploy && node apps/web/server.js; \
else \
echo "ERROR: Please set a value for NEXTAUTH_SECRET in .env.docker!"; \
exit 1; \
fi
+2 -2
View File
@@ -22,8 +22,8 @@ export default function FormbricksClient({ session }) {
});
formbricks.setUserId(session.user.id);
formbricks.setEmail(session.user.email);
if (session.user.plan) {
formbricks.setAttribute("Plan", session.user.plan);
if (session.user.teams?.length > 0) {
formbricks.setAttribute("Plan", session.user.teams[0].plan);
}
}
}, [session]);
@@ -141,6 +141,7 @@ export const authOptions: NextAuthOptions = {
memberships: {
select: {
teamId: true,
role: true,
team: {
select: {
plan: true,
@@ -156,15 +157,17 @@ export const authOptions: NextAuthOptions = {
return token;
}
const teams = existingUser.memberships.map((membership) => ({
id: membership.teamId,
role: membership.role,
plan: membership.team.plan,
}));
const additionalAttributs = {
id: existingUser.id,
createdAt: existingUser.createdAt,
onboardingCompleted: existingUser.onboardingCompleted,
teamId: existingUser.memberships.length > 0 ? existingUser.memberships[0].teamId : undefined,
plan:
existingUser.memberships.length > 0 && existingUser.memberships[0].team
? existingUser.memberships[0].team.plan
: undefined,
teams,
name: existingUser.name,
};
@@ -181,9 +184,7 @@ export const authOptions: NextAuthOptions = {
// @ts-ignore
session.user.onboardingCompleted = token?.onboardingCompleted;
// @ts-ignore
session.user.teamId = token?.teamId;
// @ts-ignore
session.user.plan = token?.plan;
session.user.teams = token?.teams;
session.user.name = token.name || "";
return session;
@@ -100,7 +100,7 @@ const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
<div style="display: block; margin-top:3em;">
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses" style="color:#1e293b;">
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
</a>
<span style="display: inline; margin-left: 10px; background-color: ${
@@ -113,7 +113,7 @@ const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
survey.responsesCount >= 1
? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses" style="background: #1e293b; margin-top:1em; font-size:0.9em; font-weight:500">
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA">
${getButtonLabel(survey.responsesCount)}
</a>`
: ""
@@ -153,8 +153,9 @@ const createSurveyFields = (surveryResponses: SurveyResponse[]) => {
const notificationFooter = () => {
return `
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
<p style="margin-top:0.8em; text-align:center; font-size:0.8em; line-height:1em;">The Formbricks Team 🤍</p>
`;
};
@@ -164,7 +165,7 @@ const createReminderNotificationBody = (notificationData: NotificationResponse,
<p style="font-weight: bold; padding-top:1em;">Dont let a week pass without learning about your users:</p>
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys" style="background: #1e293b; font-size:0.9em; font-weight:500">Setup a new survey</a>
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
<br/>
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
@@ -35,7 +35,7 @@ export async function POST(): Promise<NextResponse> {
const notificationResponse = getNotificationResponse(product.environments[0], product.name);
// if there were no responses in the last 7 days, send a different email
if (notificationResponse.insights.totalCompletedResponses == 0) {
if (notificationResponse.insights.numLiveSurvey == 0) {
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendNoLiveSurveyNotificationEmail(teamMember.user.email, notificationResponse)
@@ -12,8 +12,8 @@ export default function PosthogIdentify({ session }: { session: Session }) {
useEffect(() => {
if (posthogEnabled && session.user && posthog) {
posthog.identify(session.user.id);
if (session.user.teamId) {
posthog?.group("team", session.user.teamId);
if (session.user.teams?.length > 0) {
posthog?.group("team", session.user.teams[0].id);
}
}
}, [session, posthog]);
@@ -0,0 +1,98 @@
"use client";
import {
QuestionOption,
QuestionOptions,
} from "@/app/environments/[environmentId]/surveys/[surveyId]/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/environments/[environmentId]/surveys/[surveyId]/ResponseFilter";
import { getTodayDate } from "@/lib/surveys/surveys";
import { createContext, useContext, useState } from "react";
interface FilterValue {
questionType: Partial<QuestionOption>;
filterType: {
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
};
}
export interface SelectedFilterValue {
filter: FilterValue[];
onlyComplete: boolean;
}
interface SelectedFilterOptions {
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
}
export interface DateRange {
from: Date | undefined;
to?: Date | undefined;
}
interface FilterDateContextProps {
selectedFilter: SelectedFilterValue;
setSelectedFilter: React.Dispatch<React.SetStateAction<SelectedFilterValue>>;
selectedOptions: SelectedFilterOptions;
setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedFilterOptions>>;
dateRange: DateRange;
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
resetState: () => void;
}
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
function ResponseFilterProvider({ children }: { children: React.ReactNode }) {
// state holds the filter selected value
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
filter: [],
onlyComplete: false,
});
// state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
questionFilterOptions: [],
questionOptions: [],
});
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined,
to: getTodayDate(),
});
const resetState = () => {
setDateRange({
from: undefined,
to: getTodayDate(),
});
setSelectedFilter({
filter: [],
onlyComplete: false,
});
};
return (
<ResponseFilterContext.Provider
value={{
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
}}>
{children}
</ResponseFilterContext.Provider>
);
}
function useResponseFilter() {
const context = useContext(ResponseFilterContext);
if (context === undefined) {
throw new Error("useFilterDate must be used within a FilterDateProvider");
}
return context;
}
export { ResponseFilterContext, ResponseFilterProvider, useResponseFilter };
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,11 @@
import EventClassesList from "./EventClassesList";
import EventsAttributesTabs from "@/components/events_attributes/EventsAttributesTabs";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Actions & Attributes",
};
export default function EventsPage({ params }) {
return (
@@ -1,4 +1,9 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Integrations",
};
export default function IntegrationsLayout({ children }) {
return (
@@ -6,6 +6,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import PosthogIdentify from "./PosthogIdentify";
import FormbricksClient from "../../FormbricksClient";
import { PosthogClientWrapper } from "../../PosthogClientWrapper";
import { ResponseFilterProvider } from "@/app/environments/[environmentId]/ResponseFilterContext";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
export default async function EnvironmentLayout({ children, params }) {
@@ -20,16 +21,18 @@ export default async function EnvironmentLayout({ children, params }) {
return (
<>
<PosthogIdentify session={session} />
<FormbricksClient session={session} />
<ToasterClient />
<EnvironmentsNavbar environmentId={params.environmentId} session={session} />
<PosthogClientWrapper>
<main className="h-full flex-1 overflow-y-auto bg-slate-50">
{children}
<main />
</main>
</PosthogClientWrapper>
<ResponseFilterProvider>
<PosthogIdentify session={session} />
<FormbricksClient session={session} />
<ToasterClient />
<EnvironmentsNavbar environmentId={params.environmentId} session={session} />
<PosthogClientWrapper>
<main className="h-full flex-1 overflow-y-auto bg-slate-50">
{children}
<main />
</main>
</PosthogClientWrapper>
</ResponseFilterProvider>
</>
);
}
@@ -38,7 +38,7 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-50 px-3 py-1 text-sm text-slate-600">
<Link
className="hover:underline"
href={`environments/${environmentId}/surveys/${response.survey.id}/summary`}>
href={`/environments/${environmentId}/surveys/${response.survey.id}/summary`}>
{response.survey.name}
</Link>
<SurveyStatusIndicator
@@ -1,4 +1,9 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "People",
};
export default function PeopleLayout({ children }) {
return (
@@ -1,7 +1,8 @@
export const revalidate = 0;
export const revalidate = REVALIDATION_INTERVAL;
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { truncateMiddle } from "@/lib/utils";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getPeople } from "@formbricks/lib/services/person";
import { TPerson } from "@formbricks/types/v1/people";
import { PersonAvatar } from "@formbricks/ui";
@@ -16,7 +17,11 @@ export default async function PeoplePage({ params }) {
return (
<>
{people.length === 0 ? (
<EmptySpaceFiller type="table" environmentId={params.environmentId} />
<EmptySpaceFiller
type="table"
environmentId={params.environmentId}
emptyMessage="Your users will appear here as soon as they use your app ⏲️"
/>
) : (
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
@@ -73,9 +73,9 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
<div className="">
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="p-8">
<h2 className="inline-flex text-3xl font-bold text-slate-700">Free</h2>
{session.user?.plan === "free" && <Badge text="Current Plan" size="normal" type="success" />}
<p className=" mt-4 whitespace-pre-wrap text-sm text-slate-600">
<h2 className="mr-2 inline-flex text-3xl font-bold text-slate-700">Free</h2>
{team.plan === "free" && <Badge text="Current Plan" size="normal" type="success" />}
<p className="mt-4 whitespace-pre-wrap text-sm text-slate-600">
Always free. Giving back to the community.
</p>
<ul className="mt-4 space-y-4">
@@ -91,7 +91,7 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
<p className="mt-6 text-3xl">
<span className="text-slate-800font-light">Always free</span>
</p>
{session.user?.plan === "free" ? (
{team.plan === "free" ? (
<Button variant="minimal" disabled className="mt-6 w-full justify-center py-4 shadow-sm">
Your current plan
</Button>
@@ -109,8 +109,8 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
<div className="">
<div className="rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="p-8">
<h2 className="inline-flex text-3xl font-bold text-slate-700">Pro</h2>
{session.user?.plan === "pro" && <Badge text="Current Plan" size="normal" type="success" />}
<h2 className="mr-2 inline-flex text-3xl font-bold text-slate-700">Pro</h2>
{team.plan === "pro" && <Badge text="Current Plan" size="normal" type="success" />}
<p className="mt-4 whitespace-pre-wrap text-sm text-slate-600">
All features included. Unlimited usage.
</p>
@@ -129,12 +129,12 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
<span className="text-base font-medium text-slate-400">/ month</span>
</p>
{session.user?.plan === "pro" ? (
{team.plan === "pro" ? (
<Button
variant="secondary"
className="mt-6 w-full justify-center py-4 shadow-sm"
onClick={() => openCustomerPortal()}>
Change Plan
Manage Subscription
</Button>
) : (
<Button
@@ -1,4 +1,9 @@
import SettingsNavbar from "./SettingsNavbar";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Settings",
};
export default function SettingsLayout({ children, params }) {
return (
@@ -130,7 +130,7 @@ export function DeleteProduct({ environmentId }) {
if (deleteProductRes?.id?.length > 0) {
toast.success("Product deleted successfully.");
router.push("/environments");
router.push("/");
} else if (deleteProductRes?.message?.length > 0) {
toast.error(deleteProductRes.message);
setIsDeleteDialogOpen(false);
@@ -40,7 +40,7 @@ const MergeTagsCombobox: React.FC<IMergeTagsComboboxProps> = ({ tags, onSelect }
<div className="p-1">
<CommandInput
placeholder="Search Tags..."
className="border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
</div>
<CommandEmpty>
@@ -1,20 +1,17 @@
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { Session } from "next-auth";
import Link from "next/link";
interface ResponsesLimitReachedBannerProps {
environmentId: string;
session: Session;
surveyId: string;
}
export default async function ResponsesLimitReachedBanner({
surveyId,
environmentId,
session,
}: ResponsesLimitReachedBannerProps) {
const { responsesCount, limitReached } = await getAnalysisData(session, surveyId, environmentId);
const { responsesCount, limitReached } = await getAnalysisData(surveyId, environmentId);
return (
<>
{limitReached && (
@@ -0,0 +1,50 @@
import { cn } from "@formbricks/lib/cn";
import { PresentationChartLineIcon, InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
interface SurveyResultsTabProps {
activeId: string;
environmentId: string;
surveyId: string;
}
export default function SurveyResultsTab({ activeId, environmentId, surveyId }: SurveyResultsTabProps) {
const tabs = [
{
id: "summary",
label: "Summary",
icon: <PresentationChartLineIcon />,
href: `/environments/${environmentId}/surveys/${surveyId}/summary?referer=true`,
},
{
id: "responses",
label: "Responses",
icon: <InboxStackIcon />,
href: `/environments/${environmentId}/surveys/${surveyId}/responses?referer=true`,
},
];
return (
<div>
<div className="mb-7 h-14 w-full border-b">
<nav className="flex h-full items-center space-x-4 justify-self-center" aria-label="Tabs">
{tabs.map((tab) => (
<Link
key={tab.id}
href={tab.href}
className={cn(
tab.id === activeId
? " border-brand-dark text-brand-dark border-b-2 font-semibold"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.icon && <div className="mr-2 h-5 w-5">{tab.icon}</div>}
{tab.label}
</Link>
))}
</nav>
</div>
</div>
);
}
@@ -1,15 +1,19 @@
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { Session } from "next-auth";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
export const getAnalysisData = async (session: Session, surveyId: string, environmentId: string) => {
const survey = await getSurvey(surveyId);
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
const [survey, team, allResponses] = await Promise.all([
getSurvey(surveyId),
getTeamByEnvironmentId(environmentId),
getSurveyResponses(surveyId),
]);
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
if (!team) throw new Error(`Team not found for environment: ${environmentId}`);
if (survey.environmentId !== environmentId) throw new Error(`Survey not found: ${surveyId}`);
const allResponses = await getSurveyResponses(surveyId);
const limitReached =
IS_FORMBRICKS_CLOUD && session?.user.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
IS_FORMBRICKS_CLOUD && team.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
const responses = limitReached ? allResponses.slice(0, RESPONSES_LIMIT_FREE) : allResponses;
const responsesCount = allResponses.length;
@@ -0,0 +1,28 @@
import { Metadata } from "next";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
type Props = {
params: { surveyId: string; environmentId: string };
};
export const generateMetadata = async ({ params }: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions);
if (session) {
const { responsesCount } = await getAnalysisData(params.surveyId, params.environmentId);
return {
title: `${responsesCount} Responses`,
};
}
return {
title: "",
};
};
const SurveyLayout = ({ children }) => {
return <div>{children}</div>;
};
export default SurveyLayout;
@@ -0,0 +1,56 @@
"use client";
import CustomFilter from "@/app/environments/[environmentId]/surveys/[surveyId]/CustomFilter";
import SummaryHeader from "@/app/environments/[environmentId]/surveys/[surveyId]/SummaryHeader";
import SurveyResultsTabs from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/SurveyResultsTabs";
import ResponseTimeline from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/ResponseTimeline";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { useResponseFilter } from "@/app/environments/[environmentId]/ResponseFilterContext";
import { getFilterResponses } from "@/lib/surveys/surveys";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
interface ResponsePageProps {
environmentId: string;
survey: TSurvey;
surveyId: string;
responses: TResponse[];
}
const ResponsePage = ({ environmentId, survey, surveyId, responses }: ResponsePageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const searchParams = useSearchParams();
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
}, [selectedFilter, responses, survey, dateRange]);
return (
<ContentWrapper>
<SummaryHeader environmentId={environmentId} survey={survey} surveyId={surveyId} />
<CustomFilter
environmentId={environmentId}
responses={filterResponses}
survey={survey}
totalResponses={responses}
/>
<SurveyResultsTabs activeId="responses" environmentId={environmentId} surveyId={surveyId} />
<ResponseTimeline
environmentId={environmentId}
surveyId={surveyId}
responses={filterResponses}
survey={survey}
/>
</ContentWrapper>
);
};
export default ResponsePage;
@@ -1,6 +1,6 @@
"use client";
import TagsCombobox from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox";
import TagsCombobox from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/TagsCombobox";
import { removeTagFromResponse, useAddTagToResponse, useCreateTag } from "@/lib/tags/mutateTags";
import { useTagsForEnvironment } from "@/lib/tags/tags";
import React, { useEffect, useState } from "react";
@@ -0,0 +1,91 @@
"use client";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { createId } from "@paralleldrive/cuid2";
import { useMemo } from "react";
import SingleResponse from "./SingleResponse";
interface ResponseTimelineProps {
environmentId: string;
surveyId: string;
responses: TResponse[];
survey: TSurvey;
}
export default function ResponseTimeline({
environmentId,
surveyId,
responses,
survey,
}: ResponseTimelineProps) {
const matchQandA = useMemo(() => {
if (survey && responses) {
// Create a mapping of question IDs to their headlines
const questionIdToHeadline = {};
survey.questions.forEach((question) => {
questionIdToHeadline[question.id] = question.headline;
});
// Replace question IDs with question headlines in response data
const updatedResponses = responses.map((response) => {
const updatedResponse: Array<{
id: string;
question: string;
answer: string;
type: string;
scale?: "number" | "star" | "smiley";
range?: number;
}> = []; // Specify the type of updatedData
// iterate over survey questions and build the updated response
for (const question of survey.questions) {
const answer = response.data[question.id];
if (answer) {
updatedResponse.push({
id: createId(),
question: question.headline,
type: question.type,
scale: question.scale,
range: question.range,
answer: answer as string,
});
}
}
return { ...response, responses: updatedResponse };
});
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag),
}));
return updatedResponsesWithTags;
}
return [];
}, [survey, responses]);
return (
<div className="space-y-4">
{responses.length === 0 ? (
<EmptySpaceFiller
type="response"
environmentId={environmentId}
noWidgetRequired={survey.type === "link"}
/>
) : (
<div>
{matchQandA.map((updatedResponse) => {
return (
<SingleResponse
key={updatedResponse.id}
data={updatedResponse}
surveyId={surveyId}
environmentId={environmentId}
/>
);
})}
</div>
)}
</div>
);
}
@@ -81,7 +81,7 @@ const TagsCombobox: React.FC<ITagsComboboxProps> = ({
<div className="p-1">
<CommandInput
placeholder={tagsToSearch?.length === 0 ? "Add tag..." : "Search or add tags..."}
className="border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
value={searchValue}
onValueChange={(search) => setSearchValue(search)}
onKeyDown={(e) => {
@@ -0,0 +1,27 @@
export const revalidate = REVALIDATION_INTERVAL;
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import ResponsePage from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/ResponsePage";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import { getServerSession } from "next-auth";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
export default async function Page({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const { responses, survey } = await getAnalysisData(params.surveyId, params.environmentId);
return (
<>
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
<ResponsePage
environmentId={params.environmentId}
responses={responses}
survey={survey}
surveyId={params.surveyId}
/>
</>
);
}
@@ -1,22 +1,27 @@
"use client";
import LinkSurveyModal from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkSurveyModal";
import LinkSurveyModal from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/LinkSurveyModal";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { ShareIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import clsx from "clsx";
interface LinkSurveyShareButtonProps {
survey: TSurvey;
className?: string;
}
export default function LinkSurveyShareButton({ survey }: LinkSurveyShareButtonProps) {
export default function LinkSurveyShareButton({ survey, className }: LinkSurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
return (
<>
<Button
variant="secondary"
className="h-full border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6"
className={clsx(
"border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6",
className
)}
onClick={() => setShowLinkModal(true)}>
<ShareIcon className="h-5 w-5" />
</Button>
@@ -54,8 +54,7 @@ export default function MultipleChoiceSummary({
}
function findEmail(person) {
const emailAttribute = person.attributes.email;
return emailAttribute ? emailAttribute.value : null;
return person.attributes?.email || null;
}
const addOtherChoice = (response, value) => {
@@ -12,8 +12,7 @@ interface OpenTextSummaryProps {
}
function findEmail(person) {
const emailAttribute = person.attributes.email;
return emailAttribute ? emailAttribute.value : null;
return person.attributes?.email || null;
}
export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) {
@@ -36,6 +36,10 @@ export default function SuccessMessage({ environmentId, survey }: SummaryMetadat
if (survey.type === "link") {
setShowLinkModal(true);
}
// Remove success param from url
const url = new URL(window.location.href);
url.searchParams.delete("success");
window.history.replaceState({}, "", url.toString());
}
}
}, [environment, searchParams, survey]);
@@ -1,34 +1,31 @@
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
import ConsentSummary from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/ConsentSummary";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import {
QuestionType,
type CTAQuestion,
type ConsentQuestion,
type MultipleChoiceMultiQuestion,
type MultipleChoiceSingleQuestion,
type NPSQuestion,
type OpenTextQuestion,
type RatingQuestion,
type ConsentQuestion,
} from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/v1/surveys";
import { Session } from "next-auth";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
import CTASummary from "./CTASummary";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
import OpenTextSummary from "./OpenTextSummary";
import RatingSummary from "./RatingSummary";
import ConsentSummary from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/ConsentSummary";
interface SummaryListProps {
environmentId: string;
surveyId: string;
session: Session;
survey: TSurvey;
responses: TResponse[];
}
export default async function SummaryList({ environmentId, surveyId, session }: SummaryListProps) {
const { survey, responses } = await getAnalysisData(session, surveyId, environmentId);
export default function SummaryList({ environmentId, survey, responses }: SummaryListProps) {
const getSummaryData = (): QuestionSummary<TSurveyQuestion>[] =>
survey.questions.map((question) => {
const questionResponses = responses
@@ -1,28 +1,15 @@
import LinkSurveyShareButton from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkModalButton";
import StatusDropdown from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/StatusDropdown";
import SuccessMessage from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/SuccessMessage";
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { PencilSquareIcon, QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
import { Session } from "next-auth";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
interface SummaryMetadataProps {
session: Session;
surveyId: string;
environmentId: string;
responses: TResponse[];
survey: TSurveyWithAnalytics;
}
export default async function SummaryMetadata({ session, surveyId, environmentId }: SummaryMetadataProps) {
const survey = await getSurvey(surveyId);
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
const allResponses = await getSurveyResponses(surveyId);
const limitReached =
IS_FORMBRICKS_CLOUD && session?.user.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
const responses = limitReached ? allResponses.slice(0, RESPONSES_LIMIT_FREE) : allResponses;
export default function SummaryMetadata({ responses, survey }: SummaryMetadataProps) {
const completionRate = !responses
? 0
: (responses.filter((r) => r.finished).length / responses.length) * 100;
@@ -95,22 +82,9 @@ export default async function SummaryMetadata({ session, surveyId, environmentId
<div className="text-right text-xs text-slate-400">
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
</div>
<div className="flex justify-end gap-x-1.5">
{survey.type === "link" && <LinkSurveyShareButton survey={survey} />}
<StatusDropdown survey={survey} environmentId={environmentId} />
<Button
variant="darkCTA"
className="h-full w-full px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
<PencilSquareIcon className="mr-2 h-5 w-5 text-white" />
Edit
</Button>
</div>
</div>
</div>
</div>
<SuccessMessage environmentId={environmentId} survey={survey} />
</>
);
}
@@ -0,0 +1,52 @@
"use client";
import CustomFilter from "@/app/environments/[environmentId]/surveys/[surveyId]/CustomFilter";
import SummaryHeader from "@/app/environments/[environmentId]/surveys/[surveyId]/SummaryHeader";
import SurveyResultsTabs from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/SurveyResultsTabs";
import SummaryList from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/SummaryList";
import SummaryMetadata from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/SummaryMetadata";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { useResponseFilter } from "@/app/environments/[environmentId]/ResponseFilterContext";
import { getFilterResponses } from "@/lib/surveys/surveys";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
interface SummaryPageProps {
environmentId: string;
survey: TSurveyWithAnalytics;
surveyId: string;
responses: TResponse[];
}
const SummaryPage = ({ environmentId, survey, surveyId, responses }: SummaryPageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const searchParams = useSearchParams();
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
}, [selectedFilter, responses, survey, dateRange]);
return (
<ContentWrapper>
<SummaryHeader environmentId={environmentId} survey={survey} surveyId={surveyId} />
<CustomFilter
environmentId={environmentId}
responses={filterResponses}
survey={survey}
totalResponses={responses}
/>
<SurveyResultsTabs activeId="summary" environmentId={environmentId} surveyId={surveyId} />
<SummaryMetadata responses={filterResponses} survey={survey} />
<SummaryList responses={filterResponses} survey={survey} environmentId={environmentId} />
</ContentWrapper>
);
};
export default SummaryPage;
@@ -0,0 +1,28 @@
export const revalidate = REVALIDATION_INTERVAL;
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import { getServerSession } from "next-auth";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import SummaryPage from "./SummaryPage";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
export default async function Page({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const { responses, survey } = await getAnalysisData(params.surveyId, params.environmentId);
return (
<>
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
<SummaryPage
environmentId={params.environmentId}
responses={responses}
survey={survey}
surveyId={params.surveyId}
/>
</>
);
}
@@ -0,0 +1,415 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Calendar,
} from "@formbricks/ui";
import { format, subDays, differenceInDays } from "date-fns";
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
import {
generateQuestionsAndAttributes,
generateQuestionAndFilterOptions,
getTodayDate,
} from "@/lib/surveys/surveys";
import toast from "react-hot-toast";
import { getTodaysDateFormatted } from "@formbricks/lib/time";
import { convertToCSV } from "@/lib/csvConversion";
import useClickOutside from "@formbricks/lib/useClickOutside";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { createId } from "@paralleldrive/cuid2";
import ResponseFilter from "./ResponseFilter";
import { DateRange, useResponseFilter } from "@/app/environments/[environmentId]/ResponseFilterContext";
import { useTagsForEnvironment } from "@/lib/tags/tags";
enum DateSelected {
FROM = "from",
TO = "to",
}
enum FilterDownload {
ALL = "all",
FILTER = "filter",
}
enum FilterDropDownLabels {
ALL_TIME = "All time",
LAST_7_DAYS = "Last 7 days",
LAST_30_DAYS = "Last 30 days",
CUSTOM_RANGE = "Custom range...",
}
interface CustomFilterProps {
environmentId: string;
survey: TSurvey;
responses: TResponse[];
totalResponses: TResponse[];
}
const getDifferenceOfDays = (from, to) => {
const days = differenceInDays(to, from);
if (days === 7) {
return FilterDropDownLabels.LAST_7_DAYS;
} else if (days === 30) {
return FilterDropDownLabels.LAST_30_DAYS;
} else {
return FilterDropDownLabels.CUSTOM_RANGE;
}
};
const CustomFilter = ({ environmentId, responses, survey, totalResponses }: CustomFilterProps) => {
const { setSelectedOptions, dateRange, setDateRange } = useResponseFilter();
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
dateRange.from && dateRange.to
? getDifferenceOfDays(dateRange.from, dateRange.to)
: FilterDropDownLabels.ALL_TIME
);
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const { data: environmentTags } = useTagsForEnvironment(environmentId);
// when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options
useEffect(() => {
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey,
totalResponses,
environmentTags
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}, [totalResponses, survey, setSelectedOptions, environmentTags]);
const datePickerRef = useRef<HTMLDivElement>(null);
const getMatchQandA = (responses: any, survey: any) => {
if (survey && responses) {
// Create a mapping of question IDs to their headlines
const questionIdToHeadline = {};
survey.questions.forEach((question) => {
questionIdToHeadline[question.id] = question.headline;
});
// Replace question IDs with question headlines in response data
const updatedResponses = responses.map((response) => {
const updatedResponse: Array<{
id: string;
question: string;
answer: string;
type: string;
scale?: "number" | "star" | "smiley";
range?: number;
}> = []; // Specify the type of updatedData
// iterate over survey questions and build the updated response
for (const question of survey.questions) {
const answer = response.data[question.id];
if (answer) {
updatedResponse.push({
id: createId(),
question: question.headline,
type: question.type,
scale: question.scale,
range: question.range,
answer: answer as string,
});
}
}
return { ...response, responses: updatedResponse };
});
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag),
}));
return updatedResponsesWithTags;
}
return [];
};
const csvFileName = useMemo(() => {
if (survey) {
const formattedDateString = getTodaysDateFormatted("_");
return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase();
}
return "my_survey_responses";
}, [survey]);
const downloadResponses = useCallback(
async (filter: FilterDownload) => {
const downloadResponse = filter === FilterDownload.ALL ? totalResponses : responses;
const { attributeMap, questionNames } = generateQuestionsAndAttributes(survey, downloadResponse);
const matchQandA = getMatchQandA(downloadResponse, survey);
const csvData = matchQandA.map((response) => {
const csvResponse = {
"Response ID": response.id,
Timestamp: response.createdAt,
Finished: response.finished,
"Survey ID": response.surveyId,
"Formbricks User ID": response.person?.id ?? "",
};
// Map each question name to its corresponding answer
questionNames.forEach((questionName: string) => {
const matchingQuestion = response.responses.find((question) => question.question === questionName);
let transformedAnswer = "";
if (matchingQuestion) {
const answer = matchingQuestion.answer;
if (Array.isArray(answer)) {
transformedAnswer = answer.join("; ");
} else {
transformedAnswer = answer;
}
}
csvResponse[questionName] = matchingQuestion ? transformedAnswer : "";
});
return csvResponse;
});
// Add attribute columns to the CSV
Object.keys(attributeMap).forEach((attributeName) => {
const attributeValues = attributeMap[attributeName];
Object.keys(attributeValues).forEach((personId) => {
const value = attributeValues[personId];
const matchingResponse = csvData.find((response) => response["Formbricks User ID"] === personId);
if (matchingResponse) {
matchingResponse[attributeName] = value;
}
});
});
// Fields which will be used as column headers in the CSV
const fields = [
"Response ID",
"Timestamp",
"Finished",
"Survey ID",
"Formbricks User ID",
...Object.keys(attributeMap),
...questionNames,
];
let response;
try {
response = await convertToCSV({
json: csvData,
fields,
fileName: csvFileName,
});
} catch (err) {
toast.error("Error downloading CSV");
return;
}
const blob = new Blob([response.csvResponse], { type: "text/csv;charset=utf-8;" });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${csvFileName}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
},
[csvFileName, responses, totalResponses, survey]
);
const handleDateHoveredChange = (date: Date) => {
if (selectingDate === DateSelected.FROM) {
const startOfRange = new Date(date);
startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day
// Check if the selected date is after the current 'to' date
if (startOfRange > dateRange?.to!) {
return;
} else {
setHoveredRange({ from: startOfRange, to: dateRange.to });
}
} else {
const endOfRange = new Date(date);
endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day
// Check if the selected date is before the current 'from' date
if (endOfRange < dateRange?.from!) {
return;
} else {
setHoveredRange({ from: dateRange.from, to: endOfRange });
}
}
};
const handleDateChange = (date: Date) => {
if (selectingDate === DateSelected.FROM) {
const startOfRange = new Date(date);
startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day
// Check if the selected date is after the current 'to' date
if (startOfRange > dateRange?.to!) {
const nextDay = new Date(startOfRange);
nextDay.setDate(nextDay.getDate() + 1);
nextDay.setHours(23, 59, 59, 999);
setDateRange({ from: startOfRange, to: nextDay });
} else {
setDateRange((prevData) => ({ from: startOfRange, to: prevData.to }));
}
setSelectingDate(DateSelected.TO);
} else {
const endOfRange = new Date(date);
endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day
// Check if the selected date is before the current 'from' date
if (endOfRange < dateRange?.from!) {
const previousDay = new Date(endOfRange);
previousDay.setDate(previousDay.getDate() - 1);
previousDay.setHours(0, 0, 0, 0); // Set to the start of the selected day
setDateRange({ from: previousDay, to: endOfRange });
} else {
setDateRange((prevData) => ({ from: prevData?.from, to: endOfRange }));
}
setIsDatePickerOpen(false);
setSelectingDate(DateSelected.FROM);
}
};
const handleDatePickerClose = () => {
setIsDatePickerOpen(false);
setSelectingDate(DateSelected.FROM);
};
useClickOutside(datePickerRef, () => handleDatePickerClose());
return (
<>
<div className="relative mb-12 flex justify-between">
<div className="flex justify-stretch gap-x-1.5">
<ResponseFilter />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsFilterDropDownOpen(value);
}}>
<DropdownMenuTrigger>
<div className="flex h-auto min-w-[8rem] items-center justify-between rounded-md border bg-white p-3 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span className="text-sm text-slate-700">
{filterRange === FilterDropDownLabels.CUSTOM_RANGE
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: filterRange}
</span>
{isFilterDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">All time</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.LAST_7_DAYS);
setDateRange({ from: subDays(new Date(), 7), to: getTodayDate() });
}}>
<p className="text-slate-700">Last 7 days</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setFilterRange(FilterDropDownLabels.LAST_30_DAYS);
setDateRange({ from: subDays(new Date(), 30), to: getTodayDate() });
}}>
<p className="text-slate-700">Last 30 days</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
setIsDatePickerOpen(true);
setFilterRange(FilterDropDownLabels.CUSTOM_RANGE);
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">Custom range...</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsDownloadDropDownOpen(value);
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[11rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">Download</span>
{isDownloadDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.ALL);
}}>
<p className="text-slate-700">All responses (CSV)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER);
}}>
<p className="text-slate-700">Current selection (CSV)</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={hoveredRange ? hoveredRange : dateRange}
numberOfMonths={2}
onDayClick={(date) => handleDateChange(date)}
onDayMouseEnter={handleDateHoveredChange}
onDayMouseLeave={() => setHoveredRange(null)}
classNames={{
day_today: "hover:bg-slate-200 bg-white",
}}
/>
</div>
)}
</div>
</>
);
};
export default CustomFilter;
@@ -0,0 +1,170 @@
"use client";
import * as React from "react";
import {
Command,
CommandGroup,
CommandItem,
CommandEmpty,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuContent,
} from "@formbricks/ui";
import useClickOutside from "@formbricks/lib/useClickOutside";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { QuestionType } from "@formbricks/types/questions";
import { isArray } from "lodash";
import clsx from "clsx";
type QuestionFilterComboBoxProps = {
filterOptions: string[] | undefined;
filterComboBoxOptions: string[] | undefined;
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
onChangeFilterComboBoxValue: (o: string | string[]) => void;
type: QuestionType | "Attributes" | "Tags" | undefined;
handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean;
};
const QuestionFilterComboBox = ({
filterComboBoxOptions,
filterComboBoxValue,
filterOptions,
filterValue,
onChangeFilterComboBoxValue,
onChangeFilterValue,
type,
handleRemoveMultiSelect,
disabled = false,
}: QuestionFilterComboBoxProps) => {
const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
const commandRef = React.useRef(null);
useClickOutside(commandRef, () => setOpen(false));
// multiple when question type is multi selection
const isMultiple = type === QuestionType.MultipleChoiceMulti || type === QuestionType.MultipleChoiceSingle;
// when question type is multi selection so we remove the option from the options which has been already selected
const options = isMultiple
? filterComboBoxOptions?.filter((o) => !filterComboBoxValue?.includes(o))
: filterComboBoxOptions;
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
const isDisabledComboBox =
(type === QuestionType.NPS || type === QuestionType.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
return (
<div className="inline-flex w-full flex-row">
{filterOptions && filterOptions?.length <= 1 ? (
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
</div>
) : (
<DropdownMenu
onOpenChange={(value) => {
value && setOpen(false);
setOpenFilterValue(value);
}}>
<DropdownMenuTrigger
disabled={disabled}
className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
!disabled ? "cursor-pointer" : "opacity-50"
)}>
<div className="flex items-center justify-between">
{!filterValue ? (
<p className="text-slate-400">Select...</p>
) : (
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
)}
{filterOptions && filterOptions.length > 1 && (
<>
{openFilterValue ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white p-2">
{filterOptions?.map((o, index) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
!isMultiple ? (
<p className="text-slate-600">{filterComboBoxValue}</p>
) : (
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
{typeof filterComboBoxValue !== "string" &&
filterComboBoxValue?.map((o) => (
<button
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
))}
</div>
)
) : (
<p className="text-slate-400">Select...</p>
)}
<div>
{open ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandEmpty>No result found.</CommandEmpty>
<CommandGroup>
{options?.map((o) => (
<CommandItem
onSelect={() => {
!isMultiple
? onChangeFilterComboBoxValue(o)
: onChangeFilterComboBoxValue(
isArray(filterComboBoxValue) ? [...filterComboBoxValue, o] : [o]
);
!isMultiple && setOpen(false);
}}
className="cursor-pointer">
{o}
</CommandItem>
))}
</CommandGroup>
</div>
)}
</div>
</Command>
</div>
);
};
export default QuestionFilterComboBox;
@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import {
Command,
CommandGroup,
CommandItem,
CommandInput,
CommandEmpty,
NetPromoterScoreIcon,
} from "@formbricks/ui";
import useClickOutside from "@formbricks/lib/useClickOutside";
import { ChevronDown, ChevronUp } from "lucide-react";
import { QuestionType } from "@formbricks/types/questions";
import {
StarIcon,
HashtagIcon,
TagIcon,
CursorArrowRippleIcon,
QuestionMarkCircleIcon,
ListBulletIcon,
QueueListIcon,
CheckIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
export enum OptionsType {
QUESTIONS = "Questions",
TAGS = "Tags",
ATTRIBUTES = "Attributes",
}
export type QuestionOption = {
label: string;
questionType?: QuestionType;
type: OptionsType;
id: string;
};
export type QuestionOptions = {
header: OptionsType;
option: QuestionOption[];
};
interface QuestionComboBoxProps {
options: QuestionOptions[];
selected: Partial<QuestionOption>;
onChangeValue: (option: QuestionOption) => void;
}
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
if (type === OptionsType.QUESTIONS) {
switch (questionType) {
case QuestionType.Rating:
return <StarIcon width={18} className="text-white" />;
case QuestionType.CTA:
return <CursorArrowRippleIcon width={18} className="text-white" />;
case QuestionType.OpenText:
return <QuestionMarkCircleIcon width={18} className="text-white" />;
case QuestionType.MultipleChoiceMulti:
return <ListBulletIcon width={18} className="text-white" />;
case QuestionType.MultipleChoiceSingle:
return <QueueListIcon width={18} className="text-white" />;
case QuestionType.NPS:
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
case QuestionType.Consent:
return <CheckIcon width={18} height={18} className="text-white" />;
}
}
if (type === OptionsType.ATTRIBUTES) {
return <HashtagIcon width={18} className="text-white" />;
}
if (type === OptionsType.TAGS) {
return <TagIcon width={18} className="text-white" />;
}
};
const getColor = () => {
if (type === OptionsType.ATTRIBUTES) {
return "bg-indigo-500";
} else if (type === OptionsType.QUESTIONS) {
return "bg-brand-dark";
} else {
return "bg-amber-500";
}
};
return (
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
<p className="ml-3 truncate text-base text-slate-600">{label}</p>
</div>
);
};
const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
const [open, setOpen] = React.useState(false);
const commandRef = React.useRef(null);
const [inputValue, setInputValue] = React.useState("");
useClickOutside(commandRef, () => setOpen(false));
return (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
<div
onClick={() => setOpen(true)}
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
{!open && selected.hasOwnProperty("label") && (
<SelectedCommandItem
label={selected?.label}
type={selected?.type}
questionType={selected?.questionType}
/>
)}
{(open || !selected.hasOwnProperty("label")) && (
<CommandInput
value={inputValue}
onValueChange={setInputValue}
placeholder="Search..."
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
)}
<div>
{open ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandEmpty>No result found.</CommandEmpty>
{options?.map((data) => (
<>
{data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
{data?.option?.map((o, i) => (
<CommandItem
key={`${o.label}-${i}`}
onSelect={() => {
setInputValue("");
onChangeValue(o);
setOpen(false);
}}
className="cursor-pointer">
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
</CommandItem>
))}
</CommandGroup>
)}
</>
))}
</div>
)}
</div>
</Command>
);
};
export default QuestionsComboBox;
@@ -0,0 +1,225 @@
"use client";
import { QuestionType } from "@formbricks/types/questions";
import QuestionsComboBox, { QuestionOption, OptionsType } from "./QuestionsComboBox";
import { useState, useEffect } from "react";
import { Popover, PopoverTrigger, PopoverContent, Button, Checkbox } from "@formbricks/ui";
import { ChevronDown, ChevronUp, Plus } from "lucide-react";
import { TrashIcon } from "@heroicons/react/24/solid";
import QuestionFilterComboBox from "@/app/environments/[environmentId]/surveys/[surveyId]/QuestionFilterComboBox";
import { useResponseFilter } from "@/app/environments/[environmentId]/ResponseFilterContext";
import clsx from "clsx";
export type QuestionFilterOptions = {
type: QuestionType | "Attributes" | "Tags";
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
};
const ResponseFilter = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const { selectedFilter, setSelectedFilter, selectedOptions } = useResponseFilter();
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
if (selectedFilter.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter
selectedFilter.filter[index] = {
questionType: value,
filterType: {
filterComboBoxValue: undefined,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
},
};
setSelectedFilter({ filter: [...selectedFilter.filter], onlyComplete: selectedFilter.onlyComplete });
} else {
// Update the existing value at the specified index
selectedFilter.filter[index].questionType = value;
selectedFilter.filter[index].filterType = {
filterComboBoxValue: undefined,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
};
setSelectedFilter({ ...selectedFilter });
}
};
// remove the added filter if nothing is selected when filter is closed
useEffect(() => {
if (!isOpen) {
clearItem();
}
}, [isOpen]);
const handleAddNewFilter = () => {
setSelectedFilter({
...selectedFilter,
filter: [
...selectedFilter.filter,
{
questionType: {},
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
});
};
const handleClearAllFilters = () => {
setSelectedFilter({ ...selectedFilter, filter: [] });
};
const handleDeleteFilter = (index: number) => {
selectedFilter.filter.splice(index, 1);
setSelectedFilter({ ...selectedFilter });
};
// when filter is opened and added a filter without selecting any option clear out that value
const clearItem = () => {
setSelectedFilter({
filter: [...selectedFilter.filter.filter((s) => s.questionType.hasOwnProperty("label"))],
onlyComplete: selectedFilter.onlyComplete,
});
};
const handleOnChangeFilterComboBoxValue = (o: string | string[], index: number) => {
selectedFilter.filter[index] = {
...selectedFilter.filter[index],
filterType: {
filterComboBoxValue: o,
filterValue: selectedFilter.filter[index].filterType.filterValue,
},
};
setSelectedFilter({ ...selectedFilter });
};
const handleOnChangeFilterValue = (o: string, index: number) => {
selectedFilter.filter[index] = {
...selectedFilter.filter[index],
filterType: { filterComboBoxValue: undefined, filterValue: o },
};
setSelectedFilter({ ...selectedFilter });
};
const handleRemoveMultiSelect = (value: string[], index) => {
selectedFilter.filter[index] = {
...selectedFilter.filter[index],
filterType: {
filterComboBoxValue: value,
filterValue: selectedFilter.filter[index].filterType.filterValue,
},
};
setSelectedFilter({ ...selectedFilter });
};
const handleCheckOnlyComplete = (checked: boolean) => {
setSelectedFilter({ ...selectedFilter, onlyComplete: checked });
};
// remove the filter which has already been selected
const questionComboBoxOptions = selectedOptions.questionOptions.map((q) => {
return {
...q,
option: q.option.filter((o) => !selectedFilter.filter.some((f) => f?.questionType?.id === o?.id)),
};
});
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-slate-100 p-3 text-sm text-slate-600 sm:min-w-[11rem] sm:px-6 sm:py-3">
Filter {selectedFilter.filter.length > 0 && `(${selectedFilter.filter.length})`}
<div className="ml-3">
{isOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px] ">
<div className="mb-8 flex flex-wrap items-start justify-between">
<p className="hidden text-lg font-bold text-black sm:block">Show all responses that match</p>
<p className="block text-base text-slate-500 sm:hidden">Show all responses where...</p>
<div className="flex items-center space-x-2">
<label className="text-sm font-normal text-slate-600">Only completed</label>
<Checkbox
className={clsx("rounded-md", selectedFilter.onlyComplete && "bg-black text-white")}
checked={selectedFilter.onlyComplete}
onCheckedChange={(checked) => {
typeof checked === "boolean" && handleCheckOnlyComplete(checked);
}}
/>
</div>
</div>
{selectedFilter.filter?.map((s, i) => (
<>
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
<div
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
key={`${s.questionType.id}-${i}`}>
<QuestionsComboBox
key={`${s.questionType.label}-${i}`}
options={questionComboBoxOptions}
selected={s.questionType}
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
/>
<QuestionFilterComboBox
key={`${s.questionType.id}-${i}`}
filterOptions={
selectedOptions.questionFilterOptions.find(
(q) => q.type === s.questionType.type || q.type === s.questionType.questionType
)?.filterOptions
}
filterComboBoxOptions={
selectedOptions.questionFilterOptions.find(
(q) =>
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
q.id === s.questionType.id
)?.filterComboBoxOptions
}
filterValue={selectedFilter.filter[i].filterType.filterValue}
filterComboBoxValue={selectedFilter.filter[i].filterType.filterComboBoxValue}
type={
s?.questionType?.type === OptionsType.QUESTIONS
? s?.questionType?.questionType
: s?.questionType?.type
}
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
disabled={!s?.questionType?.label}
/>
</div>
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
<p className="block font-light text-slate-500 md:hidden">Delete</p>
<TrashIcon
className="w-4 cursor-pointer text-slate-500 md:text-black"
onClick={() => handleDeleteFilter(i)}
/>
</div>
</div>
{i !== selectedFilter.filter.length - 1 && (
<div className="my-6 flex items-center">
<p className="mr-6 text-base text-slate-600">And</p>
<hr className="w-full text-slate-600" />
</div>
)}
</>
))}
<div className="mt-8 flex items-center justify-between">
<Button size="sm" variant="darkCTA" onClick={handleAddNewFilter}>
Add filter
<Plus width={18} height={18} className="ml-2" />
</Button>
<Button size="sm" variant="secondary" onClick={handleClearAllFilters}>
Clear all
</Button>
</div>
</PopoverContent>
</Popover>
);
};
export default ResponseFilter;
@@ -0,0 +1,213 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
ErrorComponent,
} from "@formbricks/ui";
import {
CheckCircleIcon,
PauseCircleIcon,
PlayCircleIcon,
PencilSquareIcon,
EllipsisHorizontalIcon,
} from "@heroicons/react/24/solid";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { useSurveyMutation } from "@/lib/surveys/mutateSurveys";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import SuccessMessage from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/SuccessMessage";
import LinkSurveyShareButton from "@/app/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/LinkModalButton";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
interface SummaryHeaderProps {
surveyId: string;
environmentId: string;
survey: TSurvey;
}
const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps) => {
const router = useRouter();
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const { triggerSurveyMutate } = useSurveyMutation(environmentId, surveyId);
if (isLoadingProduct || isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorProduct || isErrorEnvironment) {
return <ErrorComponent />;
}
return (
<div className="mb-11 mt-6 flex flex-wrap items-center justify-between">
<div>
<p className="text-3xl font-bold text-slate-800">{survey.name}</p>
<span className="text-base font-extralight text-slate-600">{product.name}</span>
</div>
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && <LinkSurveyShareButton survey={survey} />}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<Select
onValueChange={(value) => {
triggerSurveyMutate({ status: value })
.then(() => {
toast.success(
value === "inProgress"
? "Survey live"
: value === "paused"
? "Survey paused"
: value === "completed"
? "Survey completed"
: ""
);
router.refresh();
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
}}>
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<span className="ml-2 text-sm text-slate-700">
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}
{survey.status === "completed" && "Completed"}
{survey.status === "archived" && "Archived"}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem className="group font-normal hover:text-slate-900" value="inProgress">
<PlayCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
In-progress
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
<PauseCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Paused
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
<CheckCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Completed
</SelectItem>
</SelectContent>
</Select>
) : null}
<Button
variant="darkCTA"
className="h-full w-full px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
Edit
<PencilSquareIcon className="ml-1 h-4" />
</Button>
</div>
<div className="block sm:hidden">
<DropdownMenu>
<DropdownMenuTrigger>
<Button size="sm" variant="secondary" className="h-full w-full rounded-md p-2">
<EllipsisHorizontalIcon className="h-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-2">
{survey.type === "link" && (
<>
<LinkSurveyShareButton className="flex w-full justify-center p-1" survey={survey} />
<DropdownMenuSeparator />
</>
)}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<span className="ml-1 text-sm text-slate-700">
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}
{survey.status === "completed" && "Completed"}
{survey.status === "archived" && "Archived"}
</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={survey.status}
onValueChange={(value) => {
triggerSurveyMutate({ status: value })
.then(() => {
toast.success(
value === "inProgress"
? "Survey live"
: value === "paused"
? "Survey paused"
: value === "completed"
? "Survey completed"
: ""
);
router.refresh();
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
}}>
<DropdownMenuRadioItem
value="inProgress"
className="cursor-pointer break-all text-slate-600">
In-progress
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem
value="paused"
className="cursor-pointer break-all text-slate-600">
Paused
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem
value="completed"
className="cursor-pointer break-all text-slate-600">
Completed
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
</>
) : null}
<Button
variant="darkCTA"
size="sm"
className="flex h-full w-full justify-center px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
Edit
<PencilSquareIcon className="ml-1 h-4" />
</Button>
</DropdownMenuContent>
</DropdownMenu>
</div>
<SuccessMessage environmentId={environmentId} survey={survey} />
</div>
);
};
export default SummaryHeader;
@@ -1,27 +0,0 @@
import SecondNavbar from "@/components/environments/SecondNavBar";
import { PresentationChartLineIcon, InboxStackIcon } from "@heroicons/react/24/solid";
interface SurveyResultsTabProps {
activeId: string;
environmentId: string;
surveyId: string;
}
export default function SurveyResultsTab({ activeId, environmentId, surveyId }: SurveyResultsTabProps) {
const tabs = [
{
id: "summary",
label: "Summary",
icon: <PresentationChartLineIcon />,
href: `/environments/${environmentId}/surveys/${surveyId}/summary`,
},
{
id: "responses",
label: "Responses",
icon: <InboxStackIcon />,
href: `/environments/${environmentId}/surveys/${surveyId}/responses`,
},
];
return <SecondNavbar tabs={tabs} activeId={activeId} surveyId={surveyId} environmentId={environmentId} />;
}
@@ -36,17 +36,17 @@ export default function MultipleChoiceMultiForm({
const shuffleOptionsTypes = {
none: {
id: "none",
label: "None (Keep choices in current order)",
label: "Keep current order",
show: true,
},
all: {
id: "all",
label: "All (Randomize all choices)",
label: "Randomize all",
show: question.choices.filter((c) => c.id === "other").length === 0,
},
exceptLast: {
id: "exceptLast",
label: "Except Last (Keep last choice and randomize other choices)",
label: "Randomize all except last option",
show: true,
},
};
@@ -191,12 +191,14 @@ export default function MultipleChoiceMultiForm({
onClick={() => deleteChoice(choiceIdx)}
/>
)}
{choice.id !== "other" && (
<PlusIcon
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => addChoice(choiceIdx)}
/>
)}
<div className="ml-2 h-4 w-4">
{choice.id !== "other" && (
<PlusIcon
className="h-full w-full cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => addChoice(choiceIdx)}
/>
)}
</div>
</div>
))}
<div className="flex items-center justify-between space-x-2">
@@ -207,15 +209,13 @@ export default function MultipleChoiceMultiForm({
)}
<div className="flex flex-1 items-center justify-end gap-2">
<p className="text-sm text-slate-700">Ordering</p>
<Select
defaultValue={question.shuffleOption}
value={question.shuffleOption}
onValueChange={(e) => {
updateQuestion(questionIdx, { shuffleOption: e });
}}>
<SelectTrigger className="w-fit overflow-hidden ">
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-semibold text-slate-600">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>
@@ -36,17 +36,17 @@ export default function MultipleChoiceSingleForm({
const shuffleOptionsTypes = {
none: {
id: "none",
label: "None (Keep choices in current order)",
label: "Keep current order",
show: true,
},
all: {
id: "all",
label: "All (Randomize all choices)",
label: "Randomize all",
show: question.choices.filter((c) => c.id === "other").length === 0,
},
exceptLast: {
id: "exceptLast",
label: "Except Last (Keep last choice and randomize other choices)",
label: "Randomize all except last option",
show: true,
},
};
@@ -191,12 +191,14 @@ export default function MultipleChoiceSingleForm({
onClick={() => deleteChoice(choiceIdx)}
/>
)}
{choice.id !== "other" && (
<PlusIcon
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => addChoice(choiceIdx)}
/>
)}
<div className="ml-2 h-4 w-4">
{choice.id !== "other" && (
<PlusIcon
className="h-full w-full cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => addChoice(choiceIdx)}
/>
)}
</div>
</div>
))}
<div className="flex items-center justify-between space-x-2">
@@ -207,15 +209,13 @@ export default function MultipleChoiceSingleForm({
)}
<div className="flex flex-1 items-center justify-end gap-2">
<p className="text-sm text-slate-700">Ordering</p>
<Select
defaultValue={question.shuffleOption}
value={question.shuffleOption}
onValueChange={(e) => {
updateQuestion(questionIdx, { shuffleOption: e });
}}>
<SelectTrigger className="w-fit overflow-hidden ">
<SelectTrigger className="w-fit space-x-2 overflow-hidden border-0 font-semibold text-slate-600">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>
@@ -106,7 +106,7 @@ export default function RecontactOptionsCard({
</div>
{localSurvey.type === "link" && (
<div className="flex w-full items-center justify-end pr-2">
<Badge size="normal" text="In-app survey settings" type="warning" />
<Badge size="normal" text="In-app survey settings" type="gray" />
</div>
)}
</div>
@@ -138,7 +138,7 @@ export default function SurveyMenuBar({
className="w-72 border-white hover:border-slate-200 "
/>
</div>
{localSurvey?.responseRate && (
{!!localSurvey?.responseRate && (
<div className="mx-auto flex items-center rounded-full border border-amber-200 bg-amber-100 p-2 text-sm text-amber-700 shadow-sm">
<ExclamationTriangleIcon className="mr-2 h-5 w-5 text-amber-400" />
This survey received responses. To keep the data consistent, make changes with caution.
@@ -100,10 +100,6 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
return <div>Error</div>;
}
/* if (localSurvey.type === "link") {
return null;
} */
return (
<>
<Collapsible.Root
@@ -153,7 +149,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
</div>
{localSurvey.type === "link" && (
<div className="flex w-full items-center justify-end pr-2">
<Badge size="normal" text="In-app survey settings" type="warning" />
<Badge size="normal" text="In-app survey settings" type="gray" />
</div>
)}
</div>
@@ -85,7 +85,11 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
<>
<Collapsible.Root
open={open}
onOpenChange={setOpen}
onOpenChange={(openState) => {
if (localSurvey.type !== "link") {
setOpen(openState);
}
}}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
@@ -109,7 +113,7 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
</div>
{localSurvey.type === "link" && (
<div className="flex w-full items-center justify-end pr-2">
<Badge size="normal" text="In-app survey settings" type="warning" />
<Badge size="normal" text="In-app survey settings" type="gray" />
</div>
)}
</div>
@@ -1,202 +0,0 @@
"use client";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { convertToCSV } from "@/lib/csvConversion";
import { generateQuestionsAndAttributes } from "@/lib/surveys/surveys";
import { getTodaysDateFormatted } from "@formbricks/lib/time";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import { createId } from "@paralleldrive/cuid2";
import { useCallback, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import SingleResponse from "./SingleResponse";
interface ResponseTimelineProps {
environmentId: string;
surveyId: string;
responses: TResponse[];
survey: TSurvey;
}
export default function ResponseTimeline({
environmentId,
surveyId,
responses,
survey,
}: ResponseTimelineProps) {
const { attributeMap, questionNames } = generateQuestionsAndAttributes(survey, responses);
const [isDownloadCSVLoading, setIsDownloadCSVLoading] = useState(false);
const matchQandA = useMemo(() => {
if (survey && responses) {
// Create a mapping of question IDs to their headlines
const questionIdToHeadline = {};
survey.questions.forEach((question) => {
questionIdToHeadline[question.id] = question.headline;
});
// Replace question IDs with question headlines in response data
const updatedResponses = responses.map((response) => {
const updatedResponse: Array<{
id: string;
question: string;
answer: string;
type: string;
scale?: "number" | "star" | "smiley";
range?: number;
}> = []; // Specify the type of updatedData
// iterate over survey questions and build the updated response
for (const question of survey.questions) {
const answer = response.data[question.id];
if (answer) {
updatedResponse.push({
id: createId(),
question: question.headline,
type: question.type,
scale: question.scale,
range: question.range,
answer: answer as string,
});
}
}
return { ...response, responses: updatedResponse };
});
const updatedResponsesWithTags = updatedResponses.map((response) => ({
...response,
tags: response.tags?.map((tag) => tag),
}));
return updatedResponsesWithTags;
}
return [];
}, [survey, responses]);
const csvFileName = useMemo(() => {
if (survey) {
const formattedDateString = getTodaysDateFormatted("_");
return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase();
}
return "my_survey_responses";
}, [survey]);
const downloadResponses = useCallback(async () => {
const csvData = matchQandA.map((response) => {
const csvResponse = {
"Response ID": response.id,
Timestamp: response.createdAt,
Finished: response.finished,
"Survey ID": response.surveyId,
"Formbricks User ID": response.person?.id ?? "",
};
// Map each question name to its corresponding answer
questionNames.forEach((questionName: string) => {
const matchingQuestion = response.responses.find((question) => question.question === questionName);
let transformedAnswer = "";
if (matchingQuestion) {
const answer = matchingQuestion.answer;
if (Array.isArray(answer)) {
transformedAnswer = answer.join("; ");
} else {
transformedAnswer = answer;
}
}
csvResponse[questionName] = matchingQuestion ? transformedAnswer : "";
});
return csvResponse;
});
// Add attribute columns to the CSV
Object.keys(attributeMap).forEach((attributeName) => {
const attributeValues = attributeMap[attributeName];
Object.keys(attributeValues).forEach((personId) => {
const value = attributeValues[personId];
const matchingResponse = csvData.find((response) => response["Formbricks User ID"] === personId);
if (matchingResponse) {
matchingResponse[attributeName] = value;
}
});
});
// Fields which will be used as column headers in the CSV
const fields = [
"Response ID",
"Timestamp",
"Finished",
"Survey ID",
"Formbricks User ID",
...Object.keys(attributeMap),
...questionNames,
];
setIsDownloadCSVLoading(true);
let response;
try {
response = await convertToCSV({
json: csvData,
fields,
fileName: csvFileName,
});
} catch (err) {
toast.error("Error downloading CSV");
setIsDownloadCSVLoading(false);
return;
}
setIsDownloadCSVLoading(false);
const blob = new Blob([response.csvResponse], { type: "text/csv;charset=utf-8;" });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${csvFileName}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
}, [attributeMap, csvFileName, matchQandA, questionNames]);
return (
<div className="space-y-4">
{responses.length === 0 ? (
<EmptySpaceFiller
type="response"
environmentId={environmentId}
noWidgetRequired={survey.type === "link"}
/>
) : (
<div>
<Button variant="darkCTA" onClick={() => downloadResponses()} loading={isDownloadCSVLoading}>
<div className="flex items-center gap-2">
<ArrowDownTrayIcon width={16} height={16} />
<span className="text-sm">Export to CSV</span>
</div>
</Button>
{matchQandA.map((updatedResponse) => {
return (
<SingleResponse
key={updatedResponse.id}
data={updatedResponse}
surveyId={surveyId}
environmentId={environmentId}
/>
);
})}
</div>
)}
</div>
);
}
@@ -1,39 +0,0 @@
export const revalidate = 0;
import ContentWrapper from "@/components/shared/ContentWrapper";
import SurveyResultsTabs from "../SurveyResultsTabs";
import ResponseTimeline from "./ResponseTimeline";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
export default async function ResponsesPage({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const { responses, survey } = await getAnalysisData(session, params.surveyId, params.environmentId);
return (
<>
<SurveyResultsTabs
activeId="responses"
environmentId={params.environmentId}
surveyId={params.surveyId}
/>
<ResponsesLimitReachedBanner
environmentId={params.environmentId}
surveyId={params.surveyId}
session={session}
/>
<ContentWrapper>
<ResponseTimeline
environmentId={params.environmentId}
surveyId={params.surveyId}
responses={responses}
survey={survey}
/>
</ContentWrapper>
</>
);
}
@@ -1,31 +0,0 @@
export const revalidate = 0;
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { getServerSession } from "next-auth";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import SurveyResultsTabs from "../SurveyResultsTabs";
import SummaryList from "./SummaryList";
import SummaryMetadata from "./SummaryMetadata";
export default async function SummaryPage({ params }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
return (
<>
<SurveyResultsTabs activeId="summary" environmentId={params.environmentId} surveyId={params.surveyId} />
<ResponsesLimitReachedBanner
environmentId={params.environmentId}
session={session}
surveyId={params.surveyId}
/>
<ContentWrapper>
<SummaryMetadata surveyId={params.surveyId} environmentId={params.environmentId} session={session} />
<SummaryList environmentId={params.environmentId} session={session} surveyId={params.surveyId} />
</ContentWrapper>
</>
);
}
@@ -1,8 +1,13 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import SurveysList from "./SurveyList";
import { Metadata } from "next";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
export const metadata: Metadata = {
title: "Your Surveys",
};
export default async function SurveysPage({ params }) {
const environmentId = params.environmentId;
const product = await getProductByEnvironmentId(environmentId);
+15
View File
@@ -34,3 +34,18 @@ input:focus {
--tw-ring-shadow: 0 0 #000 !important;
box-shadow: none;
}
@layer utilities {
@variants responsive {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
}
+6 -2
View File
@@ -1,7 +1,11 @@
import "./globals.css";
import { Metadata } from "next";
export const metadata = {
title: "Formbricks",
export const metadata: Metadata = {
title: {
template: "%s | Formbricks",
default: "Formbricks",
},
description: "Open-Source In-Product Survey Platform",
};
@@ -108,23 +108,23 @@ export default function MultipleChoiceSingleQuestion({
selectedChoice === choice.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative mb-2 flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-50 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
type="radio"
id={choice.id}
name={question.id}
value={choice.label}
className="h-4 w-4 border border-gray-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={() => {
setSelectedChoice(choice.id);
}}
checked={selectedChoice === choice.id}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
<span className="flex flex-col text-sm">
<span className="flex items-center">
<input
type="radio"
id={choice.id}
name={question.id}
value={choice.label}
className="h-4 w-4 border border-gray-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={() => setSelectedChoice(choice.id)}
checked={selectedChoice === choice.id}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "other" && (
<Input
@@ -9,9 +9,15 @@ type EmptySpaceFillerProps = {
type: "table" | "response" | "event" | "linkResponse" | "tag";
environmentId: string;
noWidgetRequired?: boolean;
emptyMessage?: string;
};
const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({ type, environmentId, noWidgetRequired }) => {
const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({
type,
environmentId,
noWidgetRequired,
emptyMessage,
}) => {
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
if (isLoadingEnvironment) return <LoadingSpinner />;
@@ -34,7 +40,7 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({ type, environmentId
</span>
</Link>
)}
{(environment.widgetSetupCompleted || noWidgetRequired) &&
{((environment.widgetSetupCompleted || noWidgetRequired) && emptyMessage) ||
"Your data will appear here as soon as you receive your first response ⏲️"}
</div>
+5 -1
View File
@@ -55,7 +55,11 @@ export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps)
<div className="grid w-full gap-x-2">
<div>
<Label>Team Name</Label>
<Input placeholder="e.g. Power Puff Girls" {...register("name", { required: true })} />
<Input
autoFocus
placeholder="e.g. Power Puff Girls"
{...register("name", { required: true })}
/>
</div>
</div>
</div>
+1 -1
View File
@@ -84,7 +84,7 @@ export const getPlan = async (req, res) => {
return apiKeyData?.environment.product.team.plan || "free";
} else {
const user = await getSessionUser(req, res);
return user ? user.plan : "free";
return user && user.teams?.length > 0 ? user.teams[0].plan : "free";
}
};
+5 -3
View File
@@ -71,16 +71,18 @@ export const withEmailTemplate = (content: string) =>
}
.button {
background: #00c4b8;
margin-top:12px;
background: #0f172a;
border-radius: 8px;
text-decoration: none !important;
color: #fff !important;
font-weight: 600;
font-weight: 500;
padding: 10px 30px;
display: inline-block;
font-size: 0.9em;
}
.button:hover {
background: #00e6ca;
background: #334155;
}
.footer {
+14 -9
View File
@@ -130,31 +130,36 @@ export const sendResponseFinishedEmail = async (
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail || env.MAIL_FROM,
html: withEmailTemplate(`<h1>Survey completed</h1>Someone just completed your survey "${survey.name}"<br/>
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
survey.name
}</strong><br/>
<hr/>
${getQuestionResponseMapping(survey, response)
.map(
(question) =>
question.answer && `<p><strong>${question.question}</strong></p><p>${question.answer}</p>`
question.answer &&
`<div style="margin-top:1em;">
<p style="margin:0px;">${question.question}</p>
<p style="font-weight: 500; margin:0px;">${question.answer}</p>
</div>`
)
.join("")}
<hr/>
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=emailnotification&utm_medium=email&utm_content=ViewResponsesCTA">View all responses</a>
<div class="tooltip">
<p class='brandcolor'><strong>Did you know? 💡</strong></p>
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
${
personEmail
? "<p>You can reply to this email to start a conversation with this user.</p>"
? "<p>Hit 'Reply' or reach out manually: ${personEmail}</p>"
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
}
</div>
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses">View response</a>
`),
});
};
+425
View File
@@ -2,6 +2,16 @@ import useSWR from "swr";
import { fetcher } from "@formbricks/lib/fetcher";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TResponse } from "@formbricks/types/v1/responses";
import {
OptionsType,
QuestionOptions,
} from "@/app/environments/[environmentId]/surveys/[surveyId]/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/environments/[environmentId]/surveys/[surveyId]/ResponseFilter";
import { QuestionType } from "@formbricks/types/questions";
import { TTag } from "@formbricks/types/v1/tags";
import { DateRange, SelectedFilterValue } from "@/app/environments/[environmentId]/ResponseFilterContext";
import { isArray } from "lodash";
import { isWithinInterval } from "date-fns";
export const useSurveys = (environmentId: string) => {
const { data, error, mutate, isLoading } = useSWR(`/api/v1/environments/${environmentId}/surveys`, fetcher);
@@ -164,3 +174,418 @@ export const generateQuestionsAndAttributes = (survey: TSurvey, responses: TResp
attributeMap,
};
};
const conditionOptions = {
openText: ["is"],
multipleChoiceSingle: ["Includes either"],
multipleChoiceMulti: ["Includes all", "Includes either"],
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
cta: ["is"],
tags: ["is"],
userAttributes: ["Equals", "Not equals"],
consent: ["is"],
};
const filterOptions = {
openText: ["Filled out", "Skipped"],
rating: ["1", "2", "3", "4", "5"],
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
cta: ["Clicked", "Dismissed"],
tags: ["Applied", "Not applied"],
consent: ["Accepted", "Dismissed"],
};
// creating an object for the attributes in key value format when key is string and value is an string array
const getPersonAttributes = (responses: TResponse[]): { [key: string]: any[] } | null => {
let attributes: { [key: string]: any[] } = {};
responses.forEach((obj) => {
const personAttributes = obj.personAttributes;
if (personAttributes && Object.keys(personAttributes).length > 0) {
for (const [key, value] of Object.entries(personAttributes)) {
if (attributes.hasOwnProperty(key)) {
if (!attributes[key].includes(value)) {
attributes[key].push(value);
}
} else {
attributes[key] = [value];
}
}
}
});
if (Object.keys(attributes).length > 0) {
return attributes;
} else {
return null;
}
};
// creating the options for the filtering to be selected there are three types questions, attributes and tags
export const generateQuestionAndFilterOptions = (
survey: TSurvey,
responses: TResponse[],
environmentTags: TTag[] | undefined
): {
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
} => {
let questionOptions: any = [];
let questionFilterOptions: any = [];
let questionsOptions: any = [];
survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
questionsOptions.push({
label: q.headline,
questionType: q.type,
type: OptionsType.QUESTIONS,
id: q.id,
});
}
});
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
if (q.type === QuestionType.MultipleChoiceMulti || q.type === QuestionType.MultipleChoiceSingle) {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
id: q.id,
});
} else {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: filterOptions[q.type],
id: q.id,
});
}
}
});
const tagsOptions = environmentTags?.map((t) => {
return { label: t.name, type: OptionsType.TAGS, id: t.id };
});
if (tagsOptions && tagsOptions?.length > 0) {
questionOptions = [...questionOptions, { header: OptionsType.TAGS, option: tagsOptions }];
environmentTags?.forEach((t) => {
questionFilterOptions.push({
type: "Tags",
filterOptions: conditionOptions.tags,
filterComboBoxOptions: filterOptions.tags,
id: t.id,
});
});
}
const attributes = getPersonAttributes(responses);
if (attributes) {
questionOptions = [
...questionOptions,
{
header: OptionsType.ATTRIBUTES,
option: Object.keys(attributes).map((a) => {
return { label: a, type: OptionsType.ATTRIBUTES, id: a };
}),
},
];
Object.keys(attributes).forEach((a) => {
questionFilterOptions.push({
type: "Attributes",
filterOptions: conditionOptions.userAttributes,
filterComboBoxOptions: attributes[a],
id: a,
});
});
}
return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
};
// get the filtered responses
export const getFilterResponses = (
responses: TResponse[],
selectedFilter: SelectedFilterValue,
survey: TSurvey,
dateRange: DateRange
) => {
// added the question on the response object to filter out the responses which has been selected
let toBeFilterResponses = responses.map((r) => {
return {
...r,
questions: survey.questions.map((q) => {
if (q.id in r.data) {
return q;
}
}),
};
});
// filtering the responses according to the value selected
selectedFilter.filter.forEach((filter) => {
if (filter.questionType?.type === "Questions") {
switch (filter.questionType?.questionType) {
case QuestionType.Consent:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === QuestionType.Consent && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
if (filter?.filterType?.filterComboBoxValue === "Accepted") {
return responseValue === "accepted";
}
if (filter?.filterType?.filterComboBoxValue === "Dismissed") {
return responseValue === "dismissed";
}
return true;
}
return false;
}
return true;
});
break;
case QuestionType.OpenText:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === QuestionType.OpenText && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
if (filter?.filterType?.filterComboBoxValue === "Filled out") {
return typeof responseValue === "string" && responseValue.trim() !== "" ? true : false;
}
if (filter?.filterType?.filterComboBoxValue === "Skipped") {
return typeof responseValue === "string" && responseValue.trim() === "" ? true : false;
}
return true;
}
return false;
}
return true;
});
break;
case QuestionType.CTA:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === QuestionType.CTA && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
if (filter?.filterType?.filterComboBoxValue === "Clicked") {
return responseValue === "clicked";
}
if (filter?.filterType?.filterComboBoxValue === "Dismissed") {
return responseValue === "dismissed";
}
return true;
}
return false;
}
return true;
});
break;
case QuestionType.MultipleChoiceMulti:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const question = response.questions.find(
(q) => q?.type === QuestionType.MultipleChoiceMulti && q?.id === filter?.questionType?.id
);
if (filter?.filterType?.filterComboBoxValue) {
if (question) {
const responseValue = response.data[question.id];
const filterValue = filter?.filterType?.filterComboBoxValue;
if (isArray(responseValue) && isArray(filterValue) && filterValue.length > 0) {
//@ts-ignore
const updatedResponseValue = question?.choices
? //@ts-ignore
matchAndUpdateArray([...question?.choices], [...responseValue])
: responseValue;
if (filter?.filterType?.filterValue === "Includes all") {
return filterValue.every((item) => updatedResponseValue.includes(item));
}
if (filter?.filterType?.filterValue === "Includes either") {
return filterValue.some((item) => updatedResponseValue.includes(item));
}
}
return true;
}
return false;
}
return true;
});
break;
case QuestionType.MultipleChoiceSingle:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === QuestionType.MultipleChoiceSingle && q?.id === filter?.questionType?.id
)?.id;
if (filter?.filterType?.filterComboBoxValue) {
if (questionID) {
const responseValue = response.data[questionID];
const filterValue = filter?.filterType?.filterComboBoxValue;
if (
filter?.filterType?.filterValue === "Includes either" &&
isArray(filterValue) &&
filterValue.length > 0 &&
typeof responseValue === "string"
) {
return filterValue.includes(responseValue);
}
return true;
}
return false;
}
return true;
});
break;
case QuestionType.NPS:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === QuestionType.NPS && q?.id === filter?.questionType?.id
)?.id;
const responseValue = questionID ? response.data[questionID] : undefined;
const filterValue =
filter?.filterType?.filterComboBoxValue &&
typeof filter?.filterType?.filterComboBoxValue === "string" &&
parseInt(filter?.filterType?.filterComboBoxValue);
if (filter?.filterType?.filterValue === "Submitted") {
return responseValue ? true : false;
}
if (filter?.filterType?.filterValue === "Skipped") {
return responseValue === "dismissed";
}
if (!questionID && typeof filterValue === "number") {
return false;
}
if (questionID && typeof responseValue === "number" && typeof filterValue === "number") {
if (filter?.filterType?.filterValue === "Is equal to") {
return responseValue === filterValue;
}
if (filter?.filterType?.filterValue === "Is more than") {
return responseValue > filterValue;
}
if (filter?.filterType?.filterValue === "Is less than") {
return responseValue < filterValue;
}
}
return true;
});
break;
case QuestionType.Rating:
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const questionID = response.questions.find(
(q) => q?.type === QuestionType.Rating && q?.id === filter?.questionType?.id
)?.id;
const responseValue = questionID ? response.data[questionID] : undefined;
const filterValue =
filter?.filterType?.filterComboBoxValue &&
typeof filter?.filterType?.filterComboBoxValue === "string" &&
parseInt(filter?.filterType?.filterComboBoxValue);
if (filter?.filterType?.filterValue === "Submitted") {
return responseValue ? true : false;
}
if (filter?.filterType?.filterValue === "Skipped") {
return responseValue === "dismissed";
}
if (!questionID && typeof filterValue === "number") {
return false;
}
if (questionID && typeof responseValue === "number" && typeof filterValue === "number") {
if (filter?.filterType?.filterValue === "Is equal to") {
return responseValue === filterValue;
}
if (filter?.filterType?.filterValue === "Is more than") {
return responseValue > filterValue;
}
if (filter?.filterType?.filterValue === "Is less than") {
return responseValue < filterValue;
}
}
return true;
});
break;
}
}
if (filter.questionType?.type === "Tags") {
toBeFilterResponses = toBeFilterResponses.filter((response) => {
const tagNames = response.tags.map((tag) => tag.name);
if (filter?.filterType?.filterComboBoxValue) {
if (filter?.filterType?.filterComboBoxValue === "Applied") {
if (filter?.questionType?.label) return tagNames.includes(filter.questionType.label);
}
if (filter?.filterType?.filterComboBoxValue === "Not applied") {
if (filter?.questionType?.label) return !tagNames.includes(filter?.questionType?.label);
}
}
return true;
});
}
if (filter.questionType?.type === "Attributes") {
toBeFilterResponses = toBeFilterResponses.filter((response) => {
if (filter?.questionType?.label && filter?.filterType?.filterComboBoxValue) {
const attributes =
response.personAttributes && Object.keys(response.personAttributes).length > 0
? response.personAttributes
: null;
if (attributes && attributes.hasOwnProperty(filter?.questionType?.label)) {
if (filter?.filterType?.filterValue === "Equals") {
return attributes[filter?.questionType?.label] === filter?.filterType?.filterComboBoxValue;
}
if (filter?.filterType?.filterValue === "Not equals") {
return attributes[filter?.questionType?.label] !== filter?.filterType?.filterComboBoxValue;
}
} else {
return false;
}
}
return true;
});
}
});
// filtering for the responses which is completed
toBeFilterResponses = toBeFilterResponses.filter((r) => (selectedFilter.onlyComplete ? r.finished : true));
// filtering the data according to the dates
if (dateRange?.from !== undefined && dateRange?.to !== undefined) {
// @ts-ignore
toBeFilterResponses = toBeFilterResponses.filter((r) =>
isWithinInterval(r.createdAt, { start: dateRange.from!, end: dateRange.to! })
);
}
return toBeFilterResponses;
};
// get the today date with full hours
export const getTodayDate = (): Date => {
const date = new Date();
date.setHours(23, 59, 59, 999);
return date;
};
// function update the response value of question multiChoiceSelect
function matchAndUpdateArray(choices: any, responseValue: string[]) {
const choicesArray = choices.map((obj) => obj.label);
responseValue.forEach((element, index) => {
// Check if the element is present in the choices
if (choicesArray.includes(element)) {
return; // No changes needed, move to the next iteration
}
// Check if the choices has 'Other'
if (choicesArray.includes("Other") && !choicesArray.includes(element)) {
responseValue[index] = "Other"; // Update the element to 'Other'
}
});
return responseValue;
}
+5 -5
View File
@@ -27,14 +27,14 @@
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@t3-oss/env-nextjs": "^0.6.0",
"bcryptjs": "^2.4.3",
"eslint-config-next": "^13.4.8",
"jsonwebtoken": "^9.0.0",
"eslint-config-next": "^13.4.9",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
"lucide-react": "^0.258.0",
"next": "13.4.8",
"lucide-react": "^0.260.0",
"next": "13.4.9",
"next-auth": "^4.22.1",
"nodemailer": "^6.9.3",
"posthog-js": "^1.68.5",
"posthog-js": "^1.71.0",
"prismjs": "^1.29.0",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
@@ -123,23 +123,36 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
// DELETE
else if (req.method === "DELETE") {
// get teamId from product
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
product: {
select: {
id: true,
teamId: true,
},
},
},
});
if (!environment) {
res.status(404).json({ error: "Environment not found" });
return;
}
const teamId = environment?.product.teamId;
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: currentUser.id,
teamId: currentUser.teamId,
teamId: teamId,
},
},
});
if (membership?.role !== "admin" && membership?.role !== "owner") {
return res.status(403).json({ message: "You are not allowed to delete products." });
}
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
productId: true,
},
});
const productId = environment?.product.id;
if (environment === null) {
return res.status(404).json({ message: "This environment doesn't exist" });
@@ -147,7 +160,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
// Delete the product with
const prismaRes = await prisma.product.delete({
where: { id: environment.productId },
where: { id: productId },
});
return res.json(prismaRes);
@@ -38,6 +38,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
id: true,
name: true,
stripeCustomerId: true,
plan: true,
},
});
+45
View File
@@ -0,0 +1,45 @@
# Formbricks Quickstart Using Docker
Follow the instructions below to quickly get Formbricks running on your system with Docker. This guide is designed for most users who want a straightforward setup process.
1. **Create a New Directory for Formbricks**
Open a terminal and create a new directory for Formbricks, then navigate into this new directory:
\```bash
mkdir formbricks-quickstart && cd formbricks-quickstart
\```
2. **Download the Docker-Compose File**
Download the docker-compose file directly from the Formbricks repository:
\```bash
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/docker/main/docker-compose.yml
\```
3. **Generate NextAuth Secret**
Next, you need to generate a NextAuth secret. This will be used for session signing and encryption. The `sed` command below generates a random string using `openssl`, then replaces the `NEXTAUTH_SECRET:` placeholder in the `docker-compose.yml` file with this generated secret:
\```bash
sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.\*/NEXTAUTH_SECRET: $(openssl rand -base64 32)/" docker-compose.yml
\```
4. **Start the Docker Setup**
You're now ready to start the Formbricks Docker setup. The following command will start Formbricks together with a postgreSQL database using Docker Compose:
\```bash
docker compose up -d
\```
The `-d` flag will run the containers in detached mode, meaning they'll run in the background.
5. **Visit Formbricks in Your Browser**
After starting the Docker setup, visit http://localhost:3000 in your browser to interact with the Formbricks application. The first time you access this page, you'll be greeted by a setup wizard. Follow the prompts to define your first user and get started.
Enjoy using Formbricks!
Note: For detailed documentation of local setup, take a look at our [self hosting docs](https://formbricks.com/docs/self-hosting/deployment)
+45
View File
@@ -0,0 +1,45 @@
version: "3.3"
x-environment: &environment
environment:
########################################################################
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
########################################################################
# PostgreSQL DB for Formbricks to connect to
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
# PRISMA_GENERATE_DATAPROXY=true
PRISMA_GENERATE_DATAPROXY:
# NextJS Auth
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
# You can use: `openssl rand -base64 32` to generate one
NEXTAUTH_SECRET:
# Set this to your public-facing URL, e.g., https://example.com
# You do not need the NEXTAUTH_URL environment variable in Vercel.
NEXTAUTH_URL: http://localhost:3000
services:
postgres:
restart: always
image: postgres:15-alpine
volumes:
- postgres:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
formbricks:
restart: always
image: formbricks/formbricks:latest
depends_on:
- postgres
ports:
- 3000:3000
<<: *environment
volumes:
postgres:
driver: local
+1 -1
View File
@@ -23,7 +23,7 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"generate": "turbo run generate",
"lint": "turbo run lint",
"release": "turbo run build --filter=react^... && changeset publish",
"release": "turbo run build --filter=js... && changeset publish",
"test": "turbo run test"
},
"devDependencies": {
+3 -3
View File
@@ -19,7 +19,7 @@
"db:setup": "pnpm db:up && pnpm db:migrate:dev",
"db:start": "pnpm db:setup",
"dev": "tsup --watch",
"go": "pnpm db:setup",
"go": "pnpm db:setup && tsup",
"format": "prisma format",
"generate": "prisma generate",
"lint": "eslint ./src --fix",
@@ -27,14 +27,14 @@
"predev": "npm run generate"
},
"dependencies": {
"@prisma/client": "^4.16.2",
"@prisma/client": "^5.0.0",
"dotenv-cli": "^7.2.1"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"prisma": "^4.16.2",
"prisma": "^5.0.0",
"prisma-dbml-generator": "^0.10.0",
"prisma-json-types-generator": "^2.5.0",
"tsup": "^7.1.0",
+1 -2
View File
@@ -7,8 +7,7 @@ datasource db {
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["extendedWhereUnique"]
provider = "prisma-client-js"
//provider = "prisma-dbml-generator"
}
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@formbricks/database": "workspace:*",
"next": "13.4.8",
"stripe": "^12.6.0"
"next": "13.4.9",
"stripe": "^12.12.0"
}
}
@@ -8,8 +8,8 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"eslint": "^8.41.0",
"eslint-config-next": "^13.4.4",
"eslint": "^8.44.0",
"eslint-config-next": "^13.4.9",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "7.32.2",
"eslint-config-turbo": "latest"
+11 -11
View File
@@ -44,28 +44,28 @@
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@babel/core": "^7.22.5",
"@babel/preset-env": "^7.22.5",
"@babel/core": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"@babel/preset-typescript": "^7.22.5",
"@formbricks/api": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/jest": "^29.5.2",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"autoprefixer": "^10.4.14",
"babel-jest": "^29.5.0",
"babel-jest": "^29.6.1",
"cross-env": "^7.0.3",
"eslint-config-formbricks": "workspace:*",
"eslint-config-preact": "^1.3.0",
"isomorphic-fetch": "^3.0.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jest": "^29.6.1",
"jest-environment-jsdom": "^29.6.1",
"jest-fetch-mock": "^3.0.3",
"jest-preset-preact": "^4.0.5",
"microbundle": "^0.15.1",
"preact": "10.15.1",
"preact-cli": "^3.4.5",
"preact-render-to-string": "^6.1.0",
"preact": "10.16.0",
"preact-cli": "^3.4.6",
"preact-render-to-string": "^6.2.0",
"regenerator-runtime": "^0.13.11"
},
"jest": {
+2 -2
View File
@@ -115,9 +115,9 @@ export const initialize = async (
// continue for now - next sync will check complete state
}
} else {
logger.debug("No valid session found. Creating new config.");
logger.debug("No valid configuration found. Creating new config.");
// we need new config
config.update({ environmentId: c.environmentId, apiHost: c.apiHost });
config.update({ environmentId: c.environmentId, apiHost: c.apiHost, state: undefined });
logger.debug("Syncing.");
const syncResult = await sync();

Some files were not shown because too many files have changed in this diff Show More