Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1089-formulate-faqs-and-create-page
24
.env.docker
@@ -7,7 +7,7 @@
|
||||
# BASICS #
|
||||
############
|
||||
|
||||
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
WEBAPP_URL=http://localhost:3000
|
||||
|
||||
##############
|
||||
# DATABASE #
|
||||
@@ -63,25 +63,25 @@ NEXTAUTH_URL=http://localhost:3000
|
||||
#####################
|
||||
|
||||
# Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
|
||||
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1
|
||||
EMAIL_VERIFICATION_DISABLED=1
|
||||
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1
|
||||
PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Signup. Disable the ability for new users to create an account.
|
||||
# NEXT_PUBLIC_SIGNUP_DISABLED=1
|
||||
# SIGNUP_DISABLED=1
|
||||
|
||||
# Team Invite. Disable the ability for invited users to create an account.
|
||||
# NEXT_PUBLIC_INVITE_DISABLED=1
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
##########
|
||||
# Other #
|
||||
##########
|
||||
|
||||
# Display privacy policy, imprint and terms of service links in the footer of signup & public pages.
|
||||
NEXT_PUBLIC_PRIVACY_URL=
|
||||
NEXT_PUBLIC_TERMS_URL=
|
||||
NEXT_PUBLIC_IMPRINT_URL=
|
||||
PRIVACY_URL=
|
||||
TERMS_URL=
|
||||
IMPRINT_URL=
|
||||
|
||||
# Disable Sentry warning
|
||||
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
|
||||
@@ -90,14 +90,18 @@ SENTRY_IGNORE_API_RESOLUTION_ERROR=1
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Configure Github Login
|
||||
NEXT_PUBLIC_GITHUB_AUTH_ENABLED=0
|
||||
GITHUB_AUTH_ENABLED=0
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
|
||||
# Configure Google Login
|
||||
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=0
|
||||
GOOGLE_AUTH_ENABLED=0
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Cron Secret
|
||||
CRON_SECRET=
|
||||
|
||||
# Encryption key
|
||||
# You can use: `openssl rand -base64 16` to generate one
|
||||
FORMBRICKS_ENCRYPTION_KEY=
|
||||
30
.env.example
@@ -8,7 +8,7 @@
|
||||
# BASICS #
|
||||
############
|
||||
|
||||
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
WEBAPP_URL=http://localhost:3000
|
||||
|
||||
##############
|
||||
# DATABASE #
|
||||
@@ -20,6 +20,7 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
|
||||
# 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
|
||||
# i dont think we use it so ask Matti and remove it
|
||||
PRISMA_GENERATE_DATAPROXY=
|
||||
|
||||
###############
|
||||
@@ -34,9 +35,6 @@ NEXTAUTH_SECRET=RANDOM_STRING
|
||||
# You do not need the NEXTAUTH_URL environment variable in Vercel.
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# If you encounter NEXT_AUTH URL problems this should always be localhost:3000 (or whatever port your app is running on)
|
||||
# NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||
|
||||
################
|
||||
# MAIL SETUP #
|
||||
################
|
||||
@@ -64,33 +62,33 @@ SMTP_PASSWORD=smtpPassword
|
||||
#####################
|
||||
|
||||
# Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
|
||||
# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1
|
||||
# EMAIL_VERIFICATION_DISABLED=1
|
||||
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1
|
||||
# PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Signup. Disable the ability for new users to create an account.
|
||||
# NEXT_PUBLIC_SIGNUP_DISABLED=1
|
||||
# SIGNUP_DISABLED=1
|
||||
|
||||
# Team Invite. Disable the ability for invited users to create an account.
|
||||
# NEXT_PUBLIC_INVITE_DISABLED=1
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
##########
|
||||
# Other #
|
||||
##########
|
||||
|
||||
# Display privacy policy, imprint and terms of service links in the footer of signup & public pages.
|
||||
NEXT_PUBLIC_PRIVACY_URL=
|
||||
NEXT_PUBLIC_TERMS_URL=
|
||||
NEXT_PUBLIC_IMPRINT_URL=
|
||||
PRIVACY_URL=
|
||||
TERMS_URL=
|
||||
IMPRINT_URL=
|
||||
|
||||
# Configure Github Login
|
||||
NEXT_PUBLIC_GITHUB_AUTH_ENABLED=0
|
||||
GITHUB_AUTH_ENABLED=0
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
|
||||
# Configure Google Login
|
||||
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=0
|
||||
GOOGLE_AUTH_ENABLED=0
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
@@ -106,4 +104,8 @@ NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
||||
|
||||
# Cron Secret
|
||||
CRON_SECRET=
|
||||
*/
|
||||
*/
|
||||
|
||||
# Encryption key
|
||||
# You can use: `openssl rand -base64 16` to generate one
|
||||
FORMBRICKS_ENCRYPTION_KEY=
|
||||
79
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -4,42 +4,45 @@ title: "[FEATURE]"
|
||||
labels: enhancement
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternate-solution-description
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
id: formbricks-info
|
||||
attributes:
|
||||
value: |
|
||||
### How we code at Formbricks 🤓
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternate-solution-description
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
id: formbricks-info
|
||||
attributes:
|
||||
value: |
|
||||
### How we code at Formbricks 🤓
|
||||
|
||||
- Everything is type-safe
|
||||
- All UI components are in the package `formbricks/ui`
|
||||
- Run `pnpm dev` to find a demo app to test in-app surveys at `localhost:3002`
|
||||
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
|
||||
context before you write your prompt.
|
||||
- Follow Best Practices lined out in our [Contributor Docs](https://formbricks.com/docs/contributing/how-we-code)
|
||||
- First time: Please read our [introductory blog post](https://formbricks.com/blog/join-the-formtribe)
|
||||
- All UI components are in the package `formbricks/ui`
|
||||
- Run `pnpm go` to find a demo app to test in-app surveys at `localhost:3002`
|
||||
- Everything is type-safe
|
||||
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
|
||||
context before you write your prompt.
|
||||
- Anything unclear? [Ask in Discord](https://formbricks.com/discord)
|
||||
|
||||
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -31,9 +31,10 @@ Fixes # (issue)
|
||||
|
||||
<!-- We're starting to get more and more contributions. Please help us making this efficient for all of us and go through this checklist. Please tick off what you did -->
|
||||
|
||||
- [ ] Added a screen recording or screenshots to this PR
|
||||
### Required
|
||||
|
||||
- [ ] Filled out the "How to test" section in this PR
|
||||
- [ ] Read the [contributing guide](https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md)
|
||||
- [ ] Read [How we Code at Formbricks](<[https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md](https://formbricks.com/docs/contributing/how-we-code)>)
|
||||
- [ ] Self-reviewed my own code
|
||||
- [ ] Commented on my code in hard-to-understand bits
|
||||
- [ ] Ran `pnpm build`
|
||||
@@ -41,4 +42,8 @@ Fixes # (issue)
|
||||
- [ ] Removed all `console.logs`
|
||||
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
|
||||
- [ ] My changes don't cause any responsiveness issues
|
||||
|
||||
### Appreciated
|
||||
|
||||
- [ ] If a UI change was made: Added a screen recording or screenshots to this PR
|
||||
- [ ] Updated the Formbricks Docs if changes were necessary
|
||||
|
||||
@@ -32,7 +32,7 @@ tasks:
|
||||
command: |
|
||||
gp sync-await init &&
|
||||
cp .env.example .env &&
|
||||
sed -i -r "s#^(NEXT_PUBLIC_WEBAPP_URL=).*#\1 $(gp url 3000)#" .env &&
|
||||
sed -i -r "s#^(WEBAPP_URL=).*#\1 $(gp url 3000)#" .env &&
|
||||
sed -i -r "s#^(NEXTAUTH_URL=).*#\1 $(gp url 3000)#" .env &&
|
||||
turbo --filter "@formbricks/web" go
|
||||
|
||||
|
||||
15
apps/formbricks-com/app/docs/contributing/gitpod/page.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export const meta = {
|
||||
title: "Gitpod Setup",
|
||||
description:
|
||||
"With one click, you can setup the Formbricks developer environment in your browser using Gitpod",
|
||||
};
|
||||
|
||||
### One Click Setup
|
||||
|
||||
1. Click the button below to open this project in Gitpod.
|
||||
|
||||
2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed.
|
||||
|
||||
[](https://gitpod.io/#https://github.com/formbricks/formbricks)
|
||||
|
After Width: | Height: | Size: 81 KiB |
128
apps/formbricks-com/app/docs/contributing/how-we-code/page.mdx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Image from "next/image";
|
||||
import CorsHandling from "./cors-handling-in-api.webp";
|
||||
|
||||
export const meta = {
|
||||
title: "Formbricks Code Contribution Guide: Best Practices and Standards",
|
||||
description:
|
||||
"Effortlessly Navigate Your Contribution Journey with Formbricks' Coding Guidelines and PR Review Process",
|
||||
};
|
||||
|
||||
#### Contributing
|
||||
|
||||
# How we Code at Formbricks
|
||||
|
||||
Thank you for choosing to contribute to Formbricks. Before you start, please familiarize yourself with our coding standards and best practices, as these are key criteria for pull request reviews. Your contributions are greatly valued, and if you have any questions about these guidelines, please don't hesitate to ask.
|
||||
|
||||
**Table of content**
|
||||
|
||||
- Use Typescript types throughout the application
|
||||
- Always prioritise Server components
|
||||
- Fetch data only in server components
|
||||
- Use Server-Action for mutations
|
||||
- Use service abstraction instead of direct database calls
|
||||
- Handle authentication and CORS in management APIs
|
||||
- Always Document API changes
|
||||
- Constants should be in the packages folder
|
||||
- Types should be in the packages folder
|
||||
- How we handle Pull Requests
|
||||
- Read environment variables from `.env.mjs`
|
||||
|
||||
---
|
||||
|
||||
## Use Typescript types throughout the application
|
||||
|
||||
The entire codebase is written in TypeScript, and it is crucial that every new piece of code is thoroughly and accurately typed. Instead of resorting to using the `any` type for variables, please ensure that you explicitly specify the appropriate type.
|
||||
|
||||
## Always prioritise Server components
|
||||
|
||||
When it comes to prioritizing the development of our components, our main focus is on building them as server components. This ensures that they are optimized for server-side rendering and can handle any necessary interactivity seamlessly. However, in cases where a component requires client-side interactivity, we are able to adapt it into a client component. If you would like to learn more about the advantages and benefits of server components, we highly recommend reading the comprehensive documentation provided by Next.js, which can be accessed [here](https://nextjs.org/docs/app/building-your-application/rendering/server-components).
|
||||
|
||||
## Fetch data only in server components
|
||||
|
||||
In order to ensure that both data fetching and rendering take place on the server, it is expected that actions to fetch data from the server will be performed within server components. This approach is prioritized as discussed in the previous point, which provides further details on the benefits and importance of server components.
|
||||
|
||||
**Note**: Data fetching is done in the `page.tsx` of the route or the corresponding server component that needs this data.
|
||||
|
||||
## Use Server-Action for mutations
|
||||
|
||||
Server actions are used to perform server actions in client components. For example, a button click (client-side) that should change something in the database. Server actions should be placed in an `actions.ts` file within the specific route to maintain code organization and facilitate efficient development.
|
||||
|
||||
## Use service abstraction instead of direct database calls
|
||||
|
||||
We utilize [prisma](https://www.prisma.io/) as our Object-Relational Mapping (ORM) tool to interact with the database. This implies that when you need to fetch or modify data in the database, you will be utilizing prisma. All prisma calls should be written in the services folder `packages/lib/services`, and before creating a new service, please ensure that one does not already exist.
|
||||
|
||||
## Handle authentication and CORS in management APIs
|
||||
|
||||
We have two APIs: Management API and Client API.
|
||||
|
||||
The public endpoints of the Client API are used by the link survey and `formbricks-js` to send responses and displays to formbricks. Client APIs can be found in `apps/web/app/api/v1/client`
|
||||
|
||||
The Management API offers the same functionality as the management frontend and can be used to create surveys, view responses or change account settings. The endpoints require an api key that the user can obtain in the management frontend. Management APIs can be found in `apps/web/app/api/v1/management`.
|
||||
|
||||
Please keep the following in mind:
|
||||
|
||||
- When dealing with Management APIs always make sure to require authentication via API keys and a sufficient authorization check.
|
||||
- Make sure to handle CORS requests in any new Client API endpoint you create as these are called from the browser in link surveys or `formbricks-js`. Example below:
|
||||
|
||||
<Image
|
||||
src={CorsHandling}
|
||||
alt="Cors handling within an API"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Always Document API changes
|
||||
|
||||
It is imperative that any and all modifications or updates made to the client API are thoroughly and comprehensively documented. This documentation should provide clear and detailed information about the nature of the changes, their impact on existing functionality, and any new features or improvements introduced. This practice not only ensures transparency and accountability but also aids developers, both internal and external, in understanding and effectively utilizing the API, ultimately fostering a more robust and user-friendly development ecosystem.
|
||||
|
||||
## Constants should be in the packages folder
|
||||
|
||||
You should store constants in `packages/lib/constants`
|
||||
|
||||
## Types should be in the packages folder
|
||||
|
||||
You should store type in `packages/types/v1`
|
||||
|
||||
## Read environment variables from `.env.mjs`
|
||||
|
||||
Environment variables (`process.env`) shouldn’t be accessed directly but be added in the `.env.mjs` and should be accessed from here. This practice helps us ensure that the variables are typesafe.
|
||||
|
||||
## How we handle Pull Requests
|
||||
|
||||
We have a number of requirements for PRs to ensure they are as easy to review as possible and to ensure that they are up to standard with the code.
|
||||
|
||||
### Code change
|
||||
|
||||
When submitting a pull request, it is important to avoid combining multiple changes or issues into a single PR. By keeping each PR focused on a specific change or issue, it becomes easier and faster to review. Additionally, separating changes into individual PRs makes it easier to test each change independently. This allows for more efficient and thorough testing, ensuring that each change is properly validated before merging.
|
||||
|
||||
### Title & Content
|
||||
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). You should provide a short and concise title. Don’t put something generic (e.g. bug fixes), and instead mention more specifically what your PR achieves, for instance “fix: dropdown not expanding on survey page”.
|
||||
|
||||
For the PR description, you should go into much greater detail about what your PR adds or fixes. Firstly, the description should include a link to any relevant issues or discussions surrounding the feature or bug that your PR addresses.
|
||||
|
||||
### Feature PRs
|
||||
|
||||
Give a functional overview of how your feature works, including how the user can use the feature. Then, share any technical details in an overview of how the PR works (e.g. “Once the user enters their password, the password is hashed using BCrypt and stored in the Users database field”).
|
||||
|
||||
### Bug Fix PRs
|
||||
|
||||
Give an overview of how your PR fixes the bug both as a high-level overview and a technical explanation of what caused the issue and how your PR resolves this.
|
||||
|
||||
Add a short video or screenshots of what your PR achieves. Loom is a great way of sharing short videos however you can upload your videos directly to the PR description.
|
||||
|
||||
### Code Quality & Styling
|
||||
|
||||
All submitted code must match our **[code styling](https://www.notion.so/Code-Styling-65ddc5dd2deb4b28a9876f1f7cc89ca9?pvs=21)** standards. We will reject pull requests that differ significantly from our standardised code styles.
|
||||
|
||||
All code is automatically checked by Github actions, and will notify you if there are any issues with the code that you submit. We require that code passes these quality checks before merging.
|
||||
|
||||
### PR review process
|
||||
|
||||
At the moment Matti is responsible for approving and merging pull requests, so please make sure to request his review when opening the PR and make the changes he requests in order to merge the PR.
|
||||
|
||||
### Making a Pull Request
|
||||
|
||||
- Be sure to **[check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork)** while creating your PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add **`refs #XXX`** or **`fixes #XXX`** to the PR description. Replacing **`XXX`** with the respective issue number. See more about **[Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)** .
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
@@ -1,6 +1,7 @@
|
||||
export const meta = {
|
||||
title: "Formbricks Open Source Contribution Guide: How to Enhance yourself and Contribute to Formbricks",
|
||||
description: "Join the Formbricks community and learn how to effectively contribute. From raising issues and feature requests to creating PRs, discover the best practices and communicate with our responsive team on Discord",
|
||||
description:
|
||||
"Join the Formbricks community and learn how to effectively contribute. From raising issues and feature requests to creating PRs, discover the best practices and communicate with our responsive team on Discord",
|
||||
};
|
||||
|
||||
#### Contributing
|
||||
@@ -36,3 +37,11 @@ We are currently working on having a clear [Roadmap](https://github.com/formbric
|
||||
But you can also pick a feature that is not already on the roadmap if you think it creates a positive impact for Formbricks.
|
||||
|
||||
If you are at all unsure, just raise it as an enhancement issue first and tell us that you like to work on it, and we'll very quickly respond.
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
To be able to keep working on Formbricks over the coming years, we need to collect a CLA from all relevant contributors.
|
||||
|
||||
Please note that we can only get your contribution merged when we have a CLA signed by you.
|
||||
|
||||
To access the CLA form, please click [here](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GettingStarted } from "@/components/docs/GettingStarted";
|
||||
import BestPractices from "@/components/docs/BestPractices";
|
||||
import { HeroPattern } from "@/components/docs/HeroPattern";
|
||||
import { Button } from "@/components/docs/Button";
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Ensure `docker` & `docker compose` are installed on your server/system. Both are
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
2. **Modify the `.env.docker` file as required by your setup.** <br/> 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. 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. **Modify the `.env.docker` file as required by your setup.** <br/> 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. If you configured your email credentials, you can also comment the following lines to enable email verification (`# EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# PASSWORD_RESET_DISABLED=1`)
|
||||
|
||||
<Note>
|
||||
## Editing a NEXT_PUBLIC_* variable?
|
||||
@@ -50,67 +50,64 @@ Ensure `docker` & `docker compose` are installed on your server/system. Both are
|
||||
|
||||
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.
|
||||
|
||||
### Important Run-time Variables
|
||||
## Important Run-time Variables
|
||||
|
||||
These variables must also be provided at runtime.
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | --- |
|
||||
| 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` |
|
||||
| PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | |
|
||||
| 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` |
|
||||
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to `1` else to `0`. | optional (required if email services are to be enabled) | |
|
||||
| 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) | |
|
||||
| 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 | API Secret for running cron jobs. | optional | |
|
||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
|
||||
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
|
||||
| INTERNAL_SECRET | Internal Secret (Currently we do not use this anywhere). | optional | |
|
||||
| RENDER_EXTERNAL_URL | External URL for rendering (used instead of WEBAPP_URL). | optional | |
|
||||
| HEROKU_APP_NAME | Heroku app name (used instead of WEBAPP_URL). | optional | |
|
||||
| RAILWAY_STATIC_URL | Railway static URL (used instead of WEBAPP_URL). | optional | | |
|
||||
| Variable | Description | Required | Default |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
|
||||
| SURVEY_BASE_URL | Base URL of the link surveys. | required | `http://localhost:3000/s/` |
|
||||
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
|
||||
| PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | |
|
||||
| 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` |
|
||||
| PRIVACY_URL | URL for privacy policy. | optional | |
|
||||
| TERMS_URL | URL for terms of service. | optional | |
|
||||
| IMPRINT_URL | URL for imprint. | optional | |
|
||||
| SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
|
||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to `1`. | optional | |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
|
||||
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
|
||||
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to `1` else to `0`. | optional (required if email services are to be enabled) | |
|
||||
| GITHUB_AUTH_ENABLED | Enables GitHub login if set to `1`. | optional | |
|
||||
| GOOGLE_AUTH_ENABLED | Enables Google 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) | |
|
||||
| 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 | API Secret for running cron jobs. | optional | |
|
||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
|
||||
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
|
||||
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
|
||||
| IS_FORMBRICKS_CLOUD | Uses Formbricks Cloud if set to `1` | optional | |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | `#64748b` |
|
||||
|
||||
### Build-time Variables
|
||||
## 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` |
|
||||
| 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 | |
|
||||
| NEXT_PUBLIC_GITHUB_AUTH_ENABLED | Enables GitHub login if set to `1`. | optional | |
|
||||
| NEXT_PUBLIC_GOOGLE_AUTH_ENABLED | Enables Google login if set to `1`. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_API_HOST | Host for the Formbricks API. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID | If you already have a preset environment ID of the environment. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID | If you already have an onboarding survey ID to show new users. | optional | |
|
||||
| NEXT_PUBLIC_IS_FORMBRICKS_CLOUD | Uses Formbricks Cloud if set to `1` | optional | |
|
||||
| NEXT_PUBLIC_POSTHOG_API_KEY | API key for PostHog analytics. | optional | |
|
||||
| NEXT_PUBLIC_POSTHOG_API_HOST | Host for PostHog analytics. | optional | |
|
||||
| NEXT_PUBLIC_SENTRY_DSN | DSN for Sentry error tracking. | optional | |
|
||||
| NEXT_PUBLIC_DOCSEARCH_APP_ID | ID of the DocSearch application. | optional | |
|
||||
| NEXT_PUBLIC_DOCSEARCH_API_KEY | API key of the DocSearch application. | optional | |
|
||||
| NEXT_PUBLIC_DOCSEARCH_INDEX_NAME | Name of the DocSearch index. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID | Environment ID for Formbricks (if you already have one) | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID | Survey ID for the feedback survey on the docs site. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_COM_API_HOST | Host for the Formbricks API. | optional | |
|
||||
| NEXT_PUBLIC_VERCEL_URL | Vercel URL (used instead of WEBAPP_URL). | optional |
|
||||
| Variable | Description | Required | Default |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------- | -------- | ------- |
|
||||
| NEXT_PUBLIC_FORMBRICKS_API_HOST | Host for the Formbricks API. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID | If you already have a preset environment ID of the environment. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID | If you already have an onboarding survey ID to show new users. | optional | |
|
||||
| NEXT_PUBLIC_POSTHOG_API_KEY | API key for PostHog analytics. | optional | |
|
||||
| NEXT_PUBLIC_POSTHOG_API_HOST | Host for PostHog analytics. | optional | |
|
||||
| NEXT_PUBLIC_SENTRY_DSN | DSN for Sentry error tracking. | optional | |
|
||||
| NEXT_PUBLIC_DOCSEARCH_APP_ID | ID of the DocSearch application. | optional | |
|
||||
| NEXT_PUBLIC_DOCSEARCH_API_KEY | API key of the DocSearch application. | optional | |
|
||||
| NEXT_PUBLIC_DOCSEARCH_INDEX_NAME | Name of the DocSearch index. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID | Environment ID for Formbricks (if you already have one) | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID | Survey ID for the feedback survey on the docs site. | optional | |
|
||||
| NEXT_PUBLIC_FORMBRICKS_COM_API_HOST | Host for the Formbricks API. | optional | |
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
export const meta = {
|
||||
title: "Migrating Formbricks to v1.1",
|
||||
description:
|
||||
"Formbricks v1.1 comes with an amazing set of features including the ability to define most environment variables at runtime itself! No need to build the image again! This guide will help you migrate your existing Formbricks instance to v1.1",
|
||||
};
|
||||
|
||||
#### Self-Hosting
|
||||
|
||||
# Migrating to v1.1
|
||||
|
||||
Formbricks v1.1 includes a lot of new features and improvements. However, it also comes with a few breaking changes specifically with the environment variables. This guide will help you migrate your existing Formbricks instance to v1.1 without losing any data.
|
||||
|
||||
## Changes in .env
|
||||
|
||||
### Renamed Environment Variables
|
||||
This was introduced because we got a lot of requests from our users for the ability to define some common environment variables at runtime itself i.e. without having to rebuild the image for the changes to take effect.
|
||||
This is now possible with v1.1. However, due to Next.JS best practices, we had to deprecate the prefix **NEXT_PUBLIC_** in the following environment variables:
|
||||
|
||||
| till v1.0 | v1.1 |
|
||||
| ------------------------------------------- | --------------------------- |
|
||||
| **NEXT_PUBLIC_**EMAIL_VERIFICATION_DISABLED | EMAIL_VERIFICATION_DISABLED |
|
||||
| **NEXT_PUBLIC_**PASSWORD_RESET_DISABLED | PASSWORD_RESET_DISABLED |
|
||||
| **NEXT_PUBLIC_**SIGNUP_DISABLED | SIGNUP_DISABLED |
|
||||
| **NEXT_PUBLIC_**INVITE_DISABLED | INVITE_DISABLED |
|
||||
| **NEXT_PUBLIC_**PRIVACY_URL | PRIVACY_URL |
|
||||
| **NEXT_PUBLIC_**TERMS_URL | TERMS_URL |
|
||||
| **NEXT_PUBLIC_**IMPRINT_URL | IMPRINT_URL |
|
||||
| **NEXT_PUBLIC_**GITHUB_AUTH_ENABLED | GITHUB_AUTH_ENABLED |
|
||||
| **NEXT_PUBLIC_**GOOGLE_AUTH_ENABLED | GOOGLE_AUTH_ENABLED |
|
||||
| **NEXT_PUBLIC_**WEBAPP_URL | WEBAPP_URL |
|
||||
| **NEXT_PUBLIC_**IS_FORMBRICKS_CLOUD | IS_FORMBRICKS_CLOUD |
|
||||
| **NEXT_PUBLIC_**SURVEY_BASE_URL | SURVEY_BASE_URL |
|
||||
|
||||
<Note>
|
||||
Please note that their values and the logic remains exactly the same. Only the prefix has been deprecated. The other environment variables remain the same as well.
|
||||
</Note>
|
||||
|
||||
### Deprecated Environment Variables
|
||||
|
||||
- **NEXT_PUBLIC_VERCEL_URL**: Was used as Vercel URL (used instead of WEBAPP_URL), but from v1.1, you can just set the WEBAPP_URL environment variable to your Vercel URL.
|
||||
- **RAILWAY_STATIC_URL**: Was used as Railway Static URL (used instead of WEBAPP_URL), but from v1.1, you can just set the WEBAPP_URL environment variable.
|
||||
- **RENDER_EXTERNAL_URL**: Was used as an external URL to Render (used instead of WEBAPP_URL), but from v1.1, you can just set the WEBAPP_URL environment variable.
|
||||
- **HEROKU_APP_NAME**: Was used to build the App name on a Heroku hosted webapp, but from v1.1, you can just set the WEBAPP_URL environment variable.
|
||||
- **NEXT_PUBLIC_WEBAPP_URL**: Was used for the same purpose as WEBAPP_URL, but from v1.1, you can just set the WEBAPP_URL environment variable.
|
||||
- **PRISMA_GENERATE_DATAPROXY**: Was used to tell Prisma that it should generate the runtime for Dataproxy usage. But its officially deprecated now.
|
||||
|
||||
## Helper Shell Script
|
||||
For a seamless migration, below is a shell script for your self-hosted instance that will automatically update your environment variables to be compliant with the new naming conventions.
|
||||
|
||||
### Building From Source
|
||||
The below script will:
|
||||
1. Create a backup of your existing .env file as `.env.old`
|
||||
2. Update the .env file to be compliant with the new naming conventions
|
||||
|
||||
<CodeGroup title="Run the below in your terminal in the directory of your .env">
|
||||
```shell {{ title: '.env file' }}
|
||||
for var in NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED NEXT_PUBLIC_PASSWORD_RESET_DISABLED NEXT_PUBLIC_SIGNUP_DISABLED NEXT_PUBLIC_INVITE_DISABLED NEXT_PUBLIC_PRIVACY_URL NEXT_PUBLIC_TERMS_URL NEXT_PUBLIC_IMPRINT_URL NEXT_PUBLIC_GITHUB_AUTH_ENABLED NEXT_PUBLIC_GOOGLE_AUTH_ENABLED NEXT_PUBLIC_WEBAPP_URL NEXT_PUBLIC_IS_FORMBRICKS_CLOUD NEXT_PUBLIC_SURVEY_BASE_URL; do sed -i.old "s/^$var=/$(echo $var | sed 's/NEXT_PUBLIC_//')=/" .env; done; echo "Formbricks environment variables have been migrated as per v1.1! You are good to go."
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
### Docker & Single Script Setup
|
||||
|
||||
Now that these variables can be defined at runtime, you can append them inside your `x-environment` in the `docker-compose.yml` itself.
|
||||
For a more detailed guide on these environment variables, please refer to the [Important Runtime Variables](/docs/self-hosting/from-source#important-run-time-variables) section.
|
||||
|
||||
<CodeGroup title="docker-compose.yml">
|
||||
```yaml {{ title: 'docker-compose.yml' }}
|
||||
version: "3.3"
|
||||
x-environment: &environment
|
||||
environment:
|
||||
# The url of your Formbricks instance used in the admin panel
|
||||
WEBAPP_URL:
|
||||
|
||||
# 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
|
||||
|
||||
# PostgreSQL password
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
# Email Configuration
|
||||
MAIL_FROM:
|
||||
SMTP_HOST:
|
||||
SMTP_PORT:
|
||||
SMTP_SECURE_ENABLED:
|
||||
SMTP_USER:
|
||||
SMTP_PASSWORD:
|
||||
|
||||
# Uncomment the below and set it to 1 to disable Email Verification for new signups
|
||||
# EMAIL_VERIFICATION_DISABLED:
|
||||
|
||||
# Uncomment the below and set it to 1 to disable Password Reset
|
||||
# PASSWORD_RESET_DISABLED:
|
||||
|
||||
# Uncomment the below and set it to 1 to disable Signups
|
||||
# SIGNUP_DISABLED:
|
||||
|
||||
# Uncomment the below and set it to 1 to disable Invites
|
||||
# INVITE_DISABLED:
|
||||
|
||||
# Uncomment the below and set a value to have your own Privacy Page URL on the signup & login page
|
||||
# PRIVACY_URL:
|
||||
|
||||
# Uncomment the below and set a value to have your own Terms Page URL on the auth and the surveys page
|
||||
# TERMS_URL:
|
||||
|
||||
# Uncomment the below and set a value to have your own Imprint Page URL on the auth and the surveys page
|
||||
# IMPRINT_URL:
|
||||
|
||||
# Uncomment the below and set to 1 if you want to enable GitHub OAuth
|
||||
# GITHUB_AUTH_ENABLED:
|
||||
# GITHUB_ID:
|
||||
# GITHUB_SECRET:
|
||||
|
||||
# Uncomment the below and set to 1 if you want to enable Google OAuth
|
||||
# GOOGLE_AUTH_ENABLED:
|
||||
# GOOGLE_CLIENT_ID:
|
||||
# GOOGLE_CLIENT_SECRET:
|
||||
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Did we miss something? Are you still facing issues migrating your app? [Join our Discord!](https://formbricks.com/discord) We'd be happy to help!
|
||||
@@ -151,7 +151,7 @@ function BestPractice({ resource }: { resource: BestPractice }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function BestPractices() {
|
||||
export default function BestPractices() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="resources">
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion, useIsPresent } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { remToPx } from "@/lib/remToPx";
|
||||
import { Button } from "./Button";
|
||||
import { useIsInsideMobileNavigation } from "./MobileNavigation";
|
||||
import { useSectionStore } from "./SectionProvider";
|
||||
import { Tag } from "./Tag";
|
||||
import { remToPx } from "@/lib/remToPx";
|
||||
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
@@ -246,13 +246,16 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Production", href: "/docs/self-hosting/production" },
|
||||
{ title: "Docker", href: "/docs/self-hosting/docker" },
|
||||
{ title: "From Source", href: "/docs/self-hosting/from-source" },
|
||||
{ title: "Migration to v1.1", href: "/docs/self-hosting/migrating-to-1.1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Contributing",
|
||||
links: [
|
||||
{ title: "Introduction", href: "/docs/contributing/introduction" },
|
||||
{ title: "How we code at Formbricks", href: "/docs/contributing/how-we-code" },
|
||||
{ title: "Setup Dev Environment", href: "/docs/contributing/setup" },
|
||||
{ title: "Gitpod", href: "/docs/contributing/gitpod" },
|
||||
{ title: "Demo App", href: "/docs/contributing/demo" },
|
||||
{ title: "Troubleshooting", href: "/docs/contributing/troubleshooting" },
|
||||
{ title: "FaQ", href: "/docs/faq" },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import GitHubMarkWhite from "@/images/github-mark-white.svg";
|
||||
import GitHubMarkDark from "@/images/github-mark.svg";
|
||||
import HackIconGold from "@/images/formtribe/hack-icon-gold.svg";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -10,7 +9,7 @@ export const GitHubSponsorship: React.FC = () => {
|
||||
@media (min-width: 426px);
|
||||
`}</style>
|
||||
<div className="right-24 lg:absolute">
|
||||
<Image
|
||||
{/* <Image
|
||||
src={GitHubMarkDark}
|
||||
alt="GitHub Sponsors Formbricks badge"
|
||||
width={100}
|
||||
@@ -22,19 +21,24 @@ export const GitHubSponsorship: React.FC = () => {
|
||||
alt="GitHub Sponsors Formbricks badge"
|
||||
width={100}
|
||||
height={100}
|
||||
className="mr-12 hidden dark:block md:mr-4 "
|
||||
className="mr-12 hidden dark:block md:mr-4"
|
||||
/> */}
|
||||
<Image
|
||||
src={HackIconGold}
|
||||
alt="Hacktober Icon Gold"
|
||||
width={100}
|
||||
height={100}
|
||||
className="mr-12 md:mr-4"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-800 dark:text-slate-200 lg:text-2xl">
|
||||
Proudly Open-Source 🤍
|
||||
The FormTribe goes Hacktoberfest 🥨
|
||||
</h2>
|
||||
<p className="lg:text-md mt-4 max-w-3xl text-slate-500 dark:text-slate-400">
|
||||
We're proud to to be supported by GitHubs Open-Source Program!{" "}
|
||||
Write code, win a Mac! We're running a Hacktoberfest community Hackathon:
|
||||
<span>
|
||||
<Link
|
||||
href="/blog/inaugural-batch-github-accelerator"
|
||||
className="decoration-brand-dark underline underline-offset-4">
|
||||
Read more.
|
||||
<Link href="/formtribe" className="decoration-brand-dark ml-2 underline underline-offset-4">
|
||||
Find out more.
|
||||
</Link>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -20,10 +20,10 @@ export const Hero: React.FC = ({}) => {
|
||||
<div className="relative">
|
||||
<div className="px-4 pb-20 pt-16 text-center sm:px-6 lg:px-8 lg:pb-32 lg:pt-20">
|
||||
<a
|
||||
href="https://github.com/formbricks/formbricks"
|
||||
href="https://formbricks.com/formtribe"
|
||||
target="_blank"
|
||||
className="border-brand-dark rounded-full border px-4 py-1.5 text-sm text-slate-500 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800">
|
||||
We're Open-Source | Star us on GitHub{" "}
|
||||
className="border-brand-dark animate-bounce rounded-full border px-4 py-1.5 text-sm text-slate-500 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800">
|
||||
The FormTribe Hackathon is on 🔥
|
||||
<ChevronRightIcon className="inline h-5 w-5 text-slate-300" />
|
||||
</a>
|
||||
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
|
||||
|
||||
@@ -250,7 +250,7 @@ export default function Header() {
|
||||
</Link>
|
||||
*/}
|
||||
<Link
|
||||
href="https://formbricks.com/#pricing"
|
||||
href="/pricing"
|
||||
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
33
apps/formbricks-com/components/shared/OpenSourceInfo.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
export const OpenSourceInfo = () => {
|
||||
return (
|
||||
<div className="my-8 md:my-20">
|
||||
<div className="px-8 md:px-16">
|
||||
<div className=" rounded-xl bg-slate-100 px-4 py-8 dark:bg-slate-800 md:px-12">
|
||||
<h2 className="text-lg font-semibold leading-7 tracking-tight text-slate-800 dark:text-slate-200 md:text-2xl">
|
||||
Open Source
|
||||
</h2>
|
||||
|
||||
<p className=" my-2 text-slate-600 dark:text-slate-300">
|
||||
Formbricks is an open source project. You can self-host it for free. We provide multiple easy
|
||||
deployment options as per your customisation needs. We have documented the process of self-hosting
|
||||
Formbricks on your own server using Docker, Bash Scripting, and Building from Source.
|
||||
</p>
|
||||
<div className="mt-4 space-x-2">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => window.open("https://github.com/formbricks/formbricks", "_blank")}>
|
||||
Star us on GitHub
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open("/docs/self-hosting/deployment", "_blank")}
|
||||
variant="secondary">
|
||||
Read our Docs on Self Hosting
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
115
apps/formbricks-com/components/shared/PricingCalculator.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Slider } from "@/components/shared/Slider";
|
||||
import { useState } from "react";
|
||||
|
||||
const ProductItem = ({ label, usersCount, price, onSliderChange }) => (
|
||||
<div className="mt-12">
|
||||
<div className="mb-2 flex items-center gap-x-2 md:gap-x-4">
|
||||
<div className="md:text-md w-3/6 text-left text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{label}
|
||||
</div>
|
||||
<div className="md:text-md w-2/6 text-center text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{Math.round(usersCount).toLocaleString()} MTU
|
||||
</div>
|
||||
<div className="md:text-md flex w-1/6 items-center justify-end text-center text-sm font-medium text-slate-700 dark:text-slate-200 md:justify-center">
|
||||
<span>${price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2 w-5/6 pr-8 md:pr-20">
|
||||
<Slider
|
||||
className="slider-class"
|
||||
defaultValue={[Math.log10(1000)]}
|
||||
min={3}
|
||||
max={6}
|
||||
step={0.01}
|
||||
onValueChange={onSliderChange}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
{[3, 4, 5, 6].map((mark) => (
|
||||
<span key={mark} className="text-slate-600 dark:text-slate-300">
|
||||
{mark === 3 ? "1K" : mark === 4 ? "10K" : mark === 5 ? "100K" : "1M"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Headers = () => (
|
||||
<div className="mb-4 flex justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-700 dark:text-slate-200 md:text-lg">Product</h3>
|
||||
<h3 className="w-1/6 text-center text-base font-semibold text-slate-700 dark:text-slate-200 md:text-lg">
|
||||
Subtotal
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MonthlyEstimate = ({ price }) => (
|
||||
<div className="mt-2 flex justify-between">
|
||||
<span className="text-base font-semibold text-slate-700 dark:text-slate-200 md:text-lg">
|
||||
Monthly estimate:
|
||||
</span>
|
||||
<div className="w-1/6 text-center">
|
||||
<span className="w-1/6 text-base text-slate-700 dark:text-slate-200 md:text-lg md:font-semibold">
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
<span className="hidden text-sm text-slate-400 dark:text-slate-500 md:block md:text-base">
|
||||
{" "}
|
||||
/ month
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PricingCalculator = () => {
|
||||
const [inProductSlider, setInProductSlider] = useState(Math.log10(1000));
|
||||
const [linkSlider, setLinkSlider] = useState(Math.log10(1000));
|
||||
|
||||
const transformToLog = (value) => Math.pow(10, value);
|
||||
|
||||
const calculatePrice = (users) => {
|
||||
if (users <= 5000) {
|
||||
return 0;
|
||||
} else {
|
||||
return users * 0.005;
|
||||
}
|
||||
};
|
||||
|
||||
const usersCountForInProductSlider = transformToLog(inProductSlider);
|
||||
const productSurveysPrice = calculatePrice(usersCountForInProductSlider);
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-16">
|
||||
<h2 className="px-4 py-4 text-lg font-semibold leading-7 tracking-tight text-slate-800 dark:text-slate-200 md:px-12 md:text-2xl">
|
||||
Pricing Calculator
|
||||
</h2>
|
||||
|
||||
<div className="rounded-xl bg-slate-100 px-4 py-4 dark:bg-slate-800 md:px-12">
|
||||
<div className="rounded-xl px-4">
|
||||
<Headers />
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<ProductItem
|
||||
label="In Product Surveys"
|
||||
usersCount={usersCountForInProductSlider}
|
||||
price={productSurveysPrice}
|
||||
onSliderChange={(value) => setInProductSlider(value[0])}
|
||||
/>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<ProductItem
|
||||
label="Link Surveys"
|
||||
usersCount={transformToLog(linkSlider)}
|
||||
price={0}
|
||||
onSliderChange={(value) => setLinkSlider(value[0])}
|
||||
/>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<MonthlyEstimate price={productSurveysPrice} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
apps/formbricks-com/components/shared/PricingGetStarted.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
export const GetStartedWithPricing = ({ showDetailed }: { showDetailed: boolean }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-4 px-4 pb-4 md:gap-4 md:px-16">
|
||||
<div className="w-1/3"></div>
|
||||
<div className="w-1/3 text-left text-sm text-slate-800 dark:text-slate-100">
|
||||
<p className="text-base font-semibold">Free</p>
|
||||
|
||||
{showDetailed && (
|
||||
<p className="leading text-xs text-slate-500 dark:text-slate-400 md:text-base">
|
||||
General free usage on every product. Best for early stage startups and hobbyists
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mt-4 w-full justify-center"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.open("https://app.formbricks.com/", "_blank");
|
||||
}}>
|
||||
Get started - free
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-1/3 text-left text-sm text-slate-800 dark:text-slate-100">
|
||||
<p className="text-base font-semibold"> Paid</p>
|
||||
{showDetailed && (
|
||||
<p className="leading text-xs text-slate-500 dark:text-slate-400 md:text-base">
|
||||
Formbricks with the next-generation features, Pay only for the tracked users.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mt-4 w-full justify-center"
|
||||
variant="highlight"
|
||||
onClick={() => {
|
||||
window.open("https://app.formbricks.com/", "_blank");
|
||||
}}>
|
||||
Get started - free
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-12"></div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
apps/formbricks-com/components/shared/PricingTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const PricingTable = ({ leadRow, pricing, endRow }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 px-4 md:gap-4 md:px-16 ">
|
||||
<div className="rounded-xl px-4 md:px-12">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="w-1/3 text-left font-semibold text-slate-700 dark:text-slate-200 md:text-xl">
|
||||
{leadRow.title}
|
||||
</div>
|
||||
<div
|
||||
className="flex w-1/3 items-center justify-center text-center text-sm font-semibold
|
||||
text-slate-500 dark:text-slate-200 md:text-lg">
|
||||
{leadRow.free}
|
||||
</div>
|
||||
|
||||
<div className="w-1/3 text-center font-semibold text-slate-700 dark:text-slate-200 md:text-lg">
|
||||
{leadRow.paid}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-slate-100 px-4 py-4 dark:bg-slate-800 md:px-12 ">
|
||||
{pricing.map((feature) => (
|
||||
<div key={feature.name} className="mb-8 flex items-center gap-x-4">
|
||||
<div className="w-1/3 text-left text-sm text-slate-700 dark:text-slate-200 md:text-base">
|
||||
{feature.name}
|
||||
{feature.addOnText && (
|
||||
<span className=" mx-2 bg-teal-100 p-1 text-xs text-slate-400 dark:bg-slate-700 dark:text-teal-500">
|
||||
Addon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-1/3 items-center justify-center text-center text-sm text-slate-800 dark:text-slate-100">
|
||||
{feature.addOnText ? (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<u>{feature.free}</u>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={"top"}>
|
||||
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{feature.addOnText}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : feature.free ? (
|
||||
<div className="h-6 w-6 rounded-full border border-green-300 bg-green-100 p-0.5 dark:border-green-600 dark:bg-green-900">
|
||||
<CheckIcon className="p-0.5 text-green-500 dark:text-green-300" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-full border border-red-300 bg-red-100 p-0.5 dark:border-red-500 dark:bg-red-300">
|
||||
<XMarkIcon className="dark:red-300 p-0.5 text-red-500 dark:text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-1/3 items-center justify-center text-center text-sm text-slate-800 dark:text-slate-100">
|
||||
{feature.addOnText ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<u>{feature.paid}</u>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={"top"}>
|
||||
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{feature.addOnText}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : feature.paid ? (
|
||||
<div className="h-6 w-6 rounded-full border border-green-300 bg-green-100 p-0.5 dark:border-green-600 dark:bg-green-900">
|
||||
<CheckIcon className="p-0.5 text-green-500 dark:text-green-300" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-full border border-red-300 bg-red-100 p-0.5 dark:border-red-600 dark:bg-red-900">
|
||||
<XMarkIcon className="dark:red-300 p-0.5 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl px-4 md:px-12">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="w-1/3 text-left text-sm font-semibold text-slate-700 dark:text-slate-200 md:text-base">
|
||||
{endRow.title}
|
||||
</div>
|
||||
<div className="flex w-1/3 items-center justify-center text-center text-sm font-semibold text-slate-700 dark:text-slate-200 md:text-base">
|
||||
<span>{endRow.free}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-1/3 text-center text-sm font-semibold text-slate-700 dark:text-slate-200 md:text-base">
|
||||
{endRow.paid}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
24
apps/formbricks-com/components/shared/Slider.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-300 dark:bg-slate-500">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-slate-600 dark:bg-slate-100" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className=" ring-offset-background focus-visible:ring-ring border-3 block h-4 w-4 rounded-full bg-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:bg-slate-100" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
13
apps/formbricks-com/components/shared/icons/XCircleIcon.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>;
|
||||
|
After Width: | Height: | Size: 295 B |
1
apps/formbricks-com/images/formtribe/hack-icon-gold.svg
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -24,14 +24,14 @@
|
||||
"@mapbox/rehype-prism": "^0.8.0",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "13.5.3",
|
||||
"@next/mdx": "13.4.19",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/node": "20.7.0",
|
||||
"@types/react-highlight-words": "^0.16.5",
|
||||
"@types/node": "20.6.0",
|
||||
"@types/react-highlight-words": "^0.16.4",
|
||||
"acorn": "^8.10.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"clsx": "^2.0.0",
|
||||
"fast-glob": "^3.3.1",
|
||||
"flexsearch": "^0.7.31",
|
||||
@@ -39,14 +39,16 @@
|
||||
"lottie-web": "^5.12.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mdx-annotations": "^0.1.3",
|
||||
"next": "13.5.3",
|
||||
"next": "13.4.19",
|
||||
"next-plausible": "^3.11.1",
|
||||
"next-seo": "^6.1.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"next-themes": "^0.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"prism-react-renderer": "^2.1.0",
|
||||
"prism-react-renderer": "^2.0.6",
|
||||
"prismjs": "^1.29.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-highlight-words": "^0.20.0",
|
||||
@@ -56,7 +58,7 @@
|
||||
"remark": "^14.0.3",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-mdx": "^2.3.0",
|
||||
"sharp": "^0.32.6",
|
||||
"sharp": "^0.32.5",
|
||||
"shiki": "^0.14.4",
|
||||
"simple-functional-loader": "^1.2.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
|
||||
|
After Width: | Height: | Size: 818 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 898 KiB |
107
apps/formbricks-com/pages/blog/join-the-formtribe/index.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
|
||||
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
|
||||
import GitpodImage from "./setup-formbricks-via-gitpod.png";
|
||||
import PackagesFolderImage from "./formbricks-packages-folder.png";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Join the FormTribe 🔥",
|
||||
description: "Here is everything you need to know about joining the Formbricks community",
|
||||
date: "2023-10-01",
|
||||
publishedTime: "2023-10-01T00:00:00",
|
||||
authors: ["Johannes"],
|
||||
section: "Open-Source",
|
||||
tags: ["Open-Source", "No-Code", "Formbricks", "Geting started", "Welcome guide"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" />
|
||||
|
||||
<Image src={HeaderImage} alt="Title Image" className="w-full rounded-lg" />
|
||||
|
||||
_Get a quick intro to the Formbricks community, also known as FormTribe, and learn all the deets about making awesome contributions to the project._
|
||||
|
||||
## Welcome to the Formbricks community!
|
||||
|
||||
We are so excited to have you with us 😊
|
||||
|
||||
In this post we will be helping you get familiar with the Formbricks codebase and get you up to speed contributing in no time. If you want to learn about Formbricks check out our [docs intro](https://formbricks.com/docs/introduction/what-is-formbricks), for more info about our founding story and why we're building open source checkout out our [blog](https://formbricks.com/blog).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Our codebase is written fully in Typescript and we love it 😍. To power our the experience management solution, here is the stack behind it all:
|
||||
|
||||
[Next.js](https://nextjs.org/) - React Framework
|
||||
|
||||
[Prisma](https://www.prisma.io/) - ORM
|
||||
|
||||
[Typescript](https://www.typescriptlang.org/) - Language
|
||||
|
||||
[Lucide React](https://lucide.dev/guide/packages/lucide-react) - Icons
|
||||
|
||||
[TalwindCSS](https://tailwindcss.com/) - Styling
|
||||
|
||||
[Zod](https://zod.dev/) - Validation
|
||||
|
||||
[Auth.js](https://authjs.dev/) - Authentication
|
||||
|
||||
### 😎 Installation and Setup
|
||||
|
||||
To get up and running we have 2 options: Gitpod and local.
|
||||
|
||||
#### Get started with Gitpod
|
||||
|
||||
With Gitpod you can run all of Formbricks in the cloud. With one click you can start coding right away in your browser:
|
||||
|
||||
[](https://gitpod.io/#https://github.com/formbricks/formbricks)
|
||||
|
||||
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
|
||||
|
||||
#### Run on a local machine
|
||||
|
||||
If you choose to get setup locally, we also have a well documented guide to hold you through the process, you can find it [here](https://formbricks.com/docs/contributing/setup)
|
||||
|
||||
### 👩🏽💻 Codebase Overview
|
||||
|
||||
Our codebase is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) which means we have different projects in one repository. At moment we have 3 different projects:
|
||||
|
||||
1. **demo** `apps/demo` - It's a simple React app that you can run locally and use to trigger actions and set **[Attributes](https://formbricks.com/docs/attributes/why)**. It allows you to test your setup easily.
|
||||
2. **formbricks-com** `apps/formbricks-com` - The landing page of [Formbricks](https://formbricks.com)
|
||||
3. **web** `apps/web` - Our [cloud offering](https://app.formbricks.com/) for Formbricks.
|
||||
|
||||
<Image src={MonorepoImage} alt="Formbricks monorepo folder structure" className="w-full rounded-lg" />
|
||||
|
||||
#### TurboRepo and our own packages
|
||||
|
||||
To manage all of these projects in one repository we use [turborepo](https://turbo.build/repo/docs/core-concepts/monorepos). Depending on what part of the codebase you need to contribute in, now you know where to begin 😃
|
||||
|
||||
We also have a set of packages which we manage: They are located in the `packages` folder. There we keep our styling library, components, database migrations and connection, a couple of configurations and much more. We do this to use any of these packages seamlessly between our mono repos.
|
||||
|
||||
<Image src={PackagesFolderImage} alt="Formbricks packages folder" className="w-full rounded-lg" />
|
||||
|
||||
### ⚖️ Contribution Guidelines
|
||||
|
||||
You want to get started contributing? Amazing! Checkout our must-read post on [How we Code at Formbricks](https://formbricks.com/docs/contributing/how-we-code). This will give you everything you need to know about successfully contributing to our codebase in no time.
|
||||
|
||||
### 🤗 Our Community
|
||||
|
||||
We really value our community. It might be small but it is close to our hearts. Join our [Discord](https://formbricks.com/discord) to learn from other contributors and meet the Formbricks community.
|
||||
|
||||
### Conclusion
|
||||
|
||||
Contributing to open source projects like Formbricks can be a rewarding experience. By contributing, you have the opportunity to make a meaningful impact on a project used by many and gain valuable experience in the process.
|
||||
|
||||
Whether you are a seasoned developer or just starting out, your contributions are appreciated. We might not always have to onboard everyone but try our best. You can help improve the codebase, fix bugs, add new features, or even contribute to the documentation. Every contribution, no matter how small, can make a difference.
|
||||
|
||||
Not only will you be able to showcase your skills and build your portfolio, but you will also have the chance to collaborate with other talented designers and developers in the Formbricks community. You can learn from their expertise and share your own knowledge.
|
||||
|
||||
### So, why wait?
|
||||
|
||||
Join the Formbricks community today and start contributing to an up and coming open source project. Your contributions can help shape the future of Formbricks and make a positive impact on the lives of tens of thousands of users worldwide.
|
||||
|
||||
We look forward to seeing your contributions and welcoming you to the Formbricks community!
|
||||
|
||||
### [Say Hi 👋](https://formbricks.com/discord)
|
||||
|
||||
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;
|
||||
|
After Width: | Height: | Size: 594 KiB |
@@ -16,65 +16,117 @@ import { useEffect } from "react";
|
||||
const HowTo = [
|
||||
{
|
||||
step: "1",
|
||||
header: "Pick an issue from the list below (or start with a side quest)",
|
||||
header: "Pick a 'FormTribe 🔥' issue in our repository and comment.",
|
||||
link: "https://formbricks.com/github",
|
||||
},
|
||||
{
|
||||
step: "2",
|
||||
header: "Comment on the issue to signal that you started working on it.",
|
||||
header: "Be the first to comment and get the issue assigned.",
|
||||
},
|
||||
{
|
||||
step: "3",
|
||||
header: "Join our Discord to ask questions and submit side quests.",
|
||||
link: "https://formbricks.com/discord",
|
||||
header: "You now have 24h to open a draft PR ⏲️",
|
||||
},
|
||||
{
|
||||
step: "4",
|
||||
header: "Code and open a PR with your contribution. ",
|
||||
header: "If your PR looks promising, we'll work with you to get it merged.",
|
||||
},
|
||||
{
|
||||
step: "5",
|
||||
header: "Get your PR merged and collect points.",
|
||||
header: "For every merged PR you collect points ✨",
|
||||
},
|
||||
{
|
||||
step: "6",
|
||||
header: "Tweet about your contribution and tag @formbricks",
|
||||
header: "Solve side quests to increase your chances on the MacBook 👀",
|
||||
link: "#prizes",
|
||||
},
|
||||
{
|
||||
step: "7",
|
||||
header: "Solve side quests to increase your chances on the MacBook 👀",
|
||||
link: "#prizes",
|
||||
header: "Join our Discord to ask questions (and submit side quests).",
|
||||
link: "https://formbricks.com/discord",
|
||||
},
|
||||
];
|
||||
|
||||
const SideQuests = [
|
||||
{
|
||||
points: "100 Points:",
|
||||
quest: "You think you're smart removing the blur to see the side quests first?",
|
||||
points: "Join the Tribe Tweet (100 Points)",
|
||||
quest: "Tweet a single “🧱” emoji before the 7th of October EOD to join the #FormTribe.",
|
||||
proof: "Share the link to the tweet in the “side-quest” channel.",
|
||||
},
|
||||
{
|
||||
points: "150 Points:",
|
||||
points: "Spread the Word Tweet (100 Points)",
|
||||
quest: "Tweet “🧱🚀” on the day of the ProductHunt launch to spread the word.",
|
||||
proof: "Share the link to the tweet in the “side-quest” channel.",
|
||||
},
|
||||
{
|
||||
points: "Setup Insights (200 Points)",
|
||||
quest: "Screen record yourself setting up the Formbricks dev environment.",
|
||||
proof: "Upload to WeTransfer and send to johannes@formbricks.com",
|
||||
},
|
||||
{
|
||||
points: "Meme Magic (50 Points + up to 100 Points)",
|
||||
quest:
|
||||
"You are! Take a screenshot of this and share it in the 'side-quest' channel on Discord to get 100 points.",
|
||||
"Craft a meme where a brick plays a role. For extra points, tweet it, tag us and score +5 for each like.",
|
||||
proof: "Share meme or link to the tweet in the “side-quest” channel.",
|
||||
},
|
||||
{
|
||||
points: "200 Points:",
|
||||
quest: "The rest of the side quests will be released on the 1st of October.",
|
||||
points: "GIF Magic (100 Points)",
|
||||
quest:
|
||||
"Create a branded gif related to Formbricks. Upload it to Giphy. For extra points, tweet it, tag us and score +5 for each like.",
|
||||
proof: "Share link to Giphy in the “side-quest” channel.",
|
||||
},
|
||||
{
|
||||
points: "250 Points:",
|
||||
quest: "Follow us on Twitter and join us on Discord to be the first to know!",
|
||||
points: "Design a background (250 Points)",
|
||||
quest: "Illustrate a captivating background for survey enthusiasts (more infos on Notion).",
|
||||
proof: "Share the design in the “side-quest” channel.",
|
||||
},
|
||||
{
|
||||
points: "Pushmaster Prime | +500 Points + Hoodie:",
|
||||
quest: "Merge the highest amount of Formbricks PRs in October.",
|
||||
points: "Transform Animation to CSS (350 Points per background)",
|
||||
quest: "Animate an existing background to CSS versions (more infos on Notion).",
|
||||
proof: "Share the animated background.",
|
||||
},
|
||||
{
|
||||
points: "Guidance Guru | +500 Points + Hoodie:",
|
||||
quest: "Most active and helpful in the community helping other contributors.",
|
||||
points: "Enhance Docs (50-250 Points)",
|
||||
quest:
|
||||
"Add a new section to our docs where you see gaps. Follow the current style of documentation incl. code snippets and screenshots. Pls no spam.",
|
||||
proof: "Open a PR with “docs” in the title",
|
||||
},
|
||||
{
|
||||
points: "Buzz Builder Guru | +500 Points + Hoodie:",
|
||||
quest: "Marketing Genie with great and effective ideas to spread the word about FormTribe",
|
||||
points: "Starry-eyed Supporter (250 Points)",
|
||||
quest: "Get five friends to star our repository.",
|
||||
proof: "Share 5 screenshots of the chats where you asked them and they confirmed + their GitHub names",
|
||||
},
|
||||
{
|
||||
points: "Bug Hunter (50-250 Points)",
|
||||
quest: "Find and report any functionality bugs.",
|
||||
proof: "Open a bug issue in our repository.",
|
||||
},
|
||||
{
|
||||
points: "Brickify someone famous with AI (200 Points + up to 100 Points)",
|
||||
quest:
|
||||
"Find someone whose name would be funny as a play on words with “brick”. Then, with the help of AI, create a brick version of this person like Brick Astley, Brickj Minaj, etc. For extra points, tweet it, tag us and score +5 for each like.",
|
||||
proof: "Share your art or link to the tweet in the “side-quest” channel.",
|
||||
},
|
||||
{
|
||||
points: "SEO Sage (50-250 Points)",
|
||||
quest: "Provide detailed SEO recommendations or improvements for our main website.",
|
||||
proof: "Share your insights.",
|
||||
},
|
||||
{
|
||||
points: "Community Connector (50 points each, up to 250 points)",
|
||||
quest:
|
||||
"Introduce and onboard new members to the community. Have them join Discord and reply to their automated welcome message with your discord handle (in “say-hi” channel).",
|
||||
proof: "New member joined and commented with your Discord handle",
|
||||
},
|
||||
{
|
||||
points: "Feedback Fanatic (50 Points)",
|
||||
quest: "Fill out our feedback survey after the hackathon with suggestions for improvement.",
|
||||
proof: "Submit the survey.",
|
||||
},
|
||||
{
|
||||
points: "Side Quest Babo (500 Points)",
|
||||
quest: "Complete all side quests.",
|
||||
proof: "All quests marked as completed.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -212,6 +264,155 @@ const FAQ = [
|
||||
},
|
||||
];
|
||||
|
||||
const Leaderboard = [
|
||||
{
|
||||
name: "Piyush",
|
||||
points: "550",
|
||||
link: "https://github.com/gupta-piyush19",
|
||||
},
|
||||
{
|
||||
name: "Suman",
|
||||
points: "200",
|
||||
},
|
||||
{
|
||||
name: "Subhdeep",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Pratik",
|
||||
points: "250",
|
||||
},
|
||||
{
|
||||
name: "Karuppiah",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Arth",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Neztep",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Kelvin Parmar",
|
||||
points: "200",
|
||||
},
|
||||
{
|
||||
name: "Naitik Kapadia (Arjun)",
|
||||
points: "200",
|
||||
},
|
||||
{
|
||||
name: "Yashhhh",
|
||||
points: "200",
|
||||
},
|
||||
{
|
||||
name: "vishleshak",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Ashu999",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Sachin H",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Suraj Jadhav",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Vishrut",
|
||||
points: "250",
|
||||
},
|
||||
{
|
||||
name: "cataxcab",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Eldemarkki",
|
||||
points: "500",
|
||||
},
|
||||
{
|
||||
name: "Suyash",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Rohan Gupta",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Nafees Nazik",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "monk",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Pratik Tiwari (Pratik)",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Ardash Malviya",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Aditya Deshlahre",
|
||||
points: "550",
|
||||
link: "https://github.com/adityadeshlahre",
|
||||
},
|
||||
{
|
||||
name: "Rutam",
|
||||
points: "350",
|
||||
},
|
||||
{
|
||||
name: "Sagnik Sahoo",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Prasoon Mahawar",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Dushmanta",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Arjavv",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Ashish Khare",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Rohit Mondal",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "noobcoder",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Rayyan Alam (Rayy)",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Ayush",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Zechariah",
|
||||
points: "100",
|
||||
},
|
||||
{
|
||||
name: "Rajarshi Misra",
|
||||
points: "100",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FormTribeHackathon() {
|
||||
// dark mode fix
|
||||
useEffect(() => {
|
||||
@@ -234,7 +435,7 @@ export default function FormTribeHackathon() {
|
||||
Write code, win a Macbook 🔥
|
||||
</a>
|
||||
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
|
||||
<span className="xl:inline">Let's ship Open Source Typeform in Hacktoberfest</span>
|
||||
<span className="xl:inline">Let's ship Open Source Typeform during Hacktoberfest</span>
|
||||
</h1>
|
||||
|
||||
<p className="xs:max-w-none mx-auto mt-3 max-w-xs text-base text-slate-500 dark:text-slate-400 sm:text-lg md:mt-6 md:text-xl">
|
||||
@@ -400,6 +601,7 @@ export default function FormTribeHackathon() {
|
||||
<li>🎉 1 x MacBook Air M2</li>
|
||||
<li>🎉 3 x Limited FormTribe Premium Hoodie</li>
|
||||
<li>🎉 10 x Limited FormTribe Premium Shirt</li>
|
||||
<li>🎉 10 x 250h for Gitpod</li>
|
||||
<li>🎉 50 x Sets of Formbricks Stickers</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -462,40 +664,57 @@ export default function FormTribeHackathon() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex h-64 items-center justify-center rounded-lg bg-slate-200 text-slate-600">
|
||||
<div className="text-center">
|
||||
<p>No issues released yet.</p>
|
||||
<a
|
||||
href="https://formbricks.com/discord"
|
||||
target="_blank"
|
||||
className="text-slate-700 underline decoration-[#013C27] underline-offset-4">
|
||||
Join Discord to get notified first.
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
href="https://github.com/formbricks/formbricks/issues"
|
||||
target="_blank"
|
||||
className="mx-auto mt-12 bg-gradient-to-br from-[#032E1E] via-[#032E1E] to-[#013C27] px-20 text-white ">
|
||||
View FormTribe Issues on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Side Quests */}
|
||||
<div className="mt-16">
|
||||
<div className="mt-16" id="side-quests">
|
||||
<h3 className="font-kablammo my-4 text-4xl font-bold text-slate-800">
|
||||
🏰 Side Quests: Increase your chances
|
||||
</h3>
|
||||
<p className="w-3/4 text-slate-600">
|
||||
While code contributions are what gives the most points, everyone gets to bump up their chance of
|
||||
winning. Here is a list of side quests you can complete:{" "}
|
||||
winning. Here is a list of side quests you can complete:
|
||||
</p>
|
||||
<div className="mt-8 blur">
|
||||
{SideQuests.map((quest) => (
|
||||
<div key={quest.points} className="mb-2 flex select-none items-center gap-x-4">
|
||||
<div className="text-2xl">✅</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-slate-700">
|
||||
{quest.points} <span className="font-normal">{quest.quest}</span>
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
{SideQuests.map((quest) => (
|
||||
<div key={quest.points}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="mb-2 flex items-center gap-x-6">
|
||||
<div className="text-2xl">✅</div>
|
||||
<p className="text-left font-bold text-slate-700">
|
||||
{quest.points}: <span className="font-normal">{quest.quest}</span>
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={"top"}>
|
||||
<p className="py-2 text-center text-slate-500 dark:text-slate-400">
|
||||
<p className="mt-1 text-sm text-slate-600">Proof: {quest.proof}</p>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
href="https://formbricks.notion.site/FormTribe-Side-Quests-4ab3b294cfa04e94b77dfddd66378ea2?pvs=4"
|
||||
target="_blank"
|
||||
className="mt-6 bg-gradient-to-br from-[#032E1E] via-[#032E1E] to-[#013C27] text-white ">
|
||||
Keep track with Notion Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* The Leaderboard */}
|
||||
|
||||
<SectionHeading
|
||||
@@ -504,13 +723,21 @@ export default function FormTribeHackathon() {
|
||||
title="The Leaderboard"
|
||||
description="We keep track of all contributions and side quests in Discord. Join to take part!"
|
||||
/>
|
||||
<div className="mt-12 flex h-64 items-center justify-center rounded-lg bg-slate-200 text-slate-600">
|
||||
<div className="text-center">
|
||||
<p>Not live yet.</p>
|
||||
<a href="#join" className="pl-2 text-slate-700 underline decoration-[#013C27] underline-offset-4">
|
||||
Sign up to get notified on kick-off.
|
||||
</a>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className=" grid grid-cols-2 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="m-2 pl-6">User</div>
|
||||
<div className="m-2 pr-6 text-right">Points</div>
|
||||
</div>
|
||||
{Leaderboard.sort((a, b) => parseInt(b.points) - parseInt(a.points)).map((player) => (
|
||||
<a href={player.link} key={player.name} className="w-full" target="_blank">
|
||||
<div className="m-4 grid grid-cols-2 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="m-2 font-medium text-slate-900">{player.name}</div>
|
||||
</div>
|
||||
<div className="m-2 my-auto text-right text-sm text-slate-900">{player.points} Points</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The Timeline */}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import Layout from "@/components/shared/Layout";
|
||||
import Hero from "@/components/home/Hero";
|
||||
import Features from "@/components/home/Features";
|
||||
import Highlights from "@/components/home/Highlights";
|
||||
import BreakerCTA from "@/components/shared/BreakerCTA";
|
||||
import Steps from "@/components/home/Steps";
|
||||
import Pricing from "@/components/shared/Pricing";
|
||||
import GitHubSponsorship from "@/components/home/GitHubSponsorship";
|
||||
import BestPractices from "@/components/shared/BestPractices";
|
||||
import Faq from "@/components/home/Faq";
|
||||
import Features from "@/components/home/Features";
|
||||
import GitHubSponsorship from "@/components/home/GitHubSponsorship";
|
||||
import Hero from "@/components/home/Hero";
|
||||
import Highlights from "@/components/home/Highlights";
|
||||
import Steps from "@/components/home/Steps";
|
||||
import BestPractices from "@/components/shared/BestPractices";
|
||||
import BreakerCTA from "@/components/shared/BreakerCTA";
|
||||
import Layout from "@/components/shared/Layout";
|
||||
|
||||
const IndexPage = () => (
|
||||
<Layout
|
||||
@@ -43,7 +42,7 @@ const IndexPage = () => (
|
||||
href="https://app.formbricks.com/auth/signup"
|
||||
inverted
|
||||
/>
|
||||
<Pricing />
|
||||
|
||||
<Faq />
|
||||
</Layout>
|
||||
);
|
||||
|
||||
144
apps/formbricks-com/pages/pricing.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import HeroTitle from "@/components/shared/HeroTitle";
|
||||
import Layout from "@/components/shared/Layout";
|
||||
import { OpenSourceInfo } from "@/components/shared/OpenSourceInfo";
|
||||
import { GetStartedWithPricing } from "@/components/shared/PricingGetStarted";
|
||||
import { PricingCalculator } from "../components/shared/PricingCalculator";
|
||||
import { PricingTable } from "../components/shared/PricingTable";
|
||||
|
||||
const inProductSurveys = {
|
||||
leadRow: {
|
||||
title: "In-Product Surveys",
|
||||
free: (
|
||||
<div>
|
||||
<span>5000 tracked users</span> <span className="text-slate-400">/mo</span>{" "}
|
||||
</div>
|
||||
),
|
||||
paid: "Unlimited",
|
||||
},
|
||||
features: [
|
||||
{ name: "Unlimited Surveys", free: true, paid: true },
|
||||
{ name: "Granular Targeting", free: true, paid: true },
|
||||
{ name: "30+ Templates", free: true, paid: true },
|
||||
{ name: "API Access", free: true, paid: true },
|
||||
{ name: "Third-Party Integrations", free: true, paid: true },
|
||||
{ name: "Unlimited Team Members", free: true, paid: true },
|
||||
{ name: "Unlimited Responses per Survey", free: true, paid: true },
|
||||
{ name: "Advanced User Targeting", free: false, paid: true },
|
||||
{ name: "Multi Language", free: false, paid: true },
|
||||
|
||||
{
|
||||
name: "Custom URL for Link Surveys",
|
||||
free: "10$/mo",
|
||||
paid: "10$/mo",
|
||||
addOnText: "Free if you self-host",
|
||||
},
|
||||
{
|
||||
name: "Remove Formbricks Branding",
|
||||
free: "10$/mo",
|
||||
paid: "10$/mo",
|
||||
addOnText: "Free if you self-host",
|
||||
},
|
||||
],
|
||||
endRow: {
|
||||
title: "In-Product Surveys Pricing",
|
||||
free: "Free",
|
||||
paid: (
|
||||
<div>
|
||||
<span>Free</span> <span className="text-slate-400">up to 5000 tracked users/mo, then </span>
|
||||
<span>$0.005</span>
|
||||
<span className="text-slate-400"> / tracked user</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const linkSurveys = {
|
||||
leadRow: {
|
||||
title: "Link Surveys",
|
||||
free: <span>Unlimited</span>,
|
||||
paid: "Unlimited",
|
||||
},
|
||||
|
||||
features: [
|
||||
{ name: "Unlimited Surveys", free: true, paid: true },
|
||||
{ name: "Unlimited Responses", free: true, paid: true },
|
||||
{ name: "Partial Responses", free: true, paid: true },
|
||||
{ name: "⚙️ URL Shortener", free: true, paid: true },
|
||||
{ name: "⚙️ Recall Information", free: true, paid: true },
|
||||
{ name: "⚙️ Hidden Field Questions", free: true, paid: true },
|
||||
{ name: "⚙️ Time to Complete Metadata", free: true, paid: true },
|
||||
{ name: "⚙️ File Upload", free: true, paid: true },
|
||||
{ name: "⚙️ Signature Question", free: true, paid: true },
|
||||
{ name: "⚙️ Question Grouping", free: true, paid: true },
|
||||
{ name: "⚙️ Add Media to Questions", free: true, paid: true },
|
||||
],
|
||||
|
||||
endRow: {
|
||||
title: "Link Surveys Pricing",
|
||||
free: "Free",
|
||||
paid: "Free",
|
||||
},
|
||||
};
|
||||
|
||||
const integrations = {
|
||||
leadRow: {
|
||||
title: "Integrations",
|
||||
free: <span>Unlimited</span>,
|
||||
paid: "Unlimited",
|
||||
},
|
||||
features: [
|
||||
{ name: "Webhooks", free: true, paid: true },
|
||||
{ name: "Zapier", free: true, paid: true },
|
||||
{ name: "Google Sheets", free: true, paid: true },
|
||||
{ name: "n8n", free: true, paid: true },
|
||||
{ name: "Make", free: true, paid: true },
|
||||
],
|
||||
endRow: {
|
||||
title: "Integrations Pricing",
|
||||
free: "Free",
|
||||
paid: "Free",
|
||||
},
|
||||
};
|
||||
|
||||
const PricingPage = () => {
|
||||
return (
|
||||
<Layout
|
||||
title="Pricing | Formbricks Open Source Experience Management"
|
||||
description="Choose what's best for you! All our plans start free.">
|
||||
<HeroTitle
|
||||
headingPt1=""
|
||||
headingTeal="Pricing"
|
||||
subheading="Choose what's best for you! All our plans start free."
|
||||
/>
|
||||
<div className="space-y-24">
|
||||
<div>
|
||||
<GetStartedWithPricing showDetailed={true} />
|
||||
|
||||
<PricingTable
|
||||
leadRow={inProductSurveys.leadRow}
|
||||
pricing={inProductSurveys.features}
|
||||
endRow={inProductSurveys.endRow}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PricingTable
|
||||
leadRow={linkSurveys.leadRow}
|
||||
pricing={linkSurveys.features}
|
||||
endRow={linkSurveys.endRow}
|
||||
/>
|
||||
|
||||
<PricingTable
|
||||
leadRow={integrations.leadRow}
|
||||
pricing={integrations.features}
|
||||
endRow={integrations.endRow}
|
||||
/>
|
||||
<div>
|
||||
<PricingCalculator />
|
||||
<OpenSourceInfo />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
@@ -7,7 +7,6 @@ import { useEffect } from "react";
|
||||
|
||||
type UsageAttributesUpdaterProps = {
|
||||
numSurveys: number;
|
||||
totalSubmissions: number;
|
||||
};
|
||||
|
||||
export default function FormbricksClient({ session }) {
|
||||
@@ -19,31 +18,23 @@ export default function FormbricksClient({ session }) {
|
||||
});
|
||||
formbricks.setUserId(session.user.id);
|
||||
formbricks.setEmail(session.user.email);
|
||||
if (session.user.teams?.length > 0) {
|
||||
formbricks.setAttribute("Plan", session.user.teams[0].plan);
|
||||
formbricks.setAttribute("Name", session?.user?.name);
|
||||
}
|
||||
}
|
||||
}, [session]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateUsageAttributes = (numSurveys, totalSubmissions) => {
|
||||
const updateUsageAttributes = (numSurveys) => {
|
||||
if (!formbricksEnabled) return;
|
||||
|
||||
if (numSurveys >= 3) {
|
||||
formbricks.setAttribute("HasThreeSurveys", "true");
|
||||
}
|
||||
|
||||
if (totalSubmissions >= 20) {
|
||||
formbricks.setAttribute("HasTwentySubmissions", "true");
|
||||
}
|
||||
};
|
||||
|
||||
export function UsageAttributesUpdater({ numSurveys, totalSubmissions }: UsageAttributesUpdaterProps) {
|
||||
export function UsageAttributesUpdater({ numSurveys }: UsageAttributesUpdaterProps) {
|
||||
useEffect(() => {
|
||||
updateUsageAttributes(numSurveys, totalSubmissions);
|
||||
}, [numSurveys, totalSubmissions]);
|
||||
updateUsageAttributes(numSurveys);
|
||||
}, [numSurveys]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ export default function PosthogIdentify({ session }: { session: Session }) {
|
||||
useEffect(() => {
|
||||
if (posthogEnabled && session.user && posthog) {
|
||||
posthog.identify(session.user.id);
|
||||
if (session.user.teams?.length > 0) {
|
||||
posthog?.group("team", session.user.teams[0].id);
|
||||
}
|
||||
}
|
||||
}, [session, posthog]);
|
||||
|
||||
|
||||
@@ -22,5 +22,5 @@ export default function ActionsAttributesTabs({ activeId, environmentId }: Actio
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondNavbar tabs={tabs} activeId={activeId} />;
|
||||
return <SecondNavbar tabs={tabs} activeId={activeId} environmentId={environmentId} />;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import { Label } from "@formbricks/ui";
|
||||
import { useEventClass } from "@/lib/eventClasses/eventClasses";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/v1/actionClasses";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getActionCountInLastHourAction,
|
||||
getActionCountInLast24HoursAction,
|
||||
getActionCountInLast7DaysAction,
|
||||
GetActiveInactiveSurveysAction,
|
||||
} from "./actions";
|
||||
interface ActivityTabProps {
|
||||
environmentId: string;
|
||||
actionClassId: string;
|
||||
actionClass: TActionClass;
|
||||
}
|
||||
|
||||
export default function EventActivityTab({ environmentId, actionClassId }: ActivityTabProps) {
|
||||
const { eventClass, isLoadingEventClass, isErrorEventClass } = useEventClass(environmentId, actionClassId);
|
||||
export default function EventActivityTab({ actionClass }: ActivityTabProps) {
|
||||
// const { eventClass, isLoadingEventClass, isErrorEventClass } = useEventClass(environmentId, actionClass.id);
|
||||
|
||||
if (isLoadingEventClass) return <LoadingSpinner />;
|
||||
if (isErrorEventClass) return <ErrorComponent />;
|
||||
const [numEventsLastHour, setNumEventsLastHour] = useState<number | undefined>();
|
||||
const [numEventsLast24Hours, setNumEventsLast24Hours] = useState<number | undefined>();
|
||||
const [numEventsLast7Days, setNumEventsLast7Days] = useState<number | undefined>();
|
||||
const [activeSurveys, setActiveSurveys] = useState<string[] | undefined>();
|
||||
const [inactiveSurveys, setInactiveSurveys] = useState<string[] | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
updateState();
|
||||
|
||||
async function updateState() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [
|
||||
numEventsLastHourData,
|
||||
numEventsLast24HoursData,
|
||||
numEventsLast7DaysData,
|
||||
activeInactiveSurveys,
|
||||
] = await Promise.all([
|
||||
getActionCountInLastHourAction(actionClass.id),
|
||||
getActionCountInLast24HoursAction(actionClass.id),
|
||||
getActionCountInLast7DaysAction(actionClass.id),
|
||||
GetActiveInactiveSurveysAction(actionClass.id),
|
||||
]);
|
||||
setNumEventsLastHour(numEventsLastHourData);
|
||||
setNumEventsLast24Hours(numEventsLast24HoursData);
|
||||
setNumEventsLast7Days(numEventsLast7DaysData);
|
||||
setActiveSurveys(activeInactiveSurveys.activeSurveys);
|
||||
setInactiveSurveys(activeInactiveSurveys.inactiveSurveys);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [actionClass.id]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <ErrorComponent />;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
@@ -24,15 +70,15 @@ export default function EventActivityTab({ environmentId, actionClassId }: Activ
|
||||
<Label className="text-slate-500">Ocurrances</Label>
|
||||
<div className="mt-1 grid w-fit grid-cols-3 rounded-lg border-slate-100 bg-slate-50">
|
||||
<div className="border-r border-slate-200 px-4 py-2 text-center">
|
||||
<p className="font-bold text-slate-800">{eventClass.numEventsLastHour}</p>
|
||||
<p className="font-bold text-slate-800">{numEventsLastHour}</p>
|
||||
<p className="text-xs text-slate-500">last hour</p>
|
||||
</div>
|
||||
<div className="border-r border-slate-200 px-4 py-2 text-center">
|
||||
<p className="font-bold text-slate-800">{eventClass.numEventsLast24Hours}</p>
|
||||
<p className="font-bold text-slate-800">{numEventsLast24Hours}</p>
|
||||
<p className="text-xs text-slate-500">last 24 hours</p>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-center">
|
||||
<p className="font-bold text-slate-800">{eventClass.numEventsLast7Days}</p>
|
||||
<p className="font-bold text-slate-800">{numEventsLast7Days}</p>
|
||||
<p className="text-xs text-slate-500">last week</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,8 +86,8 @@ export default function EventActivityTab({ environmentId, actionClassId }: Activ
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">Active surveys</Label>
|
||||
{eventClass.activeSurveys.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{eventClass.activeSurveys.map((surveyName) => (
|
||||
{activeSurveys?.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{activeSurveys?.map((surveyName) => (
|
||||
<p key={surveyName} className="text-sm text-slate-900">
|
||||
{surveyName}
|
||||
</p>
|
||||
@@ -49,8 +95,8 @@ export default function EventActivityTab({ environmentId, actionClassId }: Activ
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-500">Inactive surveys</Label>
|
||||
{eventClass.inactiveSurveys.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{eventClass.inactiveSurveys.map((surveyName) => (
|
||||
{inactiveSurveys?.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{inactiveSurveys?.map((surveyName) => (
|
||||
<p key={surveyName} className="text-sm text-slate-900">
|
||||
{surveyName}
|
||||
</p>
|
||||
@@ -61,28 +107,28 @@ export default function EventActivityTab({ environmentId, actionClassId }: Activ
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">Created on</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(eventClass.createdAt?.toString())}
|
||||
{convertDateTimeStringShort(actionClass.createdAt?.toString())}
|
||||
</p>
|
||||
</div>{" "}
|
||||
<div>
|
||||
<Label className=" text-xs font-normal text-slate-500">Last updated</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(eventClass.updatedAt?.toString())}
|
||||
{convertDateTimeStringShort(actionClass.updatedAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-xs font-normal text-slate-500">Type</Label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||
{eventClass.type === "code" ? (
|
||||
{actionClass.type === "code" ? (
|
||||
<CodeBracketIcon />
|
||||
) : eventClass.type === "noCode" ? (
|
||||
) : actionClass.type === "noCode" ? (
|
||||
<CursorArrowRaysIcon />
|
||||
) : eventClass.type === "automatic" ? (
|
||||
) : actionClass.type === "automatic" ? (
|
||||
<SparklesIcon />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(eventClass.type)}</p>
|
||||
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(actionClass.type)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ActionDetailModal({
|
||||
const tabs = [
|
||||
{
|
||||
title: "Activity",
|
||||
children: <EventActivityTab environmentId={environmentId} actionClassId={actionClass.id} />,
|
||||
children: <EventActivityTab actionClass={actionClass} />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
|
||||
@@ -9,11 +9,14 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { testURLmatch } from "./testURLmatch";
|
||||
import { deleteActionClass, updateActionClass } from "@formbricks/lib/services/actionClass";
|
||||
import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
|
||||
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector";
|
||||
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector";
|
||||
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector";
|
||||
import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
environmentId: string;
|
||||
@@ -77,10 +80,11 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen
|
||||
const filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as NoCodeConfig);
|
||||
const updatedData: TActionClassInput = {
|
||||
...data,
|
||||
environmentId,
|
||||
noCodeConfig: filteredNoCodeConfig,
|
||||
type: "noCode",
|
||||
} as TActionClassInput;
|
||||
await updateActionClass(environmentId, actionClass.id, updatedData);
|
||||
await updateActionClassAction(environmentId, actionClass.id, updatedData);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
toast.success("Action updated successfully");
|
||||
@@ -94,7 +98,7 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen
|
||||
const handleDeleteAction = async () => {
|
||||
try {
|
||||
setIsDeletingAction(true);
|
||||
await deleteActionClass(environmentId, actionClass.id);
|
||||
await deleteActionClassAction(environmentId, actionClass.id);
|
||||
router.refresh();
|
||||
toast.success("Action deleted successfully");
|
||||
setOpen(false);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { testURLmatch } from "./testURLmatch";
|
||||
import { createActionClass } from "@formbricks/lib/services/actionClass";
|
||||
import {
|
||||
TActionClassInput,
|
||||
TActionClassNoCodeConfig,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector";
|
||||
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector";
|
||||
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector";
|
||||
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
||||
|
||||
interface AddNoCodeActionModalProps {
|
||||
environmentId: string;
|
||||
@@ -84,7 +84,7 @@ export default function AddNoCodeActionModal({
|
||||
type: "noCode",
|
||||
} as TActionClassInput;
|
||||
|
||||
const newActionClass: TActionClass = await createActionClass(environmentId, updatedData);
|
||||
const newActionClass: TActionClass = await createActionClassAction(updatedData);
|
||||
if (setActionClassArray) {
|
||||
setActionClassArray((prevActionClassArray: TActionClass[]) => [
|
||||
...prevActionClassArray,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use server";
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createActionClass, deleteActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { canUserAccessActionClass } from "@formbricks/lib/actionClass/auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { TActionClassInput } from "@formbricks/types/v1/actionClasses";
|
||||
|
||||
import {
|
||||
getActionCountInLast24Hours,
|
||||
getActionCountInLast7Days,
|
||||
getActionCountInLastHour,
|
||||
} from "@formbricks/lib/services/actions";
|
||||
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export async function deleteActionClassAction(environmentId, actionClassId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteActionClass(environmentId, actionClassId);
|
||||
}
|
||||
|
||||
export async function updateActionClassAction(
|
||||
environmentId: string,
|
||||
actionClassId: string,
|
||||
updatedAction: Partial<TActionClassInput>
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await updateActionClass(environmentId, actionClassId, updatedAction);
|
||||
}
|
||||
|
||||
export async function createActionClassAction(action: TActionClassInput) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, action.environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createActionClass(action.environmentId, action);
|
||||
}
|
||||
|
||||
export const getActionCountInLastHourAction = async (actionClassId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getActionCountInLastHour(actionClassId);
|
||||
};
|
||||
|
||||
export const getActionCountInLast24HoursAction = async (actionClassId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getActionCountInLast24Hours(actionClassId);
|
||||
};
|
||||
|
||||
export const getActionCountInLast7DaysAction = async (actionClassId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getActionCountInLast7Days(actionClassId);
|
||||
};
|
||||
|
||||
export const GetActiveInactiveSurveysAction = async (
|
||||
actionClassId: string
|
||||
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const surveys = await getSurveysByActionClassId(actionClassId);
|
||||
const response = {
|
||||
activeSurveys: surveys.filter((s) => s.status === "inProgress").map((survey) => survey.name),
|
||||
inactiveSurveys: surveys.filter((s) => s.status !== "inProgress").map((survey) => survey.name),
|
||||
};
|
||||
return response;
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import ActionClassesTable from "@/app/(app)/environments/[environmentId]/(action
|
||||
import ActionClassDataRow from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionRowData";
|
||||
import ActionTableHeading from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionTableHeading";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getActionClasses } from "@formbricks/lib/services/actionClass";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { GetActiveInactiveSurveysAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useAttributeClass } from "@/lib/attributeClasses/attributeClasses";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
|
||||
import { ErrorComponent, Label } from "@formbricks/ui";
|
||||
import { TagIcon } from "@heroicons/react/24/solid";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface EventActivityTabProps {
|
||||
attributeClassId: string;
|
||||
environmentId: string;
|
||||
attributeClass: TAttributeClass;
|
||||
}
|
||||
|
||||
export default function AttributeActivityTab({ environmentId, attributeClassId }: EventActivityTabProps) {
|
||||
const { attributeClass, isLoadingAttributeClass, isErrorAttributeClass } = useAttributeClass(
|
||||
environmentId,
|
||||
attributeClassId
|
||||
);
|
||||
export default function AttributeActivityTab({ attributeClass }: EventActivityTabProps) {
|
||||
const [activeSurveys, setActiveSurveys] = useState<string[] | undefined>();
|
||||
const [inactiveSurveys, setInactiveSurveys] = useState<string[] | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
if (isLoadingAttributeClass) return <LoadingSpinner />;
|
||||
if (isErrorAttributeClass) return <ErrorComponent />;
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
getSurveys();
|
||||
|
||||
async function getSurveys() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const activeInactive = await GetActiveInactiveSurveysAction(attributeClass.id);
|
||||
setActiveSurveys(activeInactive.activeSurveys);
|
||||
setInactiveSurveys(activeInactive.inactiveSurveys);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [attributeClass.id]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <ErrorComponent />;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
<div className="col-span-2 space-y-4 pr-6">
|
||||
<div>
|
||||
<Label className="text-slate-500">Active surveys</Label>
|
||||
{attributeClass.activeSurveys.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{attributeClass.activeSurveys.map((surveyName) => (
|
||||
{activeSurveys?.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{activeSurveys?.map((surveyName) => (
|
||||
<p key={surveyName} className="text-sm text-slate-900">
|
||||
{surveyName}
|
||||
</p>
|
||||
@@ -33,8 +55,8 @@ export default function AttributeActivityTab({ environmentId, attributeClassId }
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-500">Inactive surveys</Label>
|
||||
{attributeClass.inactiveSurveys.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{attributeClass.inactiveSurveys.map((surveyName) => (
|
||||
{inactiveSurveys?.length === 0 && <p className="text-sm text-slate-900">-</p>}
|
||||
{inactiveSurveys?.map((surveyName) => (
|
||||
<p key={surveyName} className="text-sm text-slate-900">
|
||||
{surveyName}
|
||||
</p>
|
||||
|
||||
@@ -8,11 +8,9 @@ import { useMemo } from "react";
|
||||
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
|
||||
|
||||
export default function AttributeClassesTable({
|
||||
environmentId,
|
||||
attributeClasses,
|
||||
children: [TableHeading, howToAddAttributeButton, attributeRows],
|
||||
}: {
|
||||
environmentId: string;
|
||||
attributeClasses: TAttributeClass[];
|
||||
children: [JSX.Element, JSX.Element, JSX.Element[]];
|
||||
}) {
|
||||
@@ -69,7 +67,6 @@ export default function AttributeClassesTable({
|
||||
))}
|
||||
</div>
|
||||
<AttributeDetailModal
|
||||
environmentId={environmentId}
|
||||
open={isAttributeDetailModalOpen}
|
||||
setOpen={setAttributeDetailModalOpen}
|
||||
attributeClass={activeAttributeClass}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import ModalWithTabs from "@/components/shared/ModalWithTabs";
|
||||
import { TagIcon } from "@heroicons/react/24/solid";
|
||||
import type { AttributeClass } from "@prisma/client";
|
||||
import AttributeActivityTab from "./AttributeActivityTab";
|
||||
import AttributeSettingsTab from "./AttributeSettingsTab";
|
||||
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
|
||||
|
||||
interface AttributeDetailModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
attributeClass: AttributeClass;
|
||||
attributeClass: TAttributeClass;
|
||||
}
|
||||
|
||||
export default function AttributeDetailModal({
|
||||
environmentId,
|
||||
open,
|
||||
setOpen,
|
||||
attributeClass,
|
||||
}: AttributeDetailModalProps) {
|
||||
export default function AttributeDetailModal({ open, setOpen, attributeClass }: AttributeDetailModalProps) {
|
||||
const tabs = [
|
||||
{
|
||||
title: "Activity",
|
||||
children: <AttributeActivityTab environmentId={environmentId} attributeClassId={attributeClass.id} />,
|
||||
children: <AttributeActivityTab attributeClass={attributeClass} />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { getSurveysByAttributeClassId } from "@formbricks/lib/survey/service";
|
||||
|
||||
export const GetActiveInactiveSurveysAction = async (
|
||||
attributeClassId: string
|
||||
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {
|
||||
const surveys = await getSurveysByAttributeClassId(attributeClassId);
|
||||
const response = {
|
||||
activeSurveys: surveys.filter((s) => s.status === "inProgress").map((survey) => survey.name),
|
||||
inactiveSurveys: surveys.filter((s) => s.status !== "inProgress").map((survey) => survey.name),
|
||||
};
|
||||
return response;
|
||||
};
|
||||
@@ -14,9 +14,10 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function AttributesPage({ params }) {
|
||||
let attributeClasses = await getAttributeClasses(params.environmentId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AttributeClassesTable environmentId={params.environmentId} attributeClasses={attributeClasses}>
|
||||
<AttributeClassesTable attributeClasses={attributeClasses}>
|
||||
<AttributeTableHeading />
|
||||
<HowToAddAttributesButton />
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { createProduct } from "@/lib/products/products";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -19,9 +19,9 @@ export default function AddProductModal({ environmentId, open, setOpen }: AddPro
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const submitProduct = async (data) => {
|
||||
const submitProduct = async (data: { name: string }) => {
|
||||
setLoading(true);
|
||||
const newEnv = await createProduct(environmentId, data);
|
||||
const newEnv = await createProductAction(environmentId, data.name);
|
||||
router.push(`/environments/${newEnv.id}/`);
|
||||
setOpen(false);
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import Navigation from "@/app/(app)/environments/[environmentId]/Navigation";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getProducts } from "@formbricks/lib/services/product";
|
||||
import { getTeamByEnvironmentId, getTeamsByUserId } from "@formbricks/lib/services/team";
|
||||
@@ -11,6 +11,7 @@ import type { Session } from "next-auth";
|
||||
interface EnvironmentsNavbarProps {
|
||||
environmentId: string;
|
||||
session: Session;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export default async function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
|
||||
@@ -41,6 +42,7 @@ export default async function EnvironmentsNavbar({ environmentId, session }: Env
|
||||
products={products}
|
||||
environments={environments}
|
||||
session={session}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { formbricksLogout } from "@/lib/formbricks";
|
||||
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TTeam } from "@formbricks/types/v1/teams";
|
||||
@@ -71,6 +70,7 @@ interface NavigationProps {
|
||||
team: TTeam;
|
||||
products: TProduct[];
|
||||
environments: TEnvironment[];
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export default function Navigation({
|
||||
@@ -80,6 +80,7 @@ export default function Navigation({
|
||||
session,
|
||||
products,
|
||||
environments,
|
||||
isFormbricksCloud,
|
||||
}: NavigationProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -171,7 +172,7 @@ export default function Navigation({
|
||||
icon: CreditCardIcon,
|
||||
label: "Billing & Plan",
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -453,7 +454,7 @@ export default function Navigation({
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
{isFormbricksCloud && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,132 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createMembership } from "@formbricks/lib/services/membership";
|
||||
import { createProduct } from "@formbricks/lib/services/product";
|
||||
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||
import { deleteSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { Team } from "@prisma/client";
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
|
||||
const newTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
memberships: {
|
||||
create: {
|
||||
user: { connect: { id: ownerUserId } },
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
products: {
|
||||
create: [
|
||||
{
|
||||
name: "My Product",
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: "production",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "development",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
products: {
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
export async function createTeamAction(teamName: string): Promise<Team> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const newTeam = await createTeam({
|
||||
name: teamName,
|
||||
});
|
||||
|
||||
const teamId = newTeam?.id;
|
||||
await createMembership(newTeam.id, session.user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
},
|
||||
});
|
||||
}
|
||||
await createProduct(newTeam.id, {
|
||||
name: "My Product",
|
||||
});
|
||||
|
||||
return newTeam;
|
||||
}
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await getSurvey(surveyId);
|
||||
|
||||
if (!existingSurvey) {
|
||||
@@ -163,7 +76,9 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage
|
||||
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
|
||||
: prismaClient.JsonNull,
|
||||
|
||||
singleUse: existingSurvey.singleUse
|
||||
? JSON.parse(JSON.stringify(existingSurvey.singleUse))
|
||||
: prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail
|
||||
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
|
||||
: prismaClient.JsonNull,
|
||||
@@ -177,6 +92,24 @@ export async function copyToOtherEnvironmentAction(
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
@@ -294,6 +227,7 @@ export async function copyToOtherEnvironmentAction(
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
@@ -301,5 +235,32 @@ export async function copyToOtherEnvironmentAction(
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export const createProductAction = async (environmentId: string, productName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
if (!team) throw new ResourceNotFoundError("Team from environment", environmentId);
|
||||
|
||||
const product = await createProduct(team.id, {
|
||||
name: productName,
|
||||
});
|
||||
|
||||
// get production environment
|
||||
const productionEnvironment = product.environments.find((environment) => environment.type === "production");
|
||||
if (!productionEnvironment) throw new ResourceNotFoundError("Production environment", environmentId);
|
||||
|
||||
return productionEnvironment;
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function AddIntegrationModal({
|
||||
setSelectedQuestions(questionIds);
|
||||
}
|
||||
}
|
||||
}, [selectedSurvey]);
|
||||
}, [selectedIntegration, selectedSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
@@ -85,7 +85,7 @@ export default function AddIntegrationModal({
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
}, [selectedIntegration]);
|
||||
}, [selectedIntegration, surveys]);
|
||||
|
||||
const linkSheet = async () => {
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import GoogleSheetLogo from "@/images/google-sheets-small.png";
|
||||
import FormbricksLogo from "@/images/logo.svg";
|
||||
import { authorize } from "@formbricks/lib/client/google";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -12,13 +11,14 @@ import { useState } from "react";
|
||||
interface ConnectProps {
|
||||
enabled: boolean;
|
||||
environmentId: string;
|
||||
webAppUrl: string;
|
||||
}
|
||||
|
||||
export default function Connect({ enabled, environmentId }: ConnectProps) {
|
||||
export default function Connect({ enabled, environmentId, webAppUrl }: ConnectProps) {
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const handleGoogleLogin = async () => {
|
||||
setIsConnecting(true);
|
||||
authorize(environmentId, WEBAPP_URL).then((url: string) => {
|
||||
authorize(environmentId, webAppUrl).then((url: string) => {
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
}
|
||||
|
||||
@@ -11,21 +11,24 @@ import {
|
||||
} from "@formbricks/types/v1/integrations";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { refreshSheetAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
interface GoogleSheetWrapperProps {
|
||||
enabled: boolean;
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
surveys: TSurvey[];
|
||||
spreadSheetArray: TGoogleSpreadsheet[];
|
||||
googleSheetIntegration: TGoogleSheetIntegration | undefined;
|
||||
webAppUrl: string;
|
||||
}
|
||||
|
||||
export default function GoogleSheetWrapper({
|
||||
enabled,
|
||||
environmentId,
|
||||
environment,
|
||||
surveys,
|
||||
spreadSheetArray,
|
||||
googleSheetIntegration,
|
||||
webAppUrl,
|
||||
}: GoogleSheetWrapperProps) {
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
||||
@@ -37,7 +40,7 @@ export default function GoogleSheetWrapper({
|
||||
>(null);
|
||||
|
||||
const refreshSheet = async () => {
|
||||
const latestSpreadsheets = await refreshSheetAction(environmentId);
|
||||
const latestSpreadsheets = await refreshSheetAction(environment.id);
|
||||
setSpreadsheets(latestSpreadsheets);
|
||||
};
|
||||
|
||||
@@ -46,7 +49,7 @@ export default function GoogleSheetWrapper({
|
||||
{isConnected && googleSheetIntegration ? (
|
||||
<>
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
@@ -55,7 +58,7 @@ export default function GoogleSheetWrapper({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<Home
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
setOpenAddIntegrationModal={setModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
@@ -64,7 +67,7 @@ export default function GoogleSheetWrapper({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Connect enabled={enabled} environmentId={environmentId} />
|
||||
<Connect enabled={enabled} environmentId={environment.id} webAppUrl={webAppUrl} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,14 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TGoogleSheetIntegration, TGoogleSheetsConfigData } from "@formbricks/types/v1/integrations";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface HomeProps {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
googleSheetIntegration: TGoogleSheetIntegration;
|
||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||
setIsConnected: (v: boolean) => void;
|
||||
@@ -19,7 +20,7 @@ interface HomeProps {
|
||||
}
|
||||
|
||||
export default function Home({
|
||||
environmentId,
|
||||
environment,
|
||||
googleSheetIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -83,7 +84,7 @@ export default function Home({
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage="Your google sheet integrations will appear here as soon as you add them. ⏲️"
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import GoogleSheetWrapper from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/GoogleSheetWrapper";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { env } from "@/env.mjs";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getSpreadSheets } from "@formbricks/lib/services/googleSheet";
|
||||
import { getIntegrations } from "@formbricks/lib/services/integrations";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { TGoogleSheetIntegration, TGoogleSpreadsheet } from "@formbricks/types/v1/integrations";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
WEBAPP_URL,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function GoogleSheet({ params }) {
|
||||
const enabled = !!(
|
||||
env.GOOGLE_SHEETS_CLIENT_ID &&
|
||||
env.GOOGLE_SHEETS_CLIENT_SECRET &&
|
||||
env.GOOGLE_SHEETS_REDIRECT_URL
|
||||
);
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
let spreadSheetArray: TGoogleSpreadsheet[] = [];
|
||||
const integrations = await getIntegrations(params.environmentId);
|
||||
const enabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||
const [surveys, integrations, environment] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const googleSheetIntegration: TGoogleSheetIntegration | undefined = integrations?.find(
|
||||
(integration): integration is TGoogleSheetIntegration => integration.type === "googleSheets"
|
||||
);
|
||||
let spreadSheetArray: TGoogleSpreadsheet[] = [];
|
||||
if (googleSheetIntegration && googleSheetIntegration.config.key) {
|
||||
spreadSheetArray = await getSpreadSheets(params.environmentId);
|
||||
}
|
||||
@@ -28,10 +36,11 @@ export default async function GoogleSheet({ params }) {
|
||||
<div className="h-[75vh] w-full">
|
||||
<GoogleSheetWrapper
|
||||
enabled={enabled}
|
||||
environmentId={params.environmentId}
|
||||
environment={environment}
|
||||
surveys={surveys}
|
||||
spreadSheetArray={spreadSheetArray}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,82 +6,121 @@ import n8nLogo from "@/images/n8n.png";
|
||||
import MakeLogo from "@/images/make-small.png";
|
||||
import { Card } from "@formbricks/ui";
|
||||
import Image from "next/image";
|
||||
import { getCountOfWebhooksBasedOnSource } from "@formbricks/lib/services/webhook";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { getIntegrations } from "@formbricks/lib/services/integrations";
|
||||
|
||||
export default async function IntegrationsPage({ params }) {
|
||||
const integrations = await getIntegrations(params.environmentId);
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const [environment, integrations, userWebhooks, zapierWebhooks] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getIntegrations(environmentId),
|
||||
getCountOfWebhooksBasedOnSource(environmentId, "user"),
|
||||
getCountOfWebhooksBasedOnSource(environmentId, "zapier"),
|
||||
]);
|
||||
|
||||
const containsGoogleSheetIntegration = integrations.some(
|
||||
(integration) => integration.type === "googleSheets"
|
||||
);
|
||||
|
||||
const integrationCards = [
|
||||
{
|
||||
docsHref: "https://formbricks.com/docs/getting-started/framework-guides#next-js",
|
||||
docsText: "Docs",
|
||||
docsNewTab: true,
|
||||
label: "Javascript Widget",
|
||||
description: "Integrate Formbricks into your Webapp",
|
||||
icon: <Image src={JsLogo} alt="Javascript Logo" />,
|
||||
connected: environment?.widgetSetupCompleted,
|
||||
statusText: environment?.widgetSetupCompleted ? "Connected" : "Not Connected",
|
||||
},
|
||||
{
|
||||
docsHref: "https://formbricks.com/docs/integrations/zapier",
|
||||
docsText: "Docs",
|
||||
docsNewTab: true,
|
||||
connectHref: "https://zapier.com/apps/formbricks/integrations",
|
||||
connectText: "Connect",
|
||||
connectNewTab: true,
|
||||
label: "Zapier",
|
||||
description: "Integrate Formbricks with 5000+ apps via Zapier",
|
||||
icon: <Image src={ZapierLogo} alt="Zapier Logo" />,
|
||||
connected: zapierWebhooks > 0,
|
||||
statusText:
|
||||
zapierWebhooks === 1 ? "1 zap" : zapierWebhooks === 0 ? "Not Connected" : `${zapierWebhooks} zaps`,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/integrations/webhooks`,
|
||||
connectText: "Manage Webhooks",
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/webhook-api/overview",
|
||||
docsText: "Docs",
|
||||
docsNewTab: true,
|
||||
label: "Webhooks",
|
||||
description: "Trigger Webhooks based on actions in your surveys",
|
||||
icon: <Image src={WebhookLogo} alt="Webhook Logo" />,
|
||||
connected: userWebhooks > 0,
|
||||
statusText:
|
||||
userWebhooks === 1 ? "1 webhook" : userWebhooks === 0 ? "Not Connected" : `${userWebhooks} zaps`,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/integrations/google-sheets`,
|
||||
connectText: `${containsGoogleSheetIntegration ? "Manage Sheets" : "Connect"}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/integrations/google-sheets",
|
||||
docsText: "Docs",
|
||||
docsNewTab: true,
|
||||
label: "Google Sheets",
|
||||
description: "Instantly populate your spreadsheets with survey data",
|
||||
icon: <Image src={GoogleSheetsLogo} alt="Google sheets Logo" />,
|
||||
connected: containsGoogleSheetIntegration ? true : false,
|
||||
statusText: containsGoogleSheetIntegration ? "Connected" : "Not Connected",
|
||||
},
|
||||
{
|
||||
docsHref: "https://formbricks.com/docs/integrations/n8n",
|
||||
docsText: "Docs",
|
||||
docsNewTab: true,
|
||||
connectHref: "https://n8n.io",
|
||||
connectText: "Connect",
|
||||
connectNewTab: true,
|
||||
label: "n8n",
|
||||
description: "Integrate Formbricks with 350+ apps via n8n",
|
||||
icon: <Image src={n8nLogo} alt="n8n Logo" />,
|
||||
},
|
||||
{
|
||||
docsHref: "https://formbricks.com/docs/integrations/make",
|
||||
docsText: "Docs",
|
||||
docsNewTab: true,
|
||||
connectHref: "https://www.make.com/en/integrations/formbricks",
|
||||
connectText: "Connect",
|
||||
connectNewTab: true,
|
||||
label: "Make.com",
|
||||
description: "Integrate Formbricks with 1000+ apps via Make",
|
||||
icon: <Image src={MakeLogo} alt="Make Logo" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
|
||||
<p className="mb-6 text-slate-500">Connect Formbricks with your favorite tools.</p>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/getting-started/framework-guides#next-js"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
label="Javascript Widget"
|
||||
description="Integrate Formbricks into your Webapp"
|
||||
icon={<Image src={JsLogo} alt="Javascript Logo" />}
|
||||
/>
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/integrations/zapier"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
connectHref="https://zapier.com/apps/formbricks/integrations"
|
||||
connectText="Connect"
|
||||
connectNewTab={true}
|
||||
label="Zapier"
|
||||
description="Integrate Formbricks with 5000+ apps via Zapier"
|
||||
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
|
||||
/>
|
||||
<Card
|
||||
connectHref={`/environments/${params.environmentId}/integrations/webhooks`}
|
||||
connectText="Manage Webhooks"
|
||||
connectNewTab={false}
|
||||
docsHref="https://formbricks.com/docs/webhook-api/overview"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
label="Webhooks"
|
||||
description="Trigger Webhooks based on actions in your surveys"
|
||||
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
|
||||
/>
|
||||
<Card
|
||||
connectHref={`/environments/${params.environmentId}/integrations/google-sheets`}
|
||||
connectText={`${containsGoogleSheetIntegration ? "Manage Sheets" : "Connect"}`}
|
||||
connectNewTab={false}
|
||||
docsHref="https://formbricks.com/docs/integrations/google-sheets"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
label="Google Sheets"
|
||||
description="Instantly populate your spreadsheets with survey data"
|
||||
icon={<Image src={GoogleSheetsLogo} alt="Google sheets Logo" />}
|
||||
/>
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/integrations/n8n"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
connectHref="https://n8n.io"
|
||||
connectText="Connect"
|
||||
connectNewTab={true}
|
||||
label="n8n"
|
||||
description="Integrate Formbricks with 350+ apps via n8n"
|
||||
icon={<Image src={n8nLogo} alt="n8n Logo" />}
|
||||
/>
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/integrations/make"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
connectHref="https://www.make.com/en/integrations/formbricks"
|
||||
connectText="Connect"
|
||||
connectNewTab={true}
|
||||
label="Make.com"
|
||||
description="Integrate Formbricks with 1000+ apps via Make"
|
||||
icon={<Image src={MakeLogo} alt="Make Logo" />}
|
||||
/>
|
||||
{integrationCards.map((card) => (
|
||||
<Card
|
||||
key={card.label}
|
||||
docsHref={card.docsHref}
|
||||
docsText={card.docsText}
|
||||
docsNewTab={card.docsNewTab}
|
||||
connectHref={card.connectHref}
|
||||
connectText={card.connectText}
|
||||
connectNewTab={card.connectNewTab}
|
||||
label={card.label}
|
||||
description={card.description}
|
||||
icon={card.icon}
|
||||
connected={card.connected}
|
||||
statusText={card.statusText}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,15 @@ import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import WebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal";
|
||||
import { Webhook } from "lucide-react";
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
export default function WebhookTable({
|
||||
environmentId,
|
||||
environment,
|
||||
webhooks,
|
||||
surveys,
|
||||
children: [TableHeading, webhookRows],
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
webhooks: TWebhook[];
|
||||
surveys: TSurvey[];
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
@@ -24,7 +25,7 @@ export default function WebhookTable({
|
||||
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
|
||||
|
||||
const [activeWebhook, setActiveWebhook] = useState<TWebhook>({
|
||||
environmentId,
|
||||
environmentId: environment.id,
|
||||
id: "",
|
||||
name: "",
|
||||
url: "",
|
||||
@@ -57,7 +58,7 @@ export default function WebhookTable({
|
||||
{webhooks.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage="Your webhooks will appear here as soon as you add them. ⏲️"
|
||||
/>
|
||||
@@ -80,14 +81,14 @@ export default function WebhookTable({
|
||||
)}
|
||||
|
||||
<WebhookModal
|
||||
environmentId={environmentId}
|
||||
environmentId={environment.id}
|
||||
open={isWebhookDetailModalOpen}
|
||||
setOpen={setWebhookDetailModalOpen}
|
||||
webhook={activeWebhook}
|
||||
surveys={surveys}
|
||||
/>
|
||||
<AddWebhookModal
|
||||
environmentId={environmentId}
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
open={isAddWebhookModalOpen}
|
||||
setOpen={setAddWebhookModalOpen}
|
||||
|
||||
@@ -4,21 +4,29 @@ import WebhookRowData from "@/app/(app)/environments/[environmentId]/integration
|
||||
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
|
||||
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getWebhooks } from "@formbricks/lib/services/webhook";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function CustomWebhookPage({ params }) {
|
||||
const webhooks = (await getWebhooks(params.environmentId)).sort((a, b) => {
|
||||
const [webhooksUnsorted, surveys, environment] = await Promise.all([
|
||||
getWebhooks(params.environmentId),
|
||||
getSurveys(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const webhooks = webhooksUnsorted.sort((a, b) => {
|
||||
if (a.createdAt > b.createdAt) return -1;
|
||||
if (a.createdAt < b.createdAt) return 1;
|
||||
return 0;
|
||||
});
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<WebhookTable environmentId={params.environmentId} webhooks={webhooks} surveys={surveys}>
|
||||
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys}>
|
||||
<WebhookTableHeading />
|
||||
{webhooks.map((webhook) => (
|
||||
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
|
||||
|
||||
@@ -5,16 +5,18 @@ import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import FormbricksClient from "../../FormbricksClient";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export default async function EnvironmentLayout({ children, params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user, params.environmentId);
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new Error("User does not have access to this environment");
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -22,7 +24,11 @@ export default async function EnvironmentLayout({ children, params }) {
|
||||
<ResponseFilterProvider>
|
||||
<FormbricksClient session={session} />
|
||||
<ToasterClient />
|
||||
<EnvironmentsNavbar environmentId={params.environmentId} session={session} />
|
||||
<EnvironmentsNavbar
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
<main className="h-full flex-1 overflow-y-auto bg-slate-50">
|
||||
{children}
|
||||
<main />
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
|
||||
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: TActivityFeedItem[];
|
||||
sortByDate: boolean;
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
}
|
||||
|
||||
export default function ActivityFeed({ activities, sortByDate, environmentId }: ActivityFeedProps) {
|
||||
export default function ActivityFeed({ activities, sortByDate, environment }: ActivityFeedProps) {
|
||||
const sortedActivities: TActivityFeedItem[] = activities.sort((a, b) =>
|
||||
sortByDate
|
||||
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
@@ -17,7 +18,7 @@ export default function ActivityFeed({ activities, sortByDate, environmentId }:
|
||||
return (
|
||||
<>
|
||||
{sortedActivities.length === 0 ? (
|
||||
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
|
||||
<EmptySpaceFiller type={"event"} environment={environment} />
|
||||
) : (
|
||||
<div>
|
||||
{sortedActivities.map((activityItem) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityTimeline";
|
||||
import { getActivityTimeline } from "@formbricks/lib/services/activity";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function ActivitySection({
|
||||
environmentId,
|
||||
@@ -8,11 +9,17 @@ export default async function ActivitySection({
|
||||
environmentId: string;
|
||||
personId: string;
|
||||
}) {
|
||||
const activities = await getActivityTimeline(personId);
|
||||
const [activities, environment] = await Promise.all([
|
||||
getActivityTimeline(personId),
|
||||
getEnvironment(environmentId),
|
||||
]);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:col-span-1">
|
||||
<ActivityTimeline environmentId={environmentId} activities={activities} />
|
||||
<ActivityTimeline environment={environment} activities={activities} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import ActivityFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityFeed";
|
||||
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ActivityTimeline({
|
||||
environmentId,
|
||||
environment,
|
||||
activities,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
activities: TActivityFeedItem[];
|
||||
}) {
|
||||
const [activityAscending, setActivityAscending] = useState(true);
|
||||
@@ -30,7 +31,7 @@ export default function ActivityTimeline({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityFeed activities={activities} sortByDate={activityAscending} environmentId={environmentId} />
|
||||
<ActivityFeed activities={activities} sortByDate={activityAscending} environment={environment} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { getPerson } from "@formbricks/lib/services/person";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
|
||||
import { getSessionCount } from "@formbricks/lib/services/session";
|
||||
|
||||
export default async function AttributesSection({ personId }: { personId: string }) {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
export default async function ResponseSection({
|
||||
environmentId,
|
||||
environment,
|
||||
personId,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
personId: string;
|
||||
}) {
|
||||
const responses = await getResponsesByPersonId(personId);
|
||||
const surveyIds = responses?.map((response) => response.surveyId) || [];
|
||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environmentId)) ?? [];
|
||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environment.id)) ?? [];
|
||||
const responsesWithSurvey: TResponseWithSurvey[] =
|
||||
responses?.reduce((acc: TResponseWithSurvey[], response) => {
|
||||
const thisSurvey = surveys.find((survey) => survey?.id === response.surveyId);
|
||||
@@ -26,5 +27,5 @@ export default async function ResponseSection({
|
||||
return acc;
|
||||
}, []) || [];
|
||||
|
||||
return <ResponseTimeline environmentId={environmentId} responses={responsesWithSurvey} />;
|
||||
return <ResponseTimeline environment={environment} responses={responsesWithSurvey} />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import ResponseFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ResponseTimeline({
|
||||
environmentId,
|
||||
environment,
|
||||
responses,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
responses: TResponseWithSurvey[];
|
||||
}) {
|
||||
const [responsesAscending, setResponsesAscending] = useState(true);
|
||||
@@ -29,7 +30,7 @@ export default function ResponseTimeline({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ResponseFeed responses={responses} sortByDate={responsesAscending} environmentId={environmentId} />
|
||||
<ResponseFeed responses={responses} sortByDate={responsesAscending} environment={environment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,20 +3,22 @@ import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import Link from "next/link";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
export default function ResponseFeed({
|
||||
responses,
|
||||
sortByDate,
|
||||
environmentId,
|
||||
environment,
|
||||
}: {
|
||||
responses: TResponseWithSurvey[];
|
||||
sortByDate: boolean;
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{responses.length === 0 ? (
|
||||
<EmptySpaceFiller type="response" environmentId={environmentId} />
|
||||
<EmptySpaceFiller type="response" environment={environment} />
|
||||
) : (
|
||||
<div>
|
||||
{responses
|
||||
@@ -53,15 +55,16 @@ export default function ResponseFeed({
|
||||
<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/${environment.id}/surveys/${response.survey.id}/summary`}>
|
||||
{response.survey.name}
|
||||
</Link>
|
||||
<SurveyStatusIndicator
|
||||
status={response.survey.status}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{response.survey.questions.map((question) => (
|
||||
<div key={question.id}>
|
||||
@@ -74,6 +77,18 @@ export default function ResponseFeed({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-sm text-slate-500">{response.singleUseId}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-sm text-slate-500">Single Use Id</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,13 @@ import AttributesSection from "@/app/(app)/environments/[environmentId]/people/[
|
||||
import ResponseSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection";
|
||||
import HeadingSection from "@/app/(app)/environments/[environmentId]/people/[personId]/HeadingSection";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function PersonPage({ params }) {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<main className="mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@@ -15,7 +20,7 @@ export default async function PersonPage({ params }) {
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
|
||||
<AttributesSection personId={params.personId} />
|
||||
<ResponseSection environmentId={params.environmentId} personId={params.personId} />
|
||||
<ResponseSection environment={environment} personId={params.personId} />
|
||||
<ActivitySection environmentId={params.environmentId} personId={params.personId} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ export const revalidate = REVALIDATION_INTERVAL;
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import { truncateMiddle } from "@/lib/utils";
|
||||
import { PEOPLE_PER_PAGE, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { getPeople, getPeopleCount } from "@formbricks/lib/services/person";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { Pagination, PersonAvatar } from "@formbricks/ui";
|
||||
@@ -19,7 +20,13 @@ export default async function PeoplePage({
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const pageNumber = searchParams.page ? parseInt(searchParams.page as string) : 1;
|
||||
const totalPeople = await getPeopleCount(params.environmentId);
|
||||
const [environment, totalPeople] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getPeopleCount(params.environmentId),
|
||||
]);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const maxPageNumber = Math.ceil(totalPeople / PEOPLE_PER_PAGE);
|
||||
let hidePagination = false;
|
||||
|
||||
@@ -37,7 +44,7 @@ export default async function PeoplePage({
|
||||
{people.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environmentId={params.environmentId}
|
||||
environment={environment}
|
||||
emptyMessage="Your users will appear here as soon as they use your app ⏲️"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import { useTeam } from "@/lib/teams/teams";
|
||||
import { truncate } from "@/lib/utils";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TTeam } from "@formbricks/types/v1/teams";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
@@ -25,10 +24,18 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export default function SettingsNavbar({ environmentId }: { environmentId: string }) {
|
||||
export default function SettingsNavbar({
|
||||
environmentId,
|
||||
isFormbricksCloud,
|
||||
team,
|
||||
product,
|
||||
}: {
|
||||
environmentId: string;
|
||||
isFormbricksCloud: boolean;
|
||||
team: TTeam;
|
||||
product: TProduct;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { team } = useTeam(environmentId);
|
||||
const { product } = useProduct(environmentId);
|
||||
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
|
||||
|
||||
interface NavigationLink {
|
||||
@@ -113,7 +120,7 @@ export default function SettingsNavbar({ environmentId }: { environmentId: strin
|
||||
name: "Billing & Plan",
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
icon: CreditCardIcon,
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
hidden: !isFormbricksCloud,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
],
|
||||
@@ -152,21 +159,21 @@ export default function SettingsNavbar({ environmentId }: { environmentId: strin
|
||||
href: "https://formbricks.com/gdpr",
|
||||
icon: LinkIcon,
|
||||
target: "_blank",
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
name: "Privacy",
|
||||
href: "https://formbricks.com/privacy",
|
||||
icon: LinkIcon,
|
||||
target: "_blank",
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
name: "Terms",
|
||||
href: "https://formbricks.com/terms",
|
||||
icon: LinkIcon,
|
||||
target: "_blank",
|
||||
hidden: !IS_FORMBRICKS_CLOUD,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
name: "License",
|
||||
@@ -178,7 +185,7 @@ export default function SettingsNavbar({ environmentId }: { environmentId: strin
|
||||
],
|
||||
},
|
||||
],
|
||||
[environmentId, pathname]
|
||||
[environmentId, isFormbricksCloud, pathname]
|
||||
);
|
||||
|
||||
if (!navigation) return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import EditApiKeys from "./EditApiKeys";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getApiKeys } from "@formbricks/lib/services/apiKey";
|
||||
import { getApiKeys } from "@formbricks/lib/apiKey/service";
|
||||
import { getEnvironments } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function ApiKeyList({
|
||||
|
||||
@@ -34,19 +34,29 @@ export default function EditAPIKeys({
|
||||
};
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
await deleteApiKeyAction(activeKey.id);
|
||||
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
setOpenDeleteKeyModal(false);
|
||||
toast.success("API Key deleted");
|
||||
try {
|
||||
await deleteApiKeyAction(activeKey.id);
|
||||
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
toast.success("API Key deleted");
|
||||
} catch (e) {
|
||||
toast.error("Unable to delete API Key");
|
||||
} finally {
|
||||
setOpenDeleteKeyModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAPIKey = async (data) => {
|
||||
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
|
||||
const updatedApiKeys = [...apiKeysLocal!, apiKey];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
setOpenAddAPIKeyModal(false);
|
||||
toast.success("API key created");
|
||||
try {
|
||||
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
|
||||
const updatedApiKeys = [...apiKeysLocal!, apiKey];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
toast.success("API key created");
|
||||
} catch (e) {
|
||||
toast.error("Unable to create API Key");
|
||||
} finally {
|
||||
setOpenAddAPIKeyModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { deleteApiKey, createApiKey } from "@formbricks/lib/services/apiKey";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { deleteApiKey, createApiKey } from "@formbricks/lib/apiKey/service";
|
||||
import { canUserAccessApiKey } from "@formbricks/lib/apiKey/auth";
|
||||
import { TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export async function deleteApiKeyAction(id: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessApiKey(session.user.id, id);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await deleteApiKey(id);
|
||||
}
|
||||
export async function createApiKeyAction(environmentId: string, apiKeyData: TApiKeyCreateInput) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createApiKey(environmentId, apiKeyData);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { IS_FORMBRICKS_CLOUD, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { Metadata } from "next";
|
||||
import SettingsNavbar from "./SettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
};
|
||||
|
||||
export default function SettingsLayout({ children, params }) {
|
||||
export default async function SettingsLayout({ children, params }) {
|
||||
const [team, product] = await Promise.all([
|
||||
getTeamByEnvironmentId(params.environmentId),
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="sm:flex">
|
||||
<SettingsNavbar environmentId={params.environmentId} />
|
||||
<SettingsNavbar
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
team={team}
|
||||
product={product}
|
||||
/>
|
||||
<div className="w-full md:ml-64">
|
||||
<div className="max-w-4xl px-6 pb-6 pt-14 md:pt-6">
|
||||
<div>{children}</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { Button, ColorPicker, Label, Switch } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
@@ -10,11 +9,12 @@ import { updateProductAction } from "./actions";
|
||||
|
||||
interface EditHighlightBorderProps {
|
||||
product: TProduct;
|
||||
defaultBrandColor: string;
|
||||
}
|
||||
|
||||
export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
|
||||
export const EditHighlightBorder = ({ product, defaultBrandColor }: EditHighlightBorderProps) => {
|
||||
const [showHighlightBorder, setShowHighlightBorder] = useState(product.highlightBorderColor ? true : false);
|
||||
const [color, setColor] = useState<string | null>(product.highlightBorderColor || DEFAULT_BRAND_COLOR);
|
||||
const [color, setColor] = useState<string | null>(product.highlightBorderColor || defaultBrandColor);
|
||||
const [updatingBorder, setUpdatingBorder] = useState(false);
|
||||
|
||||
const handleUpdateHighlightBorder = async () => {
|
||||
@@ -32,7 +32,7 @@ export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
|
||||
const handleSwitch = (checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!color) {
|
||||
setColor(DEFAULT_BRAND_COLOR);
|
||||
setColor(defaultBrandColor);
|
||||
setShowHighlightBorder(true);
|
||||
} else {
|
||||
setShowHighlightBorder(true);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EditFormbricksSignature } from "./EditSignature";
|
||||
import { EditBrandColor } from "./EditBrandColor";
|
||||
import { EditPlacement } from "./EditPlacement";
|
||||
import { EditHighlightBorder } from "./EditHighlightBorder";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
|
||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
@@ -29,7 +30,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
|
||||
noPadding
|
||||
title="Highlight Border"
|
||||
description="Make sure your users notice the survey you display">
|
||||
<EditHighlightBorder product={product} />
|
||||
<EditHighlightBorder product={product} defaultBrandColor={DEFAULT_BRAND_COLOR} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TTeam } from "@formbricks/types/v1/teams";
|
||||
import React from "react";
|
||||
import MembersInfo from "@/app/(app)/environments/[environmentId]/settings/members/EditMemberships/MembersInfo";
|
||||
import { getMembersByTeamId } from "@formbricks/lib/services/membership";
|
||||
import { getInviteesByTeamId } from "@formbricks/lib/services/invite";
|
||||
import { getInvitesByTeamId } from "@formbricks/lib/services/invite";
|
||||
import { TMembership } from "@formbricks/types/v1/memberships";
|
||||
|
||||
type EditMembershipsProps = {
|
||||
@@ -18,7 +18,7 @@ export async function EditMemberships({
|
||||
currentUserMembership: membership,
|
||||
}: EditMembershipsProps) {
|
||||
const members = await getMembersByTeamId(team.id);
|
||||
const invites = await getInviteesByTeamId(team.id);
|
||||
const invites = await getInvitesByTeamId(team.id);
|
||||
|
||||
const currentUserRole = membership?.role;
|
||||
const isUserAdminOrOwner = membership?.role === "admin" || membership?.role === "owner";
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
|
||||
import CustomDialog from "@/components/shared/CustomDialog";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import { env } from "@/env.mjs";
|
||||
import { TMembershipRole } from "@formbricks/types/v1/memberships";
|
||||
import { TTeam } from "@formbricks/types/v1/teams";
|
||||
import { Button } from "@formbricks/ui";
|
||||
@@ -20,9 +19,16 @@ type TeamActionsProps = {
|
||||
isAdminOrOwner: boolean;
|
||||
isLeaveTeamDisabled: boolean;
|
||||
team: TTeam;
|
||||
isInviteDisabled: boolean;
|
||||
};
|
||||
|
||||
export default function TeamActions({ isAdminOrOwner, role, team, isLeaveTeamDisabled }: TeamActionsProps) {
|
||||
export default function TeamActions({
|
||||
isAdminOrOwner,
|
||||
role,
|
||||
team,
|
||||
isLeaveTeamDisabled,
|
||||
isInviteDisabled,
|
||||
}: TeamActionsProps) {
|
||||
const router = useRouter();
|
||||
const [isLeaveTeamModalOpen, setLeaveTeamModalOpen] = useState(false);
|
||||
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
@@ -69,7 +75,7 @@ export default function TeamActions({ isAdminOrOwner, role, team, isLeaveTeamDis
|
||||
}}>
|
||||
Create New Team
|
||||
</Button>
|
||||
{env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
|
||||
{!isInviteDisabled && isAdminOrOwner && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { updateTeamAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
|
||||
import { updateTeamNameAction } from "@/app/(app)/environments/[environmentId]/settings/members/actions";
|
||||
import { TTeam } from "@formbricks/types/v1/teams";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -43,7 +43,7 @@ export default function EditTeamName({ team }: TEditTeamNameProps) {
|
||||
const handleUpdateTeamName: SubmitHandler<TEditTeamNameForm> = async (data) => {
|
||||
try {
|
||||
setIsUpdatingTeam(true);
|
||||
await updateTeamAction(team.id, data);
|
||||
await updateTeamNameAction(team.id, data.name);
|
||||
|
||||
setIsUpdatingTeam(false);
|
||||
toast.success("Team name updated successfully.");
|
||||
|
||||
@@ -20,13 +20,22 @@ import {
|
||||
import { deleteTeam, updateTeam } from "@formbricks/lib/services/team";
|
||||
import { TInviteUpdateInput } from "@formbricks/types/v1/invites";
|
||||
import { TMembershipRole, TMembershipUpdateInput } from "@formbricks/types/v1/memberships";
|
||||
import { TTeamUpdateInput } from "@formbricks/types/v1/teams";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { hasTeamAccess, hasTeamAuthority, hasTeamOwnership, isOwner } from "@formbricks/lib/auth";
|
||||
import { env } from "@/env.mjs";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
|
||||
export const updateTeamAction = async (teamId: string, data: TTeamUpdateInput) => {
|
||||
return await updateTeam(teamId, data);
|
||||
export const updateTeamNameAction = async (teamId: string, teamName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
|
||||
if (!isUserAuthorized) {
|
||||
throw new AuthenticationError("Not authorized");
|
||||
}
|
||||
|
||||
return await updateTeam(teamId, { name: teamName });
|
||||
};
|
||||
|
||||
export const updateMembershipAction = async (
|
||||
@@ -154,7 +163,7 @@ export const inviteUserAction = async (
|
||||
|
||||
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
|
||||
|
||||
if (env.NEXT_PUBLIC_INVITE_DISABLED === "1") {
|
||||
if (INVITE_DISABLED) {
|
||||
throw new AuthenticationError("Invite disabled");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import SettingsTitle from "../SettingsTitle";
|
||||
import DeleteTeam from "./DeleteTeam";
|
||||
import { EditMemberships } from "./EditMemberships";
|
||||
import EditTeamName from "./EditTeamName";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
|
||||
const MembersLoading = () => (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
@@ -65,6 +66,7 @@ export default async function MembersSettingsPage({ params }: { params: { enviro
|
||||
isAdminOrOwner={isUserAdminOrOwner}
|
||||
role={currentUserRole}
|
||||
isLeaveTeamDisabled={isLeaveTeamDisabled}
|
||||
isInviteDisabled={INVITE_DISABLED}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TProduct } from "@formbricks/types/v1/product";
|
||||
import DeleteProductRender from "@/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/services/membership";
|
||||
|
||||
type DeleteProductProps = {
|
||||
environmentId: string;
|
||||
@@ -12,10 +13,20 @@ type DeleteProductProps = {
|
||||
|
||||
export default async function DeleteProduct({ environmentId, product }: DeleteProductProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
const availableProducts = team ? await getProducts(team.id) : null;
|
||||
|
||||
const role = team ? session?.user.teams.find((foundTeam) => foundTeam.id === team.id)?.role : null;
|
||||
const membership = await getMembershipByUserIdTeamId(session.user.id, team.id);
|
||||
if (!membership) {
|
||||
throw new Error("Membership not found");
|
||||
}
|
||||
const role = membership.role;
|
||||
const availableProductsLength = availableProducts ? availableProducts.length : 0;
|
||||
const isUserAdminOrOwner = role === "admin" || role === "owner";
|
||||
const isDeleteDisabled = availableProductsLength <= 1 || !isUserAdminOrOwner;
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/services/product";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/services/membership";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
|
||||
export const updateProductAction = async (
|
||||
environmentId: string,
|
||||
@@ -34,8 +34,8 @@ export const updateProductAction = async (
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
|
||||
throw new AuthenticationError("You don't have access to this environment");
|
||||
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
const updatedProduct = await updateProduct(productId, data);
|
||||
@@ -62,15 +62,15 @@ export const deleteProductAction = async (environmentId: string, userId: string,
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
|
||||
throw new AuthenticationError("You don't have access to this environment");
|
||||
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
const membership = team ? await getMembershipByUserIdTeamId(userId, team.id) : null;
|
||||
|
||||
if (membership?.role !== "admin" && membership?.role !== "owner") {
|
||||
throw new AuthenticationError("You are not allowed to delete products.");
|
||||
throw new AuthorizationError("You are not allowed to delete products.");
|
||||
}
|
||||
|
||||
const availableProducts = team ? await getProducts(team.id) : null;
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
|
||||
import { formbricksLogout } from "@/lib/formbricks";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
import { Button, Input, ProfileAvatar } from "@formbricks/ui";
|
||||
import { Session } from "next-auth";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { profileDeleteAction } from "./actions";
|
||||
import { deleteProfileAction } from "./actions";
|
||||
|
||||
export function EditAvatar({ session }) {
|
||||
return (
|
||||
@@ -38,10 +37,9 @@ interface DeleteAccountModalProps {
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
session: Session;
|
||||
profile: TProfile;
|
||||
}
|
||||
|
||||
function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountModalProps) {
|
||||
function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
@@ -52,7 +50,7 @@ function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountMo
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
setDeleting(true);
|
||||
await profileDeleteAction(profile.id);
|
||||
await deleteProfileAction();
|
||||
await signOut();
|
||||
await formbricksLogout();
|
||||
} catch (error) {
|
||||
@@ -105,7 +103,7 @@ function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountMo
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteAccount({ session, profile }: { session: Session | null; profile: TProfile }) {
|
||||
export function DeleteAccount({ session }: { session: Session | null }) {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
@@ -114,7 +112,7 @@ export function DeleteAccount({ session, profile }: { session: Session | null; p
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} profile={profile} />
|
||||
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} />
|
||||
<p className="text-sm text-slate-700">
|
||||
Delete your account with all personal data. <strong>This cannot be undone!</strong>
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { profileEditAction } from "./actions";
|
||||
import { updateProfileAction } from "./actions";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
|
||||
export function EditName({ profile }: { profile: TProfile }) {
|
||||
@@ -19,7 +19,7 @@ export function EditName({ profile }: { profile: TProfile }) {
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={handleSubmit(async (data) => {
|
||||
try {
|
||||
await profileEditAction(profile.id, data);
|
||||
await updateProfileAction(data);
|
||||
toast.success("Your name was updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { updateProfile, deleteProfile } from "@formbricks/lib/services/profile";
|
||||
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export async function profileEditAction(userId: string, data: Partial<TProfileUpdateInput>) {
|
||||
return await updateProfile(userId, data);
|
||||
export async function updateProfileAction(data: Partial<TProfileUpdateInput>) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await updateProfile(session.user.id, data);
|
||||
}
|
||||
|
||||
export async function profileDeleteAction(userId: string) {
|
||||
return await deleteProfile(userId);
|
||||
export async function deleteProfileAction() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await deleteProfile(session.user.id);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function ProfileSettingsPage() {
|
||||
<SettingsCard
|
||||
title="Delete account"
|
||||
description="Delete your account with all of your personal information and data.">
|
||||
<DeleteAccount session={session} profile={profile} />
|
||||
<DeleteAccount session={session} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import CodeBlock from "@/components/shared/CodeBlock";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { TabBar } from "@formbricks/ui";
|
||||
import Link from "next/link";
|
||||
import "prismjs/themes/prism.css";
|
||||
import { useState } from "react";
|
||||
import { IoLogoHtml5, IoLogoNpm } from "react-icons/io5";
|
||||
import packageJson from "@/package.json";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
const tabs = [
|
||||
{ id: "npm", label: "NPM", icon: <IoLogoNpm /> },
|
||||
{ id: "html", label: "HTML", icon: <IoLogoHtml5 /> },
|
||||
];
|
||||
|
||||
export default function SetupInstructions({ environmentId }) {
|
||||
export default function SetupInstructions({
|
||||
environmentId,
|
||||
webAppUrl,
|
||||
isFormbricksCloud,
|
||||
}: {
|
||||
environmentId: string;
|
||||
webAppUrl: string;
|
||||
isFormbricksCloud: boolean;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||
|
||||
return (
|
||||
@@ -33,7 +39,7 @@ export default function SetupInstructions({ environmentId }) {
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.init({
|
||||
environmentId: "${environmentId}",
|
||||
apiHost: "${WEBAPP_URL}",
|
||||
apiHost: "${webAppUrl}",
|
||||
debug: true, // remove when in production
|
||||
});
|
||||
}`}</CodeBlock>
|
||||
@@ -142,7 +148,7 @@ if (typeof window !== "undefined") {
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{!IS_FORMBRICKS_CLOUD && (
|
||||
{!isFormbricksCloud && (
|
||||
<div>
|
||||
<hr className="my-3" />
|
||||
<p className="flex w-full justify-end text-sm text-slate-700">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ErrorComponent } from "@formbricks/ui";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import SetupInstructions from "./SetupInstructions";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
export default async function ProfileSettingsPage({ params }) {
|
||||
const [environment, actions] = await Promise.all([
|
||||
@@ -42,7 +43,11 @@ export default async function ProfileSettingsPage({ params }) {
|
||||
title="How to setup"
|
||||
description="Follow these steps to setup the Formbricks widget within your app"
|
||||
noPadding>
|
||||
<SetupInstructions environmentId={params.environmentId} />
|
||||
<SetupInstructions
|
||||
environmentId={params.environmentId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,37 +3,46 @@
|
||||
import MergeTagsCombobox from "@/app/(app)/environments/[environmentId]/settings/tags/MergeTagsCombobox";
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useDeleteTag, useMergeTags, useUpdateTag } from "@/lib/tags/mutateTags";
|
||||
import { useTagsCountForEnvironment, useTagsForEnvironment } from "@/lib/tags/tags";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TTag, TTagsCount } from "@formbricks/types/v1/tags";
|
||||
import { Button, Input } from "@formbricks/ui";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
deleteTagAction,
|
||||
mergeTagsAction,
|
||||
updateTagNameAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/tags/actions";
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
interface IEditTagsWrapperProps {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
environmentTagsCount: TTagsCount;
|
||||
}
|
||||
|
||||
const SingleTag: React.FC<{
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
environmentId: string;
|
||||
tagCount?: number;
|
||||
tagCountLoading?: boolean;
|
||||
updateTagsCount?: () => void;
|
||||
environmentTags: TTag[];
|
||||
}> = ({
|
||||
environmentId,
|
||||
tagId,
|
||||
tagName,
|
||||
tagCount = 0,
|
||||
tagCountLoading = false,
|
||||
updateTagsCount = () => {},
|
||||
environmentTags,
|
||||
}) => {
|
||||
const { mutate: refetchEnvironmentTags, data: environmentTags } = useTagsForEnvironment(environmentId);
|
||||
const { deleteTag, isDeletingTag } = useDeleteTag(environmentId, tagId);
|
||||
|
||||
const { updateTag, updateTagError } = useUpdateTag(environmentId, tagId);
|
||||
const { mergeTags, isMergingTags } = useMergeTags(environmentId);
|
||||
const router = useRouter();
|
||||
// const { updateTag, updateTagError } = useUpdateTag(environment.id, tagId);
|
||||
// const { mergeTags, isMergingTags } = useMergeTags(environment.id);
|
||||
const [updateTagError, setUpdateTagError] = useState(false);
|
||||
const [isMergingTags, setIsMergingTags] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full" key={tagId}>
|
||||
@@ -49,18 +58,24 @@ const SingleTag: React.FC<{
|
||||
)}
|
||||
defaultValue={tagName}
|
||||
onBlur={(e) => {
|
||||
updateTag(
|
||||
{ name: e.target.value.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Tag updated");
|
||||
refetchEnvironmentTags();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message ?? "Failed to update tag");
|
||||
},
|
||||
}
|
||||
);
|
||||
updateTagNameAction(tagId, e.target.value.trim())
|
||||
.then(() => {
|
||||
setUpdateTagError(false);
|
||||
toast.success("Tag updated");
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.message.includes("Unique constraint failed on the fields")) {
|
||||
toast.error("Tag already exists", {
|
||||
duration: 2000,
|
||||
icon: <ExclamationCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
} else {
|
||||
toast.error(error?.message ?? "Something went wrong", {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
setUpdateTagError(true);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -84,19 +99,19 @@ const SingleTag: React.FC<{
|
||||
?.map((tag) => ({ label: tag.name, value: tag.id })) ?? []
|
||||
}
|
||||
onSelect={(newTagId) => {
|
||||
mergeTags(
|
||||
{
|
||||
originalTagId: tagId,
|
||||
newTagId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Tags merged");
|
||||
refetchEnvironmentTags();
|
||||
updateTagsCount();
|
||||
},
|
||||
}
|
||||
);
|
||||
setIsMergingTags(true);
|
||||
mergeTagsAction(tagId, newTagId)
|
||||
.then(() => {
|
||||
toast.success("Tags merged");
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error?.message ?? "Something went wrong");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsMergingTags(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -106,16 +121,14 @@ const SingleTag: React.FC<{
|
||||
<Button
|
||||
variant="alert"
|
||||
size="sm"
|
||||
loading={isDeletingTag}
|
||||
// loading={isDeletingTag}
|
||||
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this tag?")) {
|
||||
deleteTag(null, {
|
||||
onSuccess: () => {
|
||||
toast.success("Tag deleted");
|
||||
refetchEnvironmentTags();
|
||||
updateTagsCount();
|
||||
},
|
||||
deleteTagAction(tagId).then(() => {
|
||||
toast.success("Tag deleted");
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
}}>
|
||||
@@ -129,19 +142,7 @@ const SingleTag: React.FC<{
|
||||
};
|
||||
|
||||
const EditTagsWrapper: React.FC<IEditTagsWrapperProps> = (props) => {
|
||||
const { environmentId } = props;
|
||||
const { data: environmentTags, isLoading: isLoadingEnvironmentTags } = useTagsForEnvironment(environmentId);
|
||||
|
||||
const { tagsCount, isLoadingTagsCount, mutateTagsCount } = useTagsCountForEnvironment(environmentId);
|
||||
|
||||
if (isLoadingEnvironmentTags) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { environment, environmentTags, environmentTagsCount } = props;
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
@@ -152,18 +153,16 @@ const EditTagsWrapper: React.FC<IEditTagsWrapperProps> = (props) => {
|
||||
</div>
|
||||
|
||||
{!environmentTags?.length ? (
|
||||
<EmptySpaceFiller environmentId={environmentId} type="tag" noWidgetRequired />
|
||||
<EmptySpaceFiller environment={environment} type="tag" noWidgetRequired />
|
||||
) : null}
|
||||
|
||||
{environmentTags?.map((tag) => (
|
||||
<SingleTag
|
||||
key={tag.id}
|
||||
environmentId={environmentId}
|
||||
tagId={tag.id}
|
||||
tagName={tag.name}
|
||||
tagCount={tagsCount?.find((count) => count.tagId === tag.id)?.count ?? 0}
|
||||
tagCountLoading={isLoadingTagsCount}
|
||||
updateTagsCount={mutateTagsCount}
|
||||
tagCount={environmentTagsCount?.find((count) => count.tagId === tag.id)?.count ?? 0}
|
||||
environmentTags={environmentTags}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/tag/service";
|
||||
import { canUserAccessTag } from "@formbricks/lib/tag/auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export const deleteTagAction = async (tagId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessTag(session.user.id, tagId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await deleteTag(tagId);
|
||||
};
|
||||
|
||||
export const updateTagNameAction = async (tagId: string, name: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessTag(session.user.id, tagId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await updateTagName(tagId, name);
|
||||
};
|
||||
|
||||
export const mergeTagsAction = async (originalTagId: string, newTagId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedForOld = await canUserAccessTag(session.user.id, originalTagId);
|
||||
const isAuthorizedForNew = await canUserAccessTag(session.user.id, newTagId);
|
||||
if (!isAuthorizedForOld || !isAuthorizedForNew) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await mergeTags(originalTagId, newTagId);
|
||||
};
|
||||
@@ -1,11 +1,25 @@
|
||||
import EditTagsWrapper from "./EditTagsWrapper";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { getTagsOnResponsesCount } from "@formbricks/lib/services/tagOnResponse";
|
||||
|
||||
export default async function MembersSettingsPage({ params }) {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
const environmentTagsCount = await getTagsOnResponsesCount();
|
||||
|
||||
export default function MembersSettingsPage({ params }) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Tags" />
|
||||
<EditTagsWrapper environmentId={params.environmentId} />
|
||||
<EditTagsWrapper
|
||||
environment={environment}
|
||||
environmentTags={tags}
|
||||
environmentTagsCount={environmentTagsCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface PreviewSurveyProps {
|
||||
product: TProduct;
|
||||
environment: TEnvironment;
|
||||
}
|
||||
let surveyNameTemp;
|
||||
|
||||
export default function PreviewSurvey({
|
||||
survey,
|
||||
@@ -46,7 +47,20 @@ export default function PreviewSurvey({
|
||||
}
|
||||
}, [activeQuestionId, survey.type, survey, setActiveQuestionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.name !== surveyNameTemp) {
|
||||
resetQuestionProgress();
|
||||
surveyNameTemp = survey.name;
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
function resetQuestionProgress() {
|
||||
let storePreviewMode = previewMode;
|
||||
setPreviewMode("null");
|
||||
setTimeout(() => {
|
||||
setPreviewMode(storePreviewMode);
|
||||
}, 10);
|
||||
|
||||
setActiveQuestionId(survey.questions[0].id);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import type { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import {
|
||||
ArrowUpOnSquareStackIcon,
|
||||
DocumentDuplicateIcon,
|
||||
@@ -32,10 +32,11 @@ import toast from "react-hot-toast";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
survey: TSurveyWithAnalytics;
|
||||
survey: TSurvey;
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
surveyBaseUrl: string;
|
||||
singleUseId?: string;
|
||||
}
|
||||
|
||||
export default function SurveyDropDownMenu({
|
||||
@@ -44,12 +45,13 @@ export default function SurveyDropDownMenu({
|
||||
environment,
|
||||
otherEnvironment,
|
||||
surveyBaseUrl,
|
||||
singleUseId,
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey]);
|
||||
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey.id, surveyBaseUrl]);
|
||||
|
||||
const handleDeleteSurvey = async (survey) => {
|
||||
setLoading(true);
|
||||
@@ -155,7 +157,11 @@ export default function SurveyDropDownMenu({
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/s/${survey.id}?preview=true`}
|
||||
href={
|
||||
singleUseId
|
||||
? `/s/${survey.id}?suId=${singleUseId}&preview=true`
|
||||
: `/s/${survey.id}?preview=true`
|
||||
}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
@@ -165,8 +171,11 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
className="flex w-full items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
navigator.clipboard.writeText(
|
||||
singleUseId ? `${surveyUrl}?suId=${singleUseId}` : surveyUrl
|
||||
);
|
||||
toast.success("Copied link to clipboard");
|
||||
router.refresh();
|
||||
}}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
|
||||
@@ -2,15 +2,15 @@ import { UsageAttributesUpdater } from "@/app/(app)/FormbricksClient";
|
||||
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter";
|
||||
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import { Badge } from "@formbricks/ui";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
|
||||
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
@@ -22,10 +22,10 @@ export default async function SurveysList({ environmentId }: { environmentId: st
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
const totalSubmissions = surveys.reduce((acc, survey) => acc + (survey.analytics?.numResponses || 0), 0);
|
||||
|
||||
if (surveys.length === 0) {
|
||||
return <SurveyStarter environmentId={environmentId} environment={environment} product={product} />;
|
||||
@@ -45,67 +45,66 @@ export default async function SurveysList({ environmentId }: { environmentId: st
|
||||
</li>
|
||||
</Link>
|
||||
{surveys
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.map((survey) => (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
|
||||
.map((survey) => {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
|
||||
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
|
||||
return (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
<SurveyStatusIndicator
|
||||
status={survey.status}
|
||||
tooltip
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
<p className="ml-2 text-xs text-slate-400 ">
|
||||
{survey.analytics.numResponses} responses
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
singleUseId={singleUseId}
|
||||
/>
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`survey-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<UsageAttributesUpdater numSurveys={surveys.length} totalSubmissions={totalSubmissions} />
|
||||
<UsageAttributesUpdater numSurveys={surveys.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templ
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TSurveyInput } from "@formbricks/types/v1/surveys";
|
||||
import { TTemplate } from "@formbricks/types/v1/templates";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -28,7 +29,7 @@ export default function SurveyStarter({
|
||||
...template.preset,
|
||||
type: surveyType,
|
||||
autoComplete,
|
||||
};
|
||||
} as Partial<TSurveyInput>;
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { getSurveyResponses } from "@formbricks/lib/services/response";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
|
||||
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getSurveyWithAnalytics } from "@formbricks/lib/survey/service";
|
||||
import { getSurveyResponses } from "@formbricks/lib/response/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
|
||||
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { deleteResponse } from "@formbricks/lib/response/service";
|
||||
import { updateResponseNote, resolveResponseNote } from "@formbricks/lib/services/responseNote";
|
||||
import { createTag } from "@formbricks/lib/tag/service";
|
||||
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/services/tagOnResponse";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
import { canUserAccessResponse } from "@formbricks/lib/response/auth";
|
||||
import { canUserAccessTagOnResponse } from "@formbricks/lib/tagOnResponse/auth";
|
||||
|
||||
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
|
||||
await updateResponseNote(responseNoteId, text);
|
||||
@@ -9,3 +18,43 @@ export const updateResponseNoteAction = async (responseNoteId: string, text: str
|
||||
export const resolveResponseNoteAction = async (responseNoteId: string) => {
|
||||
await resolveResponseNote(responseNoteId);
|
||||
};
|
||||
|
||||
export const deleteResponseAction = async (responseId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessResponse(session.user.id, responseId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await deleteResponse(responseId);
|
||||
};
|
||||
|
||||
export const createTagAction = async (environmentId: string, tagName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createTag(environmentId, tagName);
|
||||
};
|
||||
|
||||
export const createTagToResponeAction = async (responseId: string, tagId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await addTagToRespone(responseId, tagId);
|
||||
};
|
||||
|
||||
export const deleteTagOnResponseAction = async (responseId: string, tagId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await deleteTagOnResponse(responseId, tagId);
|
||||
};
|
||||
|
||||
@@ -10,16 +10,29 @@ import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
|
||||
interface ResponsePageProps {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
responses: TResponse[];
|
||||
surveyBaseUrl: string;
|
||||
product: TProduct;
|
||||
environmentTags: TTag[];
|
||||
}
|
||||
|
||||
const ResponsePage = ({ environmentId, survey, surveyId, responses, surveyBaseUrl }: ResponsePageProps) => {
|
||||
const ResponsePage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
responses,
|
||||
surveyBaseUrl,
|
||||
product,
|
||||
environmentTags,
|
||||
}: ResponsePageProps) => {
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
@@ -37,23 +50,25 @@ const ResponsePage = ({ environmentId, survey, surveyId, responses, surveyBaseUr
|
||||
return (
|
||||
<ContentWrapper>
|
||||
<SummaryHeader
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={surveyId}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
product={product}
|
||||
/>
|
||||
<CustomFilter
|
||||
environmentId={environmentId}
|
||||
environmentTags={environmentTags}
|
||||
responses={filterResponses}
|
||||
survey={survey}
|
||||
totalResponses={responses}
|
||||
/>
|
||||
<SurveyResultsTabs activeId="responses" environmentId={environmentId} surveyId={surveyId} />
|
||||
<SurveyResultsTabs activeId="responses" environmentId={environment.id} surveyId={surveyId} />
|
||||
<ResponseTimeline
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
surveyId={surveyId}
|
||||
responses={filterResponses}
|
||||
survey={survey}
|
||||
environmentTags={environmentTags}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||