feat: move env vars from build-time to run-time to better support self-hosting environments (#789)

* feat: privacy, imprint, and terms URL env vars now do not need rebuilding

* feat: disable_singup env var now do not need rebuilding

* feat: password_reset_disabled env var now do not need rebuilding

* feat: email_verification_disabled env var now do not need rebuilding

* feat: github_oauth & google_oauth env var now do not need rebuilding

* feat: move logic of env vars to serverside and send boolean client-side

* feat: invite_disabled env var now do not need rebuilding

* feat: rename vars logically

* feat: migration guide

* feat: update docker-compose as per v1.1

* deprecate: unused NEXT_PUBLIC_VERCEL_URL & VERCEL_URL

* deprecate: unused RAILWAY_STATIC_URL

* deprecate: unused RENDER_EXTERNAL_URL

* deprecate: unused HEROKU_APP_NAME

* fix: define WEBAPP_URL & replace NEXT_WEBAPP_URL with it

* migrate: NEXT_PUBLIC_IS_FORMBRICKS_CLOUD to IS_FORMBRICKS_CLOUD

* chore: move all env parsing to a constants.ts from page files

* feat: migrate client side envs to server side

* redo: isFormbricksCloud to navbar serverside page

* fix: constants is now a server only file

* fix: removal of use swr underway

* fix: move 1 tag away from swr to service

* feat: move away from tags swr

* feat: move away from surveys  swr

* feat: move away from eventClass swr

* feat: move away from event swr

* fix: make constants server-only

* remove comments from .env.example, use constants in MetaInformation

* clean up services

* rename tag function

* fix build error

* fix smaller bugs, fix Response % not working in summary

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Shubham Palriwala
2023-10-01 01:10:59 +05:30
committed by GitHub
parent 3c2087452c
commit 13afba7615
137 changed files with 1764 additions and 1727 deletions

View File

@@ -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,12 +90,12 @@ 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=

View File

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

View File

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

View File

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

View File

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

View File

@@ -246,6 +246,7 @@ 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" },
],
},
{

View File

@@ -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} />;
}

View File

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

View File

@@ -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",

View File

@@ -0,0 +1,31 @@
"use server";
import {
getActionCountInLast24Hours,
getActionCountInLast7Days,
getActionCountInLastHour,
} from "@formbricks/lib/services/actions";
import { getSurveysByActionClassId } from "@formbricks/lib/services/survey";
export const getActionCountInLastHourAction = async (actionClassId: string) => {
return await getActionCountInLastHour(actionClassId);
};
export const getActionCountInLast24HoursAction = async (actionClassId: string) => {
return await getActionCountInLast24Hours(actionClassId);
};
export const getActionCountInLast7DaysAction = async (actionClassId: string) => {
return await getActionCountInLast7Days(actionClassId);
};
export const GetActiveInactiveSurveysAction = async (
actionClassId: string
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {
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;
};

View File

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

View File

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

View File

@@ -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",

View File

@@ -0,0 +1,14 @@
"use server";
import { getSurveysByAttributeClassId } from "@formbricks/lib/services/survey";
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;
};

View File

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

View File

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

View File

@@ -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}
/>
);
}

View File

@@ -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={() => {

View File

@@ -6,6 +6,7 @@ import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
import { Team } from "@prisma/client";
import { Prisma as prismaClient } from "@prisma/client/";
import { createProduct } from "@formbricks/lib/services/product";
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
const newTeam = await prisma.team.create({
@@ -303,3 +304,10 @@ export async function copyToOtherEnvironmentAction(
export const deleteSurveyAction = async (surveyId: string) => {
await deleteSurvey(surveyId);
};
export const createProductAction = async (environmentId: string, productName: string) => {
const productCreated = await createProduct(environmentId, productName);
const newEnvironment = productCreated.environments[0];
return newEnvironment;
};

View File

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

View File

@@ -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);
}

View File

@@ -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} />
)}
</>
);

View File

@@ -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. ⏲️"
/>

View File

@@ -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 { 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>
</>

View File

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

View File

@@ -7,18 +7,26 @@ import GoBackButton from "@/components/shared/GoBackButton";
import { getSurveys } from "@formbricks/lib/services/survey";
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} />

View File

@@ -6,6 +6,7 @@ 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 { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
export default async function EnvironmentLayout({ children, params }) {
const session = await getServerSession(authOptions);
@@ -22,7 +23,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 />

View File

@@ -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) => (

View File

@@ -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>
);
}

View File

@@ -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} />
</>
);
}

View File

@@ -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 { 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} />;
}

View File

@@ -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>
);
}

View File

@@ -3,20 +3,21 @@ 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 { 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,12 +54,12 @@ 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>

View File

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

View File

@@ -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 ⏲️"
/>
) : (

View File

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

View File

@@ -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";

View File

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

View File

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

View File

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

View File

@@ -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={() => {

View File

@@ -23,7 +23,7 @@ import { TMembershipRole, TMembershipUpdateInput } from "@formbricks/types/v1/me
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);
@@ -154,7 +154,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");
}

View File

@@ -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}
/>
)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
"use server";
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/services/tag";
export const deleteTagAction = async (tagId: string) => {
return await deleteTag(tagId);
};
export const updateTagNameAction = async (tagId: string, name: string) => {
return await updateTagName(tagId, name);
};
export const mergeTagsAction = async (originalTagId: string, newTagId: string) => {
return await mergeTags(originalTagId, newTagId);
};

View File

@@ -1,11 +1,25 @@
import EditTagsWrapper from "./EditTagsWrapper";
import SettingsTitle from "../SettingsTitle";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
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>
);
}

View File

@@ -49,7 +49,7 @@ export default function SurveyDropDownMenu({
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);

View File

@@ -76,11 +76,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
<div className="flex items-center">
{survey.status !== "draft" && (
<>
<SurveyStatusIndicator
status={survey.status}
tooltip
environmentId={environmentId}
/>
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
</>
)}
{survey.status === "draft" && (

View File

@@ -1,4 +1,5 @@
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";

View File

@@ -1,6 +1,9 @@
"use server";
import { deleteResponse } from "@formbricks/lib/services/response";
import { updateResponseNote, resolveResponseNote } from "@formbricks/lib/services/responseNote";
import { createTag } from "@formbricks/lib/services/tag";
import { addTagToRespone, deleteTagFromResponse } from "@formbricks/lib/services/tagOnResponse";
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
await updateResponseNote(responseNoteId, text);
@@ -9,3 +12,19 @@ export const updateResponseNoteAction = async (responseNoteId: string, text: str
export const resolveResponseNoteAction = async (responseNoteId: string) => {
await resolveResponseNote(responseNoteId);
};
export const deleteResponseAction = async (responseId: string) => {
return await deleteResponse(responseId);
};
export const createTagAction = async (environmentId: string, tagName: string) => {
return await createTag(environmentId, tagName);
};
export const addTagToResponeAction = async (responseId: string, tagId: string) => {
return await addTagToRespone(responseId, tagId);
};
export const removeTagFromResponseAction = async (responseId: string, tagId: string) => {
return await deleteTagFromResponse(responseId, tagId);
};

View File

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

View File

@@ -1,14 +1,18 @@
"use client";
import TagsCombobox from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TagsCombobox";
import { removeTagFromResponse, useAddTagToResponse, useCreateTag } from "@/lib/tags/mutateTags";
import { useTagsForEnvironment } from "@/lib/tags/tags";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Tag } from "./Tag";
import { ExclamationCircleIcon, Cog6ToothIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
import { Button } from "@formbricks/ui";
import { TTag } from "@formbricks/types/v1/tags";
import {
addTagToResponeAction,
createTagAction,
removeTagFromResponseAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
interface ResponseTagsWrapperProps {
tags: {
@@ -16,15 +20,15 @@ interface ResponseTagsWrapperProps {
tagName: string;
}[];
environmentId: string;
surveyId: string;
responseId: string;
environmentTags: TTag[];
}
const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
tags,
environmentId,
responseId,
surveyId,
environmentTags,
}) => {
const router = useRouter();
const [searchValue, setSearchValue] = useState("");
@@ -32,13 +36,9 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
const [tagsState, setTagsState] = useState(tags);
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
const { createTag } = useCreateTag(environmentId);
const { data: environmentTags, mutate: refetchEnvironmentTags } = useTagsForEnvironment(environmentId);
const { addTagToRespone } = useAddTagToResponse(environmentId, surveyId, responseId);
const onDelete = async (tagId: string) => {
try {
await removeTagFromResponse(environmentId, surveyId, responseId, tagId);
await removeTagFromResponseAction(responseId, tagId);
router.refresh();
} catch (e) {
@@ -79,60 +79,38 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={(tagName) => {
createTag(
{
name: tagName?.trim() ?? "",
},
{
onSuccess: (data) => {
setTagsState((prevTags) => [
...prevTags,
{
tagId: data.id,
tagName: data.name,
},
]);
addTagToRespone(
{
tagIdToAdd: data.id,
},
{
onSuccess: () => {
setSearchValue("");
setOpen(false);
refetchEnvironmentTags();
router.refresh();
},
}
);
},
onError: (err) => {
if (err?.cause === "DUPLICATE_RECORD") {
toast.error(err?.message ?? "Something went wrong", {
duration: 2000,
icon: <ExclamationCircleIcon className="h-5 w-5 text-orange-500" />,
});
const tag = tags.find((tag) => tag.tagName === tagName?.trim() ?? "");
setTagIdToHighlight(tag?.tagId ?? "");
} else {
toast.error(err?.message ?? "Something went wrong", {
duration: 2000,
});
}
createTag={async (tagName) => {
await createTagAction(environmentId, tagName?.trim() ?? "")
.then((tag) => {
setTagsState((prevTags) => [
...prevTags,
{
tagId: tag.id,
tagName: tag.name,
},
]);
addTagToResponeAction(responseId, tag.id).then(() => {
setSearchValue("");
setOpen(false);
refetchEnvironmentTags();
router.refresh();
},
throwOnError: false,
}
);
});
})
.catch((err) => {
if (err?.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(err?.message ?? "Something went wrong", {
duration: 2000,
});
}
setSearchValue("");
setOpen(false);
router.refresh();
});
}}
addTag={(tagId) => {
setTagsState((prevTags) => [
@@ -143,20 +121,11 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
},
]);
addTagToRespone(
{
tagIdToAdd: tagId,
},
{
onSuccess: () => {
setSearchValue("");
setOpen(false);
refetchEnvironmentTags();
router.refresh();
},
}
);
addTagToResponeAction(responseId, tagId).then(() => {
setSearchValue("");
setOpen(false);
router.refresh();
});
}}
/>
</div>

View File

@@ -6,19 +6,23 @@ import { createId } from "@paralleldrive/cuid2";
import { useMemo } from "react";
import SingleResponse from "./SingleResponse";
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TTag } from "@formbricks/types/v1/tags";
interface ResponseTimelineProps {
environmentId: string;
environment: TEnvironment;
surveyId: string;
responses: TResponse[];
survey: TSurvey;
environmentTags: TTag[];
}
export default function ResponseTimeline({
environmentId,
environment,
surveyId,
responses,
survey,
environmentTags,
}: ResponseTimelineProps) {
const matchQandA = useMemo(() => {
if (survey && responses) {
@@ -67,11 +71,13 @@ export default function ResponseTimeline({
return (
<div className="space-y-4">
{survey.type === "web" && responses.length === 0 && <EmptyInAppSurveys environmentId={environmentId} />}
{survey.type === "web" && responses.length === 0 && (
<EmptyInAppSurveys environmentId={environment.id} />
)}
{survey.type !== "web" && responses.length === 0 ? (
<EmptySpaceFiller
type="response"
environmentId={environmentId}
environment={environment}
noWidgetRequired={survey.type === "link"}
/>
) : (
@@ -82,7 +88,8 @@ export default function ResponseTimeline({
key={updatedResponse.id}
data={updatedResponse}
surveyId={surveyId}
environmentId={environmentId}
environmentId={environment.id}
environmentTags={environmentTags}
/>
);
})}

View File

@@ -1,7 +1,6 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { deleteSubmission } from "@/lib/responses/responses";
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { QuestionType } from "@formbricks/types/questions";
@@ -17,6 +16,8 @@ import toast from "react-hot-toast";
import ResponseNote from "./ResponseNote";
import ResponseTagsWrapper from "./ResponseTagsWrapper";
import { RatingResponse } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/RatingResponse";
import { deleteResponseAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
import { TTag } from "@formbricks/types/v1/tags";
export interface OpenTextSummaryProps {
environmentId: string;
@@ -31,6 +32,7 @@ export interface OpenTextSummaryProps {
range?: number;
}[];
};
environmentTags: TTag[];
}
function findEmail(person) {
@@ -59,7 +61,12 @@ function TooltipRenderer(props: TooltipRendererProps) {
return <>{children}</>;
}
export default function SingleResponse({ data, environmentId, surveyId }: OpenTextSummaryProps) {
export default function SingleResponse({
data,
environmentId,
surveyId,
environmentTags,
}: OpenTextSummaryProps) {
const router = useRouter();
const email = data.person && findEmail(data.person);
const displayIdentifier = email || (data.person && truncate(data.person.id, 16)) || null;
@@ -69,9 +76,9 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
const handleDeleteSubmission = async () => {
setIsDeleting(true);
const deleteResponse = await deleteSubmission(environmentId, data?.surveyId, data?.id);
const deleteResponseStatus = await deleteResponseAction(data?.id);
router.refresh();
if (deleteResponse?.id?.length > 0) toast.success("Submission deleted successfully.");
if (deleteResponseStatus) toast.success("Submission deleted successfully.");
setDeleteDialogOpen(false);
setIsDeleting(false);
};
@@ -182,10 +189,10 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
<ResponseTagsWrapper
environmentId={environmentId}
surveyId={surveyId}
responseId={data.id}
tags={data.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
key={data.tags.map((tag) => tag.id).join("-")}
environmentTags={environmentTags}
/>
<DeleteDialog

View File

@@ -4,26 +4,41 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import ResponsePage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data";
import { getServerSession } from "next-auth";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import ResponsesLimitReachedBanner from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponsesLimitReachedBanner";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
export default async function Page({ params }) {
const surveyBaseUrl = SURVEY_BASE_URL;
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const { responses, survey } = await getAnalysisData(params.surveyId, params.environmentId);
const [{ responses, survey }, environment] = await Promise.all([
getAnalysisData(params.surveyId, params.environmentId),
getEnvironment(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
const tags = await getTagsByEnvironmentId(params.environmentId);
return (
<>
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
<ResponsePage
environmentId={params.environmentId}
environment={environment}
responses={responses}
survey={survey}
surveyId={params.surveyId}
surveyBaseUrl={surveyBaseUrl}
surveyBaseUrl={SURVEY_BASE_URL}
product={product}
environmentTags={tags}
/>
</>
);

View File

@@ -19,7 +19,7 @@ export default function LinkSurveyModal({ survey, open, setOpen, surveyBaseUrl }
const linkTextRef = useRef(null);
const [showEmbed, setShowEmbed] = useState(false);
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey]);
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey.id, surveyBaseUrl]);
const iframeCode = `<div style="position: relative; height:100vh; max-height:100vh;
overflow:auto;">

View File

@@ -1,32 +0,0 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useEnvironment } from "@/lib/environments/environments";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { ErrorComponent } from "@formbricks/ui";
interface StatusDropdownProps {
survey: TSurvey;
environmentId: string;
}
export default function StatusDropdown({ survey, environmentId }: StatusDropdownProps) {
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
if (isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorEnvironment) {
return <ErrorComponent />;
}
return (
<>
{environment.widgetSetupCompleted || survey.type === "link" ? (
<SurveyStatusDropdown surveyId={survey.id} environmentId={environmentId} />
) : null}
</>
);
}

View File

@@ -1,47 +1,45 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Confetti } from "@formbricks/ui";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import LinkSurveyModal from "./LinkSurveyModal";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface SummaryMetadataProps {
environmentId: string;
environment: TEnvironment;
survey: TSurvey;
surveyBaseUrl: string;
}
export default function SuccessMessage({ environmentId, survey, surveyBaseUrl }: SummaryMetadataProps) {
const { environment } = useEnvironment(environmentId);
export default function SuccessMessage({ environment, survey, surveyBaseUrl }: SummaryMetadataProps) {
const searchParams = useSearchParams();
const [showLinkModal, setShowLinkModal] = useState(false);
const [confetti, setConfetti] = useState(false);
useEffect(() => {
if (environment) {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
survey.type === "web" && !environment.widgetSetupCompleted
? "Almost there! Install widget to start receiving responses."
: "Congrats! Your survey is live.",
{
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
}
);
if (survey.type === "link") {
setShowLinkModal(true);
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
survey.type === "web" && !environment.widgetSetupCompleted
? "Almost there! Install widget to start receiving responses."
: "Congrats! Your survey is live.",
{
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
}
// Remove success param from url
const url = new URL(window.location.href);
url.searchParams.delete("success");
window.history.replaceState({}, "", url.toString());
);
if (survey.type === "link") {
setShowLinkModal(true);
}
// Remove success param from url
const url = new URL(window.location.href);
url.searchParams.delete("success");
window.history.replaceState({}, "", url.toString());
}
}, [environment, searchParams, survey]);

View File

@@ -20,14 +20,15 @@ import NPSSummary from "./NPSSummary";
import OpenTextSummary from "./OpenTextSummary";
import RatingSummary from "./RatingSummary";
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface SummaryListProps {
environmentId: string;
environment: TEnvironment;
survey: TSurvey;
responses: TResponse[];
}
export default function SummaryList({ environmentId, survey, responses }: SummaryListProps) {
export default function SummaryList({ environment, survey, responses }: SummaryListProps) {
const getSummaryData = (): QuestionSummary<TSurveyQuestion>[] =>
survey.questions.map((question) => {
const questionResponses = responses
@@ -48,13 +49,13 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
<>
<div className="mt-10 space-y-8">
{survey.type === "web" && responses.length === 0 && (
<EmptyInAppSurveys environmentId={environmentId} />
<EmptyInAppSurveys environmentId={environment.id} />
)}
{survey.type !== "web" && responses.length === 0 ? (
<EmptySpaceFiller
type="response"
environmentId={environmentId}
environment={environment}
noWidgetRequired={survey.type === "link"}
/>
) : (
@@ -65,7 +66,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
<OpenTextSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<TSurveyOpenTextQuestion>}
environmentId={environmentId}
environmentId={environment.id}
/>
);
}
@@ -81,7 +82,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
>
}
environmentId={environmentId}
environmentId={environment.id}
surveyType={survey.type}
/>
);

View File

@@ -12,16 +12,29 @@ import { TResponse } from "@formbricks/types/v1/responses";
import { TSurveyWithAnalytics } 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 SummaryPageProps {
environmentId: string;
environment: TEnvironment;
survey: TSurveyWithAnalytics;
surveyId: string;
responses: TResponse[];
surveyBaseUrl: string;
product: TProduct;
environmentTags: TTag[];
}
const SummaryPage = ({ environmentId, survey, surveyId, responses, surveyBaseUrl }: SummaryPageProps) => {
const SummaryPage = ({
environment,
survey,
surveyId,
responses,
surveyBaseUrl,
product,
environmentTags,
}: SummaryPageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const searchParams = useSearchParams();
@@ -30,6 +43,7 @@ const SummaryPage = ({ environmentId, survey, surveyId, responses, surveyBaseUrl
resetState();
}
}, [searchParams]);
// get the filtered array when the selected filter value changes
const filterResponses: TResponse[] = useMemo(() => {
return getFilterResponses(responses, selectedFilter, survey, dateRange);
@@ -38,20 +52,21 @@ const SummaryPage = ({ environmentId, survey, surveyId, responses, surveyBaseUrl
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="summary" environmentId={environmentId} surveyId={surveyId} />
<SurveyResultsTabs activeId="summary" environmentId={environment.id} surveyId={surveyId} />
<SummaryMetadata responses={filterResponses} survey={survey} />
<SummaryList responses={filterResponses} survey={survey} environmentId={environmentId} />
<SummaryList responses={filterResponses} survey={survey} environment={environment} />
</ContentWrapper>
);
};

View File

@@ -5,6 +5,9 @@ import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/survey
import SummaryPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
import { getServerSession } from "next-auth";
export default async function Page({ params }) {
@@ -12,17 +15,31 @@ export default async function Page({ params }) {
if (!session) {
throw new Error("Unauthorized");
}
const { responses, survey } = await getAnalysisData(params.surveyId, params.environmentId);
const [{ responses, survey }, environment] = await Promise.all([
getAnalysisData(params.surveyId, params.environmentId),
getEnvironment(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
const tags = await getTagsByEnvironmentId(params.environmentId);
return (
<>
<ResponsesLimitReachedBanner environmentId={params.environmentId} surveyId={params.surveyId} />
<SummaryPage
environmentId={params.environmentId}
environment={environment}
responses={responses}
survey={survey}
surveyId={params.surveyId}
surveyBaseUrl={SURVEY_BASE_URL}
product={product}
environmentTags={tags}
/>
</>
);

View File

@@ -24,7 +24,7 @@ import { TSurvey } from "@formbricks/types/v1/surveys";
import { createId } from "@paralleldrive/cuid2";
import ResponseFilter from "./ResponseFilter";
import { DateRange, useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import { useTagsForEnvironment } from "@/lib/tags/tags";
import { TTag } from "@formbricks/types/v1/tags";
enum DateSelected {
FROM = "from",
@@ -44,7 +44,7 @@ enum FilterDropDownLabels {
}
interface CustomFilterProps {
environmentId: string;
environmentTags: TTag[];
survey: TSurvey;
responses: TResponse[];
totalResponses: TResponse[];
@@ -61,7 +61,7 @@ const getDifferenceOfDays = (from, to) => {
}
};
const CustomFilter = ({ environmentId, responses, survey, totalResponses }: CustomFilterProps) => {
const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: CustomFilterProps) => {
const { setSelectedOptions, dateRange, setDateRange } = useResponseFilter();
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
dateRange.from && dateRange.to
@@ -73,7 +73,6 @@ const CustomFilter = ({ environmentId, responses, survey, totalResponses }: Cust
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const { data: environmentTags } = useTagsForEnvironment(environmentId);
// when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options
useEffect(() => {

View File

@@ -1,8 +1,8 @@
"use client";
import { QuestionType } from "@formbricks/types/questions";
import { TSurveyQuestionType } from "@formbricks/types/v1/surveys";
import QuestionsComboBox, { QuestionOption, OptionsType } from "./QuestionsComboBox";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Popover, PopoverTrigger, PopoverContent, Button, Checkbox } from "@formbricks/ui";
import { ChevronDown, ChevronUp, Plus } from "lucide-react";
import { TrashIcon } from "@heroicons/react/24/solid";
@@ -11,7 +11,7 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/Resp
import clsx from "clsx";
export type QuestionFilterOptions = {
type: QuestionType | "Attributes" | "Tags";
type: TSurveyQuestionType | "Attributes" | "Tags";
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
@@ -47,6 +47,14 @@ const ResponseFilter = () => {
}
};
// when filter is opened and added a filter without selecting any option clear out that value
const clearItem = () => {
setSelectedFilter({
filter: [...selectedFilter.filter.filter((s) => s.questionType.hasOwnProperty("label"))],
onlyComplete: selectedFilter.onlyComplete,
});
};
// remove the added filter if nothing is selected when filter is closed
useEffect(() => {
if (!isOpen) {
@@ -76,14 +84,6 @@ const ResponseFilter = () => {
setSelectedFilter({ ...selectedFilter });
};
// when filter is opened and added a filter without selecting any option clear out that value
const clearItem = () => {
setSelectedFilter({
filter: [...selectedFilter.filter.filter((s) => s.questionType.hasOwnProperty("label"))],
onlyComplete: selectedFilter.onlyComplete,
});
};
const handleOnChangeFilterComboBoxValue = (o: string | string[], index: number) => {
selectedFilter.filter[index] = {
...selectedFilter.filter[index],

View File

@@ -1,7 +1,5 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
Button,
@@ -15,41 +13,32 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
ErrorComponent,
} from "@formbricks/ui";
import { PencilSquareIcon, EllipsisHorizontalIcon } from "@heroicons/react/24/solid";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { useSurveyMutation } from "@/lib/surveys/mutateSurveys";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import SuccessMessage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import LinkSurveyShareButton from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
interface SummaryHeaderProps {
surveyId: string;
environmentId: string;
environment: TEnvironment;
survey: TSurvey;
surveyBaseUrl: string;
product: TProduct;
}
const SummaryHeader = ({ surveyId, environmentId, survey, surveyBaseUrl }: SummaryHeaderProps) => {
const SummaryHeader = ({ surveyId, environment, survey, surveyBaseUrl, product }: SummaryHeaderProps) => {
const router = useRouter();
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const { triggerSurveyMutate } = useSurveyMutation(environmentId, surveyId);
const isCloseOnDateEnabled = survey.closeOnDate !== null;
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
const isStatusChangeDisabled = (isCloseOnDateEnabled && closeOnDate && closeOnDate < new Date()) ?? false;
if (isLoadingProduct || isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorProduct || isErrorEnvironment) {
return <ErrorComponent />;
}
return (
<div className="mb-11 mt-6 flex flex-wrap items-center justify-between">
<div>
@@ -59,12 +48,12 @@ const SummaryHeader = ({ surveyId, environmentId, survey, surveyBaseUrl }: Summa
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && <LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} />}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<SurveyStatusDropdown environmentId={environmentId} surveyId={surveyId} />
<SurveyStatusDropdown environment={environment} survey={survey} />
) : null}
<Button
variant="darkCTA"
className="h-full w-full px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
href={`/environments/${environment.id}/surveys/${surveyId}/edit`}>
Edit
<PencilSquareIcon className="ml-1 h-4" />
</Button>
@@ -94,7 +83,7 @@ const SummaryHeader = ({ surveyId, environmentId, survey, surveyBaseUrl }: Summa
disabled={isStatusChangeDisabled}
style={isStatusChangeDisabled ? { pointerEvents: "none", opacity: 0.5 } : {}}>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<SurveyStatusIndicator status={survey.status} environment={environment} />
<span className="ml-1 text-sm text-slate-700">
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}
@@ -107,7 +96,8 @@ const SummaryHeader = ({ surveyId, environmentId, survey, surveyBaseUrl }: Summa
<DropdownMenuRadioGroup
value={survey.status}
onValueChange={(value) => {
triggerSurveyMutate({ status: value })
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
surveyMutateAction({ ...survey, status: castedValue })
.then(() => {
toast.success(
value === "inProgress"
@@ -152,14 +142,14 @@ const SummaryHeader = ({ surveyId, environmentId, survey, surveyBaseUrl }: Summa
variant="darkCTA"
size="sm"
className="flex h-full w-full justify-center px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
href={`/environments/${environment.id}/surveys/${surveyId}/edit`}>
Edit
<PencilSquareIcon className="ml-1 h-4" />
</Button>
</DropdownMenuContent>
</DropdownMenu>
</div>
<SuccessMessage environmentId={environmentId} survey={survey} surveyBaseUrl={surveyBaseUrl} />
<SuccessMessage environment={environment} survey={survey} surveyBaseUrl={surveyBaseUrl} />
</div>
);
};

View File

@@ -1,10 +1,8 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProduct } from "@/lib/products/products";
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/lib/questions";
import { cn } from "@formbricks/lib/cn";
import { ErrorComponent } from "@formbricks/ui";
import { TProduct } from "@formbricks/types/v1/product";
import { PlusIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
@@ -12,17 +10,12 @@ import { useState } from "react";
interface AddQuestionButtonProps {
addQuestion: (question: any) => void;
environmentId: string;
product: TProduct;
}
export default function AddQuestionButton({ addQuestion, environmentId }: AddQuestionButtonProps) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
export default function AddQuestionButton({ addQuestion, product }: AddQuestionButtonProps) {
const [open, setOpen] = useState(false);
if (isLoadingProduct) return <LoadingSpinner />;
if (isErrorProduct) return <ErrorComponent />;
return (
<Collapsible.Root
open={open}

View File

@@ -9,16 +9,17 @@ import AddQuestionButton from "./AddQuestionButton";
import EditThankYouCard from "./EditThankYouCard";
import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import { Question } from "@formbricks/types/questions";
import { TSurveyQuestion } from "@formbricks/types/v1/surveys";
import { validateQuestion } from "./Validation";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TProduct } from "@formbricks/types/v1/product";
interface QuestionsViewProps {
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
environmentId: string;
product: TProduct;
invalidQuestions: String[] | null;
setInvalidQuestions: (invalidQuestions: String[] | null) => void;
}
@@ -28,7 +29,7 @@ export default function QuestionsView({
setActiveQuestionId,
localSurvey,
setLocalSurvey,
environmentId,
product,
invalidQuestions,
setInvalidQuestions,
}: QuestionsViewProps) {
@@ -58,7 +59,7 @@ export default function QuestionsView({
};
// function to validate individual questions
const validateSurvey = (question: Question) => {
const validateSurvey = (question: TSurveyQuestion) => {
// prevent this function to execute further if user hasnt still tried to save the survey
if (invalidQuestions === null) {
return;
@@ -212,7 +213,7 @@ export default function QuestionsView({
</StrictModeDroppable>
</div>
</DragDropContext>
<AddQuestionButton addQuestion={addQuestion} environmentId={environmentId} />
<AddQuestionButton addQuestion={addQuestion} product={product} />
<div className="mt-5">
<EditThankYouCard
localSurvey={localSurvey}

View File

@@ -146,7 +146,7 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setCloseOnDate(localSurvey.closeOnDate);
setSurveyCloseOnDateToggle(true);
}
}, []);
}, [localSurvey]);
const handleCheckMark = () => {
if (autoComplete) {

View File

@@ -46,7 +46,7 @@ export default function SurveyEditor({
// when the survey type changes, we need to reset the active question id to the first question
useEffect(() => {
if (localSurvey && localSurvey.questions?.length > 0) {
if (localSurvey?.questions?.length && localSurvey.questions.length > 0) {
setActiveQuestionId(localSurvey.questions[0].id);
}
}, [localSurvey?.type]);
@@ -77,7 +77,7 @@ export default function SurveyEditor({
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environment.id}
product={product}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
/>

View File

@@ -205,8 +205,8 @@ export default function SurveyMenuBar({
<div className="mt-3 flex sm:ml-4 sm:mt-0">
<div className="mr-4 flex items-center">
<SurveyStatusDropdown
surveyId={localSurvey.id}
environmentId={environment.id}
survey={survey}
environment={environment}
updateLocalSurveyStatus={updateLocalSurveyStatus}
/>
</div>

View File

@@ -16,7 +16,7 @@ import {
} from "@formbricks/ui";
import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
@@ -40,30 +40,36 @@ export default function WhenToSendCard({
const autoClose = localSurvey.autoClose !== null;
let newTrigger = {
id: "", // Set the appropriate value for the id
createdAt: new Date(),
updatedAt: new Date(),
name: "",
type: "code" as const, // Set the appropriate value for the type
environmentId: "",
description: null,
noCodeConfig: null,
};
let newTrigger = useMemo(
() => ({
id: "", // Set the appropriate value for the id
createdAt: new Date(),
updatedAt: new Date(),
name: "",
type: "code" as const, // Set the appropriate value for the type
environmentId: "",
description: null,
noCodeConfig: null,
}),
[]
);
const addTriggerEvent = () => {
const addTriggerEvent = useCallback(() => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers = [...localSurvey.triggers, newTrigger];
setLocalSurvey(updatedSurvey);
};
}, [newTrigger, localSurvey, setLocalSurvey]);
const setTriggerEvent = (idx: number, actionClassId: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers[idx] = actionClassArray!.find((actionClass) => {
return actionClass.id === actionClassId;
})!;
setLocalSurvey(updatedSurvey);
};
const setTriggerEvent = useCallback(
(idx: number, actionClassId: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers[idx] = actionClassArray!.find((actionClass) => {
return actionClass.id === actionClassId;
})!;
setLocalSurvey(updatedSurvey);
},
[actionClassArray, localSurvey, setLocalSurvey]
);
const removeTriggerEvent = (idx: number) => {
const updatedSurvey = { ...localSurvey };
@@ -95,11 +101,12 @@ export default function WhenToSendCard({
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, delay: value };
setLocalSurvey(updatedSurvey);
};
useEffect(() => {
if (activeIndex !== null) {
setTriggerEvent(activeIndex, actionClassArray[actionClassArray.length - 1].id);
}
}, [actionClassArray]);
}, [actionClassArray, activeIndex, setTriggerEvent]);
useEffect(() => {
if (localSurvey.type === "link") {
@@ -112,7 +119,7 @@ export default function WhenToSendCard({
if (localSurvey.triggers.length === 0) {
addTriggerEvent();
}
}, []);
}, [addTriggerEvent, localSurvey.triggers.length]);
return (
<>

View File

@@ -2,6 +2,12 @@ import { SigninForm } from "@/components/auth/SigninForm";
import Testimonial from "@/components/auth/Testimonial";
import FormWrapper from "@/components/auth/FormWrapper";
import { Metadata } from "next";
import {
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
PASSWORD_RESET_DISABLED,
SIGNUP_ENABLED,
} from "@formbricks/lib/constants";
export const metadata: Metadata = {
title: "Login",
@@ -16,7 +22,12 @@ export default function SignInPage() {
</div>
<div className="col-span-3 flex flex-col items-center justify-center">
<FormWrapper>
<SigninForm />
<SigninForm
publicSignUpEnabled={SIGNUP_ENABLED}
passwordResetEnabled={!PASSWORD_RESET_DISABLED}
googleOAuthEnabled={GOOGLE_OAUTH_ENABLED}
githubOAuthEnabled={GITHUB_OAUTH_ENABLED}
/>
</FormWrapper>
</div>
</div>

View File

@@ -1,15 +1,25 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SignupForm } from "@/components/auth/SignupForm";
import FormWrapper from "@/components/auth/FormWrapper";
import Testimonial from "@/components/auth/Testimonial";
import { env } from "@/env.mjs";
import {
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
INVITE_DISABLED,
PASSWORD_RESET_DISABLED,
PRIVACY_URL,
SIGNUP_ENABLED,
TERMS_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
export default function SignUpPage() {
const searchParams = useSearchParams();
const inviteToken = searchParams?.get("inviteToken");
export default function SignUpPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const inviteToken = searchParams["inviteToken"] ?? null;
return (
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
@@ -18,9 +28,7 @@ export default function SignUpPage() {
</div>
<div className="col-span-3 flex flex-col items-center justify-center">
<FormWrapper>
{(
inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
) ? (
{(inviteToken ? INVITE_DISABLED : !SIGNUP_ENABLED) ? (
<>
<h1 className="leading-2 mb-4 text-center font-bold">Sign up disabled</h1>
<p className="text-center">
@@ -35,7 +43,15 @@ export default function SignUpPage() {
</Link>
</>
) : (
<SignupForm />
<SignupForm
webAppUrl={WEBAPP_URL}
termsUrl={TERMS_URL}
privacyUrl={PRIVACY_URL}
passwordResetEnabled={!PASSWORD_RESET_DISABLED}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
googleOAuthEnabled={GOOGLE_OAUTH_ENABLED}
githubOAuthEnabled={GITHUB_OAUTH_ENABLED}
/>
)}
</FormWrapper>
</div>

View File

@@ -2,7 +2,6 @@ import { sendInviteAcceptedEmail } from "@/lib/email";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { env } from "process";
import { prisma } from "@formbricks/database";
import {
NotLoggedInContent,
@@ -11,6 +10,7 @@ import {
UsedContent,
RightAccountContent,
} from "./InviteContentComponents";
import { env } from "@/env.mjs";
export default async function JoinTeam({ searchParams }) {
const currentUser = await getServerSession(authOptions);

View File

@@ -1,7 +1,7 @@
import { env } from "@/env.mjs";
import { verifyPassword } from "@/lib/auth";
import { prisma } from "@formbricks/database";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { verifyToken } from "@formbricks/lib/jwt";
import { getProfileByEmail } from "@formbricks/lib/services/profile";
import type { IdentityProvider } from "@prisma/client";
@@ -166,7 +166,7 @@ export const authOptions: NextAuthOptions = {
},
async signIn({ user, account }: any) {
if (account.provider === "credentials" || account.provider === "token") {
if (!user.emailVerified && env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
}
return true;

View File

@@ -1,13 +1,13 @@
import { env } from "@/env.mjs";
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { CRON_SECRET } from "@formbricks/lib/constants";
import { headers } from "next/headers";
export async function POST() {
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey || apiKey !== env.CRON_SECRET) {
if (!apiKey || apiKey !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}

View File

@@ -1,6 +1,10 @@
import { env } from "@/env.mjs";
import { prisma } from "@formbricks/database";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import {
GOOGLE_SHEETS_CLIENT_ID,
WEBAPP_URL,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@formbricks/lib/constants";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
@@ -18,9 +22,9 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
}
const client_id = env.GOOGLE_SHEETS_CLIENT_ID;
const client_secret = env.GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = env.GOOGLE_SHEETS_REDIRECT_URL;
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });

View File

@@ -1,5 +1,9 @@
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { env } from "@/env.mjs";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@formbricks/lib/constants";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
@@ -24,9 +28,9 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
}
const client_id = env.GOOGLE_SHEETS_CLIENT_ID;
const client_secret = env.GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = env.GOOGLE_SHEETS_REDIRECT_URL;
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });

View File

@@ -3,13 +3,18 @@ import { verifyInviteToken } from "@formbricks/lib/jwt";
import { populateEnvironment } from "@/lib/populate";
import { prisma } from "@formbricks/database";
import { NextResponse } from "next/server";
import { env } from "@/env.mjs";
import { Prisma } from "@prisma/client";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import {
EMAIL_VERIFICATION_DISABLED,
INTERNAL_SECRET,
INVITE_DISABLED,
SIGNUP_ENABLED,
WEBAPP_URL,
} from "@formbricks/lib/constants";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
if (inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1") {
if (inviteToken ? INVITE_DISABLED : !SIGNUP_ENABLED) {
return NextResponse.json({ error: "Signup disabled" }, { status: 403 });
}
user = { ...user, ...{ email: user.email.toLowerCase() } };
@@ -117,7 +122,7 @@ export async function POST(request: Request) {
await prisma.invite.delete({ where: { id: inviteId } });
}
if (env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(userData);
}
return NextResponse.json(userData);

View File

@@ -1,28 +1,10 @@
import ClientLogout from "@/app/ClientLogout";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironmentByUser } from "@formbricks/lib/services/environment";
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
async function getEnvironment() {
const cookie = headers().get("cookie") || "";
const res = await fetch(`${WEBAPP_URL}/api/v1/environments/find-first`, {
headers: {
cookie,
},
});
if (!res.ok) {
const error = await res.json();
console.error(error);
throw new Error("Failed to fetch data");
}
return res.json();
}
export default async function Home() {
const session: Session | null = await getServerSession(authOptions);
@@ -36,7 +18,7 @@ export default async function Home() {
let environment;
try {
environment = await getEnvironment();
environment = await getEnvironmentByUser(session?.user);
} catch (error) {
console.error("error getting environment", error);
}

View File

@@ -1,19 +1,19 @@
import { env } from "@/env.mjs";
import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
import Link from "next/link";
export default function LegalFooter() {
if (!env.NEXT_PUBLIC_IMPRINT_URL && !env.NEXT_PUBLIC_PRIVACY_URL) return null;
if (!IMPRINT_URL && !PRIVACY_URL) return null;
return (
<div className="h-10 w-full border-t border-slate-200">
<div className="mx-auto max-w-lg p-3 text-center text-sm text-slate-400">
{env.NEXT_PUBLIC_IMPRINT_URL && (
<Link href={env.NEXT_PUBLIC_IMPRINT_URL} target="_blank">
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank">
Imprint
</Link>
)}
{env.NEXT_PUBLIC_IMPRINT_URL && env.NEXT_PUBLIC_PRIVACY_URL && <span> | </span>}
{env.NEXT_PUBLIC_PRIVACY_URL && (
<Link href={env.NEXT_PUBLIC_PRIVACY_URL} target="_blank">
{IMPRINT_URL && PRIVACY_URL && <span> | </span>}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank">
Privacy Policy
</Link>
)}

View File

@@ -3,7 +3,6 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import { SurveyInline } from "@/components/shared/Survey";
import { createDisplay } from "@formbricks/lib/client/display";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { TProduct } from "@formbricks/types/v1/product";
@@ -21,6 +20,7 @@ interface LinkSurveyProps {
personId?: string;
emailVerificationStatus?: string;
prefillAnswer?: string;
webAppUrl: string;
}
export default function LinkSurvey({
@@ -29,6 +29,7 @@ export default function LinkSurvey({
personId,
emailVerificationStatus,
prefillAnswer,
webAppUrl,
}: LinkSurveyProps) {
const searchParams = useSearchParams();
const isPreview = searchParams?.get("preview") === "true";
@@ -42,7 +43,7 @@ export default function LinkSurvey({
() =>
new ResponseQueue(
{
apiHost: WEBAPP_URL,
apiHost: webAppUrl,
retryAttempts: 2,
onResponseSendingFailed: (response) => {
alert(`Failed to send response: ${JSON.stringify(response, null, 2)}`);
@@ -52,7 +53,7 @@ export default function LinkSurvey({
},
surveyState
),
[]
[personId, webAppUrl]
);
const [autoFocus, setAutofocus] = useState(false);
@@ -63,6 +64,10 @@ export default function LinkSurvey({
}
}, []);
useEffect(() => {
responseQueue.updateSurveyState(surveyState);
}, [responseQueue, surveyState]);
if (emailVerificationStatus && emailVerificationStatus !== "verified") {
if (emailVerificationStatus === "fishy") {
return <VerifyEmail survey={survey} isErrorComponent={true} />;
@@ -89,9 +94,16 @@ export default function LinkSurvey({
survey={survey}
brandColor={product.brandColor}
formbricksSignature={product.formbricksSignature}
onDisplay={() => createDisplay({ surveyId: survey.id }, window?.location?.origin)}
onDisplay={async () => {
if (!isPreview) {
const { id } = await createDisplay({ surveyId: survey.id }, window?.location?.origin);
const newSurveyState = surveyState.copy();
newSurveyState.updateDisplayId(id);
setSurveyState(newSurveyState);
}
}}
onResponse={(responseUpdate) => {
responseQueue.add(responseUpdate);
!isPreview && responseQueue.add(responseUpdate);
}}
onActiveQuestionChange={(questionId) => setActiveQuestionId(questionId)}
activeQuestionId={activeQuestionId}

View File

@@ -2,7 +2,7 @@ export const revalidate = REVALIDATION_INTERVAL;
import LinkSurvey from "@/app/s/[surveyId]/LinkSurvey";
import SurveyInactive from "@/app/s/[surveyId]/SurveyInactive";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getOrCreatePersonByUserId } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getSurvey } from "@formbricks/lib/services/survey";
@@ -59,6 +59,7 @@ export default async function LinkSurveyPage({ params, searchParams }) {
personId={person?.id}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
webAppUrl={WEBAPP_URL}
/>
);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { GoogleButton } from "@/components/auth/GoogleButton";
import { env } from "@/env.mjs";
import { Button, PasswordInput } from "@formbricks/ui";
import { XCircleIcon } from "@heroicons/react/24/solid";
import { signIn } from "next-auth/react";
@@ -10,7 +9,17 @@ import { useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import { GithubButton } from "./GithubButton";
export const SigninForm = () => {
export const SigninForm = ({
publicSignUpEnabled,
passwordResetEnabled,
googleOAuthEnabled,
githubOAuthEnabled,
}: {
publicSignUpEnabled: boolean;
passwordResetEnabled: boolean;
googleOAuthEnabled: boolean;
githubOAuthEnabled: boolean;
}) => {
const searchParams = useSearchParams();
const emailRef = useRef<HTMLInputElement>(null);
@@ -79,7 +88,7 @@ export const SigninForm = () => {
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
</div>
{env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
{passwordResetEnabled && isPasswordFocused && (
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
<Link
href="/auth/forgot-password"
@@ -109,18 +118,18 @@ export const SigninForm = () => {
</Button>
</form>
{env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
{googleOAuthEnabled && (
<>
<GoogleButton inviteUrl={callbackUrl} />
</>
)}
{env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
{githubOAuthEnabled && (
<>
<GithubButton inviteUrl={callbackUrl} />
</>
)}
</div>
{env.NEXT_PUBLIC_SIGNUP_DISABLED !== "1" && (
{publicSignUpEnabled && (
<div className="mt-9 text-center text-xs ">
<span className="leading-5 text-slate-500">New to Formbricks?</span>
<br />

View File

@@ -2,7 +2,6 @@
import { GoogleButton } from "@/components/auth/GoogleButton";
import IsPasswordValid from "@/components/auth/IsPasswordValid";
import { env } from "@/env.mjs";
import { createUser } from "@/lib/users/users";
import { Button, PasswordInput } from "@formbricks/ui";
import { XCircleIcon } from "@heroicons/react/24/solid";
@@ -11,7 +10,23 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useRef, useState } from "react";
import { GithubButton } from "./GithubButton";
export const SignupForm = () => {
export const SignupForm = ({
webAppUrl,
privacyUrl,
termsUrl,
passwordResetEnabled,
emailVerificationDisabled,
googleOAuthEnabled,
githubOAuthEnabled,
}: {
webAppUrl: string;
privacyUrl: string | undefined;
termsUrl: string | undefined;
passwordResetEnabled: boolean;
emailVerificationDisabled: boolean;
googleOAuthEnabled: boolean;
githubOAuthEnabled: boolean;
}) => {
const searchParams = useSearchParams();
const router = useRouter();
const [error, setError] = useState<string>("");
@@ -19,15 +34,13 @@ export const SignupForm = () => {
const nameRef = useRef<HTMLInputElement>(null);
const inviteToken = searchParams?.get("inviteToken");
// const callbackUrl = env.NEXT_PUBLIC_WEBAPP_URL + "/invite?token=" + inviteToken;
const callbackUrl = useMemo(() => {
if (inviteToken) {
return env.NEXT_PUBLIC_WEBAPP_URL + "/invite?token=" + inviteToken;
return webAppUrl + "/invite?token=" + inviteToken;
} else {
return env.NEXT_PUBLIC_WEBAPP_URL;
return webAppUrl;
}
}, [inviteToken]);
}, [inviteToken, webAppUrl]);
const handleSubmit = async (e: any) => {
e.preventDefault();
@@ -45,10 +58,9 @@ export const SignupForm = () => {
e.target.elements.password.value,
inviteToken
);
const url =
env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED === "1"
? `/auth/signup-without-verification-success`
: `/auth/verification-requested?email=${encodeURIComponent(e.target.elements.email.value)}`;
const url = emailVerificationDisabled
? `/auth/signup-without-verification-success`
: `/auth/verification-requested?email=${encodeURIComponent(e.target.elements.email.value)}`;
router.push(url);
} catch (e: any) {
@@ -144,7 +156,7 @@ export const SignupForm = () => {
className="focus:border-brand focus:ring-brand block w-full rounded-md shadow-sm sm:text-sm"
/>
</div>
{env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
{passwordResetEnabled && isPasswordFocused && (
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
<Link
href="/auth/forgot-password"
@@ -176,38 +188,30 @@ export const SignupForm = () => {
</Button>
</form>
{env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
{googleOAuthEnabled && (
<>
<GoogleButton inviteUrl={callbackUrl} />
</>
)}
{env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
{githubOAuthEnabled && (
<>
<GithubButton inviteUrl={callbackUrl} />
</>
)}
</div>
{(env.NEXT_PUBLIC_TERMS_URL || env.NEXT_PUBLIC_PRIVACY_URL) && (
{(termsUrl || privacyUrl) && (
<div className="mt-3 text-center text-xs text-slate-500">
By signing up, you agree to our
<br />
{env.NEXT_PUBLIC_TERMS_URL && (
<Link
className="font-semibold"
href={env.NEXT_PUBLIC_TERMS_URL}
rel="noreferrer"
target="_blank">
{termsUrl && (
<Link className="font-semibold" href={termsUrl} rel="noreferrer" target="_blank">
Terms of Service
</Link>
)}
{env.NEXT_PUBLIC_TERMS_URL && env.NEXT_PUBLIC_PRIVACY_URL && <span> and </span>}
{env.NEXT_PUBLIC_PRIVACY_URL && (
<Link
className="font-semibold"
href={env.NEXT_PUBLIC_PRIVACY_URL}
rel="noreferrer"
target="_blank">
{termsUrl && privacyUrl && <span> and </span>}
{privacyUrl && (
<Link className="font-semibold" href={privacyUrl} rel="noreferrer" target="_blank">
Privacy Policy.
</Link>
)}

View File

@@ -1,27 +1,39 @@
import { cn } from "@formbricks/lib/cn";
import SurveyNavBarName from "@/components/shared/SurveyNavBarName";
import Link from "next/link";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getSurvey } from "@formbricks/lib/services/survey";
interface SecondNavbarProps {
tabs: { id: string; label: string; href: string; icon?: React.ReactNode }[];
activeId: string;
surveyId?: string;
environmentId?: string;
environmentId: string;
}
export default function SecondNavbar({
export default async function SecondNavbar({
tabs,
activeId,
surveyId,
environmentId,
...props
}: SecondNavbarProps) {
const product = await getProductByEnvironmentId(environmentId!);
if (!product) {
throw new Error("Product not found");
}
let survey;
if (surveyId) {
survey = await getSurvey(surveyId);
}
return (
<div {...props}>
<div className="grid h-14 w-full grid-cols-3 items-center justify-items-stretch border-b bg-white px-4">
<div className="justify-self-start">
{surveyId && environmentId && (
<SurveyNavBarName surveyId={surveyId} environmentId={environmentId} />
{survey && environmentId && (
<SurveyNavBarName surveyName={survey.name} productName={product.name} />
)}
</div>{" "}
<nav className="flex h-full items-center space-x-4 justify-self-center" aria-label="Tabs">

View File

@@ -2,27 +2,21 @@
import React from "react";
import Link from "next/link";
import { useEnvironment } from "@/lib/environments/environments";
import LoadingSpinner from "./LoadingSpinner";
import { TEnvironment } from "@formbricks/types/v1/environment";
type EmptySpaceFillerProps = {
type: "table" | "response" | "event" | "linkResponse" | "tag";
environmentId: string;
environment: TEnvironment;
noWidgetRequired?: boolean;
emptyMessage?: string;
};
const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({
type,
environmentId,
environment,
noWidgetRequired,
emptyMessage,
}) => {
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
if (isLoadingEnvironment) return <LoadingSpinner />;
if (isErrorEnvironment) return <span>Error</span>;
if (type === "table") {
return (
<div className="group">
@@ -34,7 +28,7 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({
{!environment.widgetSetupCompleted && !noWidgetRequired && (
<Link
className="flex w-full items-center justify-center"
href={`/environments/${environmentId}/settings/setup`}>
href={`/environments/${environment.id}/settings/setup`}>
<span className="decoration-brand-dark underline transition-all duration-300 ease-in-out">
Install Formbricks Widget. <strong>Go to Setup Checklist 👉</strong>
</span>
@@ -63,7 +57,7 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({
{!environment.widgetSetupCompleted && !noWidgetRequired && (
<Link
className="flex h-full w-full items-center justify-center"
href={`/environments/${environmentId}/settings/setup`}>
href={`/environments/${environment.id}/settings/setup`}>
<span className="decoration-brand-dark underline transition-all duration-300 ease-in-out">
Install Formbricks Widget. <strong>Go to Setup Checklist 👉</strong>
</span>
@@ -94,7 +88,7 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({
{!environment.widgetSetupCompleted && !noWidgetRequired && (
<Link
className="flex h-full w-full items-center justify-center"
href={`/environments/${environmentId}/settings/setup`}>
href={`/environments/${environment.id}/settings/setup`}>
<span className="decoration-brand-dark underline transition-all duration-300 ease-in-out">
Install Formbricks Widget. <strong>Go to Setup Checklist 👉</strong>
</span>
@@ -122,7 +116,7 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({
{!environment.widgetSetupCompleted && !noWidgetRequired && (
<Link
className="flex h-full w-full items-center justify-center"
href={`/environments/${environmentId}/settings/setup`}>
href={`/environments/${environment.id}/settings/setup`}>
<span className="decoration-brand-dark underline transition-all duration-300 ease-in-out">
Install Formbricks Widget. <strong>Go to Setup Checklist 👉</strong>
</span>

View File

@@ -1,25 +1,9 @@
"use client";
import { useProduct } from "@/lib/products/products";
import { useSurvey } from "@/lib/surveys/surveys";
interface SurveyNavBarNameProps {
surveyId: string;
environmentId: string;
surveyName: string;
productName: string;
}
export default function SurveyNavBarName({ surveyId, environmentId }: SurveyNavBarNameProps) {
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
if (isLoadingSurvey || isLoadingProduct) {
return null;
}
if (isErrorProduct || isErrorSurvey) {
return null;
}
export default function SurveyNavBarName({ surveyName, productName }: SurveyNavBarNameProps) {
return (
<div className="hidden items-center space-x-2 whitespace-nowrap md:flex">
{/* <Button
@@ -30,8 +14,8 @@ export default function SurveyNavBarName({ surveyId, environmentId }: SurveyNavB
}}>
Back
</Button> */}
<p className="pl-4 font-semibold">{product.name} / </p>
<span>{survey.name}</span>
<p className="pl-4 font-semibold">{productName} / </p>
<span>{surveyName}</span>
</div>
);
}

View File

@@ -1,11 +1,10 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { surveyMutateAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { useSurveyMutation } from "@/lib/surveys/mutateSurveys";
import { useSurvey } from "@/lib/surveys/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
ErrorComponent,
Select,
SelectContent,
SelectItem,
@@ -20,43 +19,32 @@ import { CheckCircleIcon, PauseCircleIcon, PlayCircleIcon } from "@heroicons/rea
import toast from "react-hot-toast";
export default function SurveyStatusDropdown({
surveyId,
environmentId,
environment,
updateLocalSurveyStatus,
survey,
}: {
surveyId: string;
environmentId: string;
environment: TEnvironment;
updateLocalSurveyStatus?: (status: "draft" | "inProgress" | "paused" | "completed" | "archived") => void;
survey: TSurvey;
}) {
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { triggerSurveyMutate } = useSurveyMutation(environmentId, surveyId);
if (isLoadingSurvey) {
return <LoadingSpinner />;
}
if (isErrorSurvey) {
return <ErrorComponent />;
}
const isCloseOnDateEnabled = survey.closeOnDate !== null;
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
const isStatusChangeDisabled = (isCloseOnDateEnabled && closeOnDate && closeOnDate < new Date()) ?? false;
return (
<>
{survey.status === "draft" || survey.status === "archived" ? (
{survey.status === "draft" ? (
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<SurveyStatusIndicator status={survey.status} environment={environment} />
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
{survey.status === "archived" && <p className="text-sm italic text-slate-600">Archived</p>}
</div>
) : (
<Select
value={survey.status}
disabled={isStatusChangeDisabled}
onValueChange={(value) => {
triggerSurveyMutate({ status: value })
const castedValue = value as "draft" | "inProgress" | "paused" | "completed";
surveyMutateAction({ ...survey, status: castedValue })
.then(() => {
toast.success(
value === "inProgress"
@@ -81,13 +69,11 @@ export default function SurveyStatusDropdown({
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<SurveyStatusIndicator status={survey.status} environment={environment} />
<span className="ml-2 text-sm text-slate-700">
{survey.status === "draft" && "Draft"}
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}
{survey.status === "completed" && "Completed"}
{survey.status === "archived" && "Archived"}
</span>
</div>
</SelectValue>

View File

@@ -1,25 +1,16 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
interface SurveyStatusIndicatorProps {
status: string;
tooltip?: boolean;
environmentId: string;
environment: TEnvironment;
}
export default function SurveyStatusIndicator({
status,
tooltip,
environmentId,
}: SurveyStatusIndicatorProps) {
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
if (isLoadingEnvironment) return <></>;
if (isErrorEnvironment) return <></>;
export default function SurveyStatusIndicator({ status, tooltip, environment }: SurveyStatusIndicatorProps) {
if (!environment.widgetSetupCompleted) return null;
if (tooltip) {
return (

View File

@@ -25,38 +25,41 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
CRON_SECRET: z.string().optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
PRIVACY_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
TERMS_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
IMPRINT_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
GITHUB_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
GOOGLE_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
VERCEL_URL: z.string().url().optional(),
SURVEY_BASE_URL: z.string().optional(),
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
},
/*
* Environment variables available on the client (and server).
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {
NEXT_PUBLIC_WEBAPP_URL: z.string().url().optional(),
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_INVITE_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_PRIVACY_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_TERMS_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_IMPRINT_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_GITHUB_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_FORMBRICKS_API_HOST: z
.string()
.url()
@@ -64,11 +67,9 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: z.string().optional(),
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
NEXT_PUBLIC_SURVEY_BASE_URL: z.string().optional(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -95,24 +96,26 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
CRON_SECRET: process.env.CRON_SECRET,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
SIGNUP_DISABLED: process.env.SIGNUP_DISABLED,
INVITE_DISABLED: process.env.INVITE_DISABLED,
PRIVACY_URL: process.env.PRIVACY_URL,
TERMS_URL: process.env.TERMS_URL,
IMPRINT_URL: process.env.IMPRINT_URL,
GITHUB_AUTH_ENABLED: process.env.GITHUB_AUTH_ENABLED,
GOOGLE_AUTH_ENABLED: process.env.GOOGLE_AUTH_ENABLED,
GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
NEXT_PUBLIC_WEBAPP_URL: process.env.NEXT_PUBLIC_WEBAPP_URL,
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED: process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED,
NEXT_PUBLIC_PASSWORD_RESET_DISABLED: process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED,
NEXT_PUBLIC_SIGNUP_DISABLED: process.env.NEXT_PUBLIC_SIGNUP_DISABLED,
NEXT_PUBLIC_INVITE_DISABLED: process.env.NEXT_PUBLIC_INVITE_DISABLED,
NEXT_PUBLIC_PRIVACY_URL: process.env.NEXT_PUBLIC_PRIVACY_URL,
NEXT_PUBLIC_TERMS_URL: process.env.NEXT_PUBLIC_TERMS_URL,
NEXT_PUBLIC_IMPRINT_URL: process.env.NEXT_PUBLIC_IMPRINT_URL,
NEXT_PUBLIC_GITHUB_AUTH_ENABLED: process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED,
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED: process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED,
NEXT_PUBLIC_FORMBRICKS_API_HOST: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID,
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
VERCEL_URL: process.env.VERCEL_URL,
SURVEY_BASE_URL: process.env.SURVEY_BASE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
},
});

View File

@@ -1,39 +0,0 @@
import useSWR from "swr";
import { fetcher } from "@formbricks/lib/fetcher";
export const useApiKeys = (environmentId: string) => {
const { data, error, mutate } = useSWR(`/api/v1/environments/${environmentId}/api-keys`, fetcher);
return {
apiKeys: data,
isLoadingApiKeys: !error && !data,
isErrorApiKeys: error,
mutateApiKeys: mutate,
};
};
export const createApiKey = async (environmentId: string, apiKey = {}) => {
try {
const res = await fetch(`/api/v1/environments/${environmentId}/api-keys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(apiKey),
});
return await res.json();
} catch (error) {
console.error(error);
throw Error(`createApiKey: unable to create api-key: ${error.message}`);
}
};
export const deleteApiKey = async (environmentId: string, apiKey) => {
try {
const res = await fetch(`/api/v1/environments/${environmentId}/api-keys/${apiKey.id}`, {
method: "DELETE",
});
return await res.json();
} catch (error) {
console.error(error);
throw Error(`deleteApiKey: unable to delete api-key: ${error.message}`);
}
};

View File

@@ -1,38 +0,0 @@
import useSWR from "swr";
import { fetcher } from "@formbricks/lib/fetcher";
import type { AttributeClass } from "@prisma/client";
export const useAttributeClasses = (environmentId) => {
const { data, isLoading, error, mutate, isValidating } = useSWR(
`/api/v1/environments/${environmentId}/attribute-classes`,
fetcher
);
return {
attributeClasses: data,
isLoadingAttributeClasses: isLoading,
isErrorAttributeClasses: error,
isValidatingAttributeClasses: isValidating,
mutateAttributeClasses: mutate,
};
};
type AttributeClassWithSurvey = AttributeClass & {
activeSurveys: string[];
inactiveSurveys: string[];
};
export const useAttributeClass = (environmentId: string, attributeClassId: string) => {
const { data, isLoading, error, mutate, isValidating } = useSWR(
`/api/v1/environments/${environmentId}/attribute-classes/${attributeClassId}`,
fetcher
);
return {
attributeClass: data as AttributeClassWithSurvey,
isLoadingAttributeClass: isLoading,
isErrorAttributeClass: error,
isValidatingAttributeClass: isValidating,
mutateAttributeClass: mutate,
};
};

View File

@@ -1,14 +0,0 @@
import useSWRMutation from "swr/mutation";
import { updateRessource } from "@formbricks/lib/fetcher";
export function useAttributeClassMutation(environmentId: string, attributeClassId: string) {
const { trigger, isMutating } = useSWRMutation(
`/api/v1/environments/${environmentId}/attribute-classes/${attributeClassId}`,
updateRessource
);
return {
triggerAttributeClassMutate: trigger,
isMutatingAttributeClass: isMutating,
};
}

View File

@@ -1,6 +1,13 @@
import { env } from "@/env.mjs";
import { getQuestionResponseMapping } from "@/lib/responses/questionResponseMapping";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import {
MAIL_FROM,
SMTP_HOST,
SMTP_PASSWORD,
SMTP_PORT,
SMTP_SECURE_ENABLED,
SMTP_USER,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { Question } from "@formbricks/types/questions";
import { TResponse } from "@formbricks/types/v1/responses";
import { withEmailTemplate } from "./email-template";
@@ -18,18 +25,18 @@ interface sendEmailData {
export const sendEmail = async (emailData: sendEmailData) => {
let transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE_ENABLED === "1", // true for 465, false for other ports
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
user: SMTP_USER,
pass: SMTP_PASSWORD,
},
// logger: true,
// debug: true,
});
const emailDefaults = {
from: `Formbricks <${env.MAIL_FROM || "noreply@formbricks.com"}>`,
from: `Formbricks <${MAIL_FROM || "noreply@formbricks.com"}>`,
};
await transporter.sendMail({ ...emailDefaults, ...emailData });
};
@@ -147,7 +154,7 @@ export const sendResponseFinishedEmail = async (
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail?.toString() || env.MAIL_FROM,
replyTo: personEmail?.toString() || MAIL_FROM,
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
survey.name
}</strong><br/>

View File

@@ -1,17 +0,0 @@
import useSWR from "swr";
import { fetcher } from "@formbricks/lib/fetcher";
export const useEnvironment = (environmentId: string) => {
const { data, isLoading, error, mutate, isValidating } = useSWR(
`/api/v1/environments/${environmentId}/`,
fetcher
);
return {
environment: data,
isLoadingEnvironment: isLoading,
isErrorEnvironment: error,
isValidatingEnvironment: isValidating,
mutateEnvironment: mutate,
};
};

View File

@@ -1,11 +0,0 @@
import useSWRMutation from "swr/mutation";
import { updateRessource } from "@formbricks/lib/fetcher";
export function useEnvironmentMutation(environmentId: string) {
const { trigger, isMutating } = useSWRMutation(`/api/v1/environments/${environmentId}`, updateRessource);
return {
triggerEnvironmentMutate: trigger,
isMutatingEnvironment: isMutating,
};
}

View File

@@ -1,66 +0,0 @@
import useSWR from "swr";
import { fetcher } from "@formbricks/lib/fetcher";
import type { Event } from "@formbricks/types/events";
export const useEventClasses = (environmentId) => {
const { data, isLoading, error, mutate, isValidating } = useSWR(
`/api/v1/environments/${environmentId}/event-classes`,
fetcher
);
return {
eventClasses: data,
isLoadingEventClasses: isLoading,
isErrorEventClasses: error,
isValidatingEventClasses: isValidating,
mutateEventClasses: mutate,
};
};
export const useEventClass = (environmentId, eventClassId) => {
const { data, isLoading, error, mutate, isValidating } = useSWR(
`/api/v1/environments/${environmentId}/event-classes/${eventClassId}`,
fetcher
);
return {
eventClass: data,
isLoadingEventClass: isLoading,
isErrorEventClass: error,
isValidatingEventClass: isValidating,
mutateEventClass: mutate,
};
};
export const createEventClass = async (environmentId, eventClass: Event) => {
const response = await fetch(`/api/v1/environments/${environmentId}/event-classes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(eventClass),
});
if (!response.ok) {
if (response.status === 409) {
throw Error("Action with this name already exists");
}
throw Error(`Unable to create Action: ${response.statusText}`);
}
return response.json();
};
export const deleteEventClass = async (environmentId: string, eventClassId: string) => {
try {
const res = await fetch(`/api/v1/environments/${environmentId}/event-classes/${eventClassId}`, {
method: "DELETE",
});
if (!res.ok) {
throw Error(`deleteEventClass: unable to delete eventClass: ${res.statusText}`);
}
} catch (error) {
console.error(error);
throw Error(`deleteEventClass: unable to delete eventClass: ${error.message}`);
}
};

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