Compare commits
9 Commits
action-env
...
merteroglu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7616133a25 | ||
|
|
60139afd81 | ||
|
|
19e5865d05 | ||
|
|
6c5c27f571 | ||
|
|
c12fb1a9f8 | ||
|
|
526439def3 | ||
|
|
4e01ac211f | ||
|
|
f2f3ff6d46 | ||
|
|
b332cf12ca |
16
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=20
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# && apt-get -y install --no-install-recommends <your-package-list-here>
|
||||
|
||||
# [Optional] Uncomment if you want to install an additional version of node using nvm
|
||||
# ARG EXTRA_NODE_VERSION=10
|
||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||
|
||||
# [Optional] Uncomment if you want to install more global node modules
|
||||
# RUN su node -c "npm install -g <your-package-list-here>"
|
||||
|
||||
RUN su node -c "npm install -g pnpm"
|
||||
@@ -1,6 +1,28 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node-postgres
|
||||
// Update the VARIANT arg in docker-compose.yml to pick a Node.js version
|
||||
{
|
||||
"features": {},
|
||||
"image": "mcr.microsoft.com/devcontainers/universal:2",
|
||||
"postAttachCommand": "pnpm go",
|
||||
"postCreateCommand": "cp .env.example .env && sed -i '/^ENCRYPTION_KEY=/c\\ENCRYPTION_KEY='$(openssl rand -hex 32) .env && sed -i '/^NEXTAUTH_SECRET=/c\\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env && sed -i '/^CRON_SECRET=/c\\CRON_SECRET='$(openssl rand -hex 32) .env && pnpm install && pnpm db:migrate:dev"
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or with the host.
|
||||
"forwardPorts": [3000, 5432, 8025],
|
||||
|
||||
"name": "Node.js & PostgreSQL",
|
||||
"postAttachCommand": "pnpm dev --filter=@formbricks/web... --filter=@formbricks/demo...",
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cp .env.example .env && sed -i '/^ENCRYPTION_KEY=/c\\ENCRYPTION_KEY='$(openssl rand -hex 32) .env && sed -i '/^NEXTAUTH_SECRET=/c\\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env && sed -i '/^CRON_SECRET=/c\\CRON_SECRET='$(openssl rand -hex 32) .env && pnpm install && pnpm db:migrate:dev",
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace"
|
||||
}
|
||||
|
||||
51
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Update 'VARIANT' to pick an LTS version of Node.js: 20, 18, 16, 14.
|
||||
# Append -bullseye or -buster to pin to an OS version.
|
||||
# Use -bullseye variants on local arm64/Apple Silicon.
|
||||
VARIANT: "20"
|
||||
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
|
||||
network_mode: service:db
|
||||
# Uncomment the next line to use a non-root user for all processes.
|
||||
# user: node
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: formbricks
|
||||
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
mailhog:
|
||||
image: mailhog/mailhog
|
||||
network_mode: service:app
|
||||
logging:
|
||||
driver:
|
||||
"none" # disable saving logs
|
||||
# ports:
|
||||
# - 8025:8025 # web ui
|
||||
# 1025:1025 # smtp server
|
||||
|
||||
volumes:
|
||||
postgres-data: null
|
||||
7
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -10,13 +10,6 @@ body:
|
||||
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: issue-expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-information
|
||||
attributes:
|
||||
|
||||
6
.gitpod.Dockerfile
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM gitpod/workspace-full
|
||||
|
||||
# Install custom tools, runtime, etc.
|
||||
RUN brew install yq
|
||||
|
||||
RUN pnpm install turbo --global
|
||||
74
.gitpod.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
tasks:
|
||||
- name: demo
|
||||
init: |
|
||||
gp sync-await init-install &&
|
||||
bash .gitpod/setup-demo.bash
|
||||
command: |
|
||||
cd apps/demo &&
|
||||
cp .env.example .env &&
|
||||
sed -i -r "s#^(NEXT_PUBLIC_FORMBRICKS_API_HOST=).*#\1 $(gp url 3000)#" .env &&
|
||||
gp sync-await init &&
|
||||
turbo --filter "@formbricks/demo" go
|
||||
|
||||
- name: Init Formbricks
|
||||
init: |
|
||||
cp .env.example .env &&
|
||||
bash .gitpod/init.bash &&
|
||||
turbo --filter "@formbricks/js" build &&
|
||||
gp sync-done init-install
|
||||
command: |
|
||||
gp sync-done init &&
|
||||
gp tasks list &&
|
||||
gp ports await 3002 && gp ports await 3000 && gp open apps/demo/.env && gp preview $(gp url 3002) --external
|
||||
|
||||
- name: web
|
||||
init: |
|
||||
gp sync-await init-install &&
|
||||
bash .gitpod/setup-web.bash &&
|
||||
turbo --filter "@formbricks/database" db:down
|
||||
command: |
|
||||
gp sync-await init &&
|
||||
cp .env.example .env &&
|
||||
sed -i -r "s#^(WEBAPP_URL=).*#\1 $(gp url 3000)#" .env &&
|
||||
RANDOM_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
sed -i 's/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY='"$RANDOM_ENCRYPTION_KEY"'/' .env
|
||||
turbo --filter "@formbricks/web" go
|
||||
|
||||
image:
|
||||
file: .gitpod.Dockerfile
|
||||
|
||||
ports:
|
||||
- port: 3000
|
||||
visibility: public
|
||||
onOpen: open-browser
|
||||
- port: 3001
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 3002
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 5432
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 1025
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 8025
|
||||
visibility: public
|
||||
onOpen: open-browser
|
||||
|
||||
github:
|
||||
prebuilds:
|
||||
master: true
|
||||
pullRequests: true
|
||||
addComment: true
|
||||
|
||||
vscode:
|
||||
extensions:
|
||||
- "ban.spellright"
|
||||
- "bradlc.vscode-tailwindcss"
|
||||
- "DavidAnson.vscode-markdownlint"
|
||||
- "dbaeumer.vscode-eslint"
|
||||
- "esbenp.prettier-vscode"
|
||||
- "Prisma.prisma"
|
||||
- "yzhang.markdown-all-in-one"
|
||||
@@ -18,7 +18,7 @@ Ready to dive into the code and make a real impact? Here's your path:
|
||||
|
||||
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
|
||||
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://formbricks.com/docs/developer-docs/contributing/gitpod) or use [Codespaces](https://formbricks.com/docs/developer-docs/contributing/codespaces)
|
||||
|
||||
1. **Tweak and Transform:** Work your coding magic and apply your changes.
|
||||
|
||||
|
||||
@@ -10,11 +10,6 @@ export const metadata = {
|
||||
|
||||
# SDK: Formbricks API
|
||||
|
||||
<Note>
|
||||
The API SDK is currently in beta and APIs are subject to change. We will do our best to notify you of any
|
||||
changes.
|
||||
</Note>
|
||||
|
||||
### Overview
|
||||
|
||||
The Formbricks Client API Wrapper is a lightweight package designed to simplify the integration of Formbricks API endpoints into your JavaScript (JS) or TypeScript (TS) projects. With this wrapper, you can easily interact with Formbricks API endpoints without the need for complex setup or manual HTTP requests.
|
||||
|
||||
@@ -14,7 +14,11 @@ We are so happy that you are interested in contributing to Formbricks 🤗 There
|
||||
|
||||
- **Issues**: Spotted a bug? Has deployment gone wrong? Do you have user feedback? [Raise an issue](https://github.com/formbricks/formbricks/issues/new/choose) for the fastest response.
|
||||
- **Feature requests**: Raise an issue for these and tag it as an Enhancement. We love every idea. Please [open a feature request](https://github.com/formbricks/formbricks/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml&title=%5BFEATURE%5D) clearly describing the problem you want to solve.
|
||||
- **Creating a PR**: Please fork the repository, make your changes and create a new pull request if you want to make an update. Please talk to us first before starting development of more complex features. Small fixes are always welcome!
|
||||
- **How we Code at Formbricks**: [View this Notion document](https://formbricks.notion.site/How-we-code-at-Formbricks-8bcaa0304a20445db4871831149c0cf5?pvs=4) and understand the coding practises we follow so that you can adhere to them for uniformity.
|
||||
- **Creating a PR**: Please fork the repository, make your changes and create a new pull request if you want to make an update.
|
||||
- **E2E Tests**: [Understand how we write E2E tests](https://formbricks.notion.site/Formbricks-End-to-End-Tests-06dc830d71604deaa8da24714540f7ab?pvs=4) and make sure to consider writing a test whenever you ship a feature.
|
||||
- **New Question Types**:[Follow this guide](https://formbricks.notion.site/Guidelines-for-Implementing-a-New-Question-Type-9ac0d1c362714addb24b9abeb326d1c1?pvs=4) to keep everything in mind when you want to add a new question type.
|
||||
- **How to create a service**: [Read this document to understand how we use services](https://formbricks.notion.site/How-to-create-a-service-8e0c035704bb40cb9ea5e5beeeeabd67?pvs=4). This is particulalry important when you need to write a new one.
|
||||
|
||||
## Talk to us first
|
||||
|
||||
@@ -30,8 +34,8 @@ Once you open a PR, you will get a message from the CLA bot to fill out the form
|
||||
|
||||
We currently officially support the below methods to set up your development environment for Formbricks:
|
||||
|
||||
- [Gitpod](https://gitpod.io)
|
||||
- [GitHub Codespaces](https://github.com/features/codespaces)
|
||||
- [Gitpod](/developer-docs/contributing/gitpod)
|
||||
- [GitHub Codespaces](/developer-docs/contributing/codespaces)
|
||||
- [Local Machine Setup](#local-machine-setup)
|
||||
|
||||
Both Gitpod and GitHub Codespaces have a **generous free tier** to explore and develop. For junior developers we suggest using either of these, because you can dive into coding within minutes, not hours.
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 130 KiB |
172
apps/docs/app/developer-docs/contributing/gitpod/page.mdx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import GitpodAuth from "./images/auth.webp";
|
||||
import GitpodNewWorkspace from "./images/new-workspace.webp";
|
||||
import GitpodPorts from "./images/ports.webp";
|
||||
import GitpodPreparing from "./images/preparing.webp";
|
||||
import GitpodRunning from "./images/running.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Formbricks Open Source Contribution Guide: How to Enhance yourself and Contribute to Formbricks",
|
||||
description:
|
||||
"Join the Formbricks community and learn how to effectively contribute. From raising issues and feature requests to creating PRs, discover the best practices and communicate with our responsive team on Discord",
|
||||
};
|
||||
|
||||
#### Contributing
|
||||
|
||||
# Gitpod Guide
|
||||
|
||||
**Building custom image for the workspace:**
|
||||
- This includes : Installing `yq` and `turbo` globally before the workspace starts. This is accomplished within the `.gitpod.Dockerfile` along with starting upon a base custom image building on [workspace-full](https://hub.docker.com/r/workspace-full/dockerfile).
|
||||
|
||||
**Initialization of Formbricks:**
|
||||
- During the prebuilds phase, we initialize Formbricks by performing the following tasks:
|
||||
1. Setting up environment variables.
|
||||
2. Installing monorepo dependencies.
|
||||
3. Installing Docker images by extracting them from the `packages/database/docker-compose.yml` file.
|
||||
4. Building the @formbricks/js component.
|
||||
- When the workspace starts:
|
||||
1. Wait for the web and demo apps to launch on Gitpod. This automatically opens the `apps/demo/.env` file. Utilize dynamic localhost URLs (e.g., `localhost:3000` for signup and `localhost:8025` for email confirmation) to configure `NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID`. After creating your account and finding the `ID` in the URL at `localhost:3000`, replace `YOUR_ENVIRONMENT_ID` in the `.env` file located in `app/demo`.
|
||||
|
||||
**Web Component Initialization:**
|
||||
- We initialize the @formbricks/web component during prebuilds. This involves:
|
||||
1. Installing build dependencies for the `@formbricks/web#go` task from turbo.json in prebuilds to save time.
|
||||
2. Starting PostgreSQL and Mailhog containers for running migrations in prebuilds.
|
||||
3. To prevent the "Init" task from running indefinitely due to prebuild rules, a cleanup `docker compose down` step i.e. `db:down` is added to `turbo.json`. This step is designed to halt the execution of containers that are currently running.
|
||||
- When the workspace starts:
|
||||
1. Initializing environment variables.
|
||||
2. Replacing `NEXT_PUBLIC_WEBAPP_URL` to take in Gitpod URL's ports when running on VSCode browser.
|
||||
3. Starting the `@formbricks/web` dev environment.
|
||||
|
||||
**Demo Component Initialization:**
|
||||
- Similar to the web component, the demo component is also initialized during prebuilds. This includes:
|
||||
1. Installing build dependencies for the `formbricks/demo#go` task from turbo.json in prebuilds to save time.
|
||||
2. Caching hits and replaying builds from the `@formbricks/js` component.
|
||||
- When the workspace starts:
|
||||
1. Initializing environment variables.
|
||||
2. Replaces `NEXT_PUBLIC_FORMBRICKS_API_HOST` to take in Gitpod URL's ports when running on VSCode browser.
|
||||
3. Starting the `@formbricks/demo` dev environment.
|
||||
|
||||
**Github Prebuilds Configuration:**
|
||||
- This configures Github Prebuilds for the master branch, pull requests, and adding comments. This helps automate the prebuild process for the specified branches and actions.
|
||||
|
||||
**VSCode Extensions:**
|
||||
- This includes a list of VSCode extensions that are added to the configuration when using Gitpod. These extensions can enhance the development experience within Gitpod.
|
||||
|
||||
### 1. Browser Redirection
|
||||
|
||||
After clicking the one-click setup button, Gitpod will open a new tab or window. Please ensure that your browser allows redirection to successfully access the services:
|
||||
|
||||
### 2. Authorizing in Gitpod
|
||||
|
||||
<MdxImage
|
||||
src={GitpodAuth}
|
||||
alt="Gitpod Auth Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- This is the Gitpod Authentication Page. It appears when you click the "Open in GitPod" button and Gitpod
|
||||
needs to authenticate your access to the workspace. Click on 'Continue With Github' to authorize your GitPod
|
||||
session.
|
||||
|
||||
### 3. Creating a New Workspace
|
||||
|
||||
<MdxImage
|
||||
src={GitpodNewWorkspace}
|
||||
alt="Gitpod New workspace Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- After authentication, Gitpod asks to create a new workspace for you. This page displays the configurations
|
||||
of your workspace. - You can use either choose either VS Code Browser or VS Code Desktop editor with the
|
||||
'Standard Class' for your workspace class. - If you opt for the VS Code Desktop, follow the following steps 1.
|
||||
Gitpod will prompt you to grant access to the VSCode app. Once approved, install the GitPod extension from the
|
||||
VSCode Marketplace and follow the prompts to authorize the integration. 2. Change the `WEBAPP_URL` to
|
||||
`https://localhost:3000`
|
||||
|
||||
### 4. Gitpod preparing the created Workspace
|
||||
|
||||
<MdxImage
|
||||
src={GitpodPreparing}
|
||||
alt="Gitpod Preparing workspace Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- Gitpod is preparing your workspace with all the necessary dependencies and configurations. You will see this
|
||||
page while Gitpod sets up your development environment.
|
||||
|
||||
### 5. Gitpod running the Workspace
|
||||
|
||||
<MdxImage
|
||||
src={GitpodRunning}
|
||||
alt="Gitpod Running Workspace Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- Once the workspace is fully prepared, voila, it enters the running state. You can start working on your
|
||||
project in this environment.
|
||||
|
||||
### Ports and Services
|
||||
|
||||
Here are the ports and corresponding URLs for the services within your Gitpod environment:
|
||||
|
||||
- **Port 3000**:
|
||||
|
||||
- **Service**: Demo App
|
||||
- **Description**: This port hosts the demo application of your project. You can access and interact with your application's demo by navigating to this port.
|
||||
|
||||
- **Port 3001**:
|
||||
|
||||
- **Service**: Formbricks website
|
||||
- **Description**: This port hosts the [Formbricks](https://formbricks.com) website, which contains documents, pricing, blogs, best practices, and concierge service.
|
||||
|
||||
- **Port 3002**:
|
||||
|
||||
- **Service**: Formbricks In-product Survey Demo App
|
||||
- **Description**: This app helps you test your app & website surveys. You can create and test user actions, create and update user attributes, etc.
|
||||
|
||||
- **Port 5432**:
|
||||
|
||||
- **Service**: PostgreSQL Database Server
|
||||
- **Description**: The PostgreSQL DB is hosted on this port.
|
||||
|
||||
- **Port 1025**:
|
||||
|
||||
- **Service**: SMTP server
|
||||
- **Description**: SMTP Server for sending and receiving email messages. This server is responsible for handling email communication.
|
||||
|
||||
- **Port 8025**:
|
||||
- **Service**: Mailhog
|
||||
|
||||
### Accessing port URLs
|
||||
|
||||
1. **Direct URL Composition**:
|
||||
|
||||
- You can access the dedicated port URL by pre-pending the port number to the workspace URL.
|
||||
- For example, if you want to access port 3000, you can use the URL format: `3000-yourworkspace.ws-eu45.gitpod.io`.
|
||||
|
||||
2. **Using [gp CLI](https://www.gitpod.io/docs/references-cli)**:
|
||||
|
||||
- Gitpod provides a convenient command, `gp url`, to quickly retrieve the URL for a specific port.
|
||||
- Simply use the command followed by the desired port number. For example, to get the URL for port 3000, run: `gp url 3000`.
|
||||
|
||||
3. **Listing All Open Port URLs**:
|
||||
|
||||
- If you prefer to see a list of all open port URLs at once, you can use the `gp ports list` command.
|
||||
- Running this command will display a list of ports along with their corresponding URLs.
|
||||
|
||||
4. **Viewing All Ports in Panel**:
|
||||
|
||||
- Gitpod also offers a user-friendly 'Ports' tab in the Gitpod panel.
|
||||
- Click on the 'Ports' tab to view a list of all open ports and their respective URLs.
|
||||
|
||||
{" "}
|
||||
|
||||
<MdxImage
|
||||
src={GitpodPorts}
|
||||
alt="Gitpod Ports tab"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
These URLs and port numbers represent various services and endpoints within your Gitpod environment. You can access and interact with these services by the Port URL for the respective service.
|
||||
@@ -26,20 +26,15 @@ export const metadata = {
|
||||
|
||||
# n8n Setup
|
||||
|
||||
<Note>
|
||||
The Formbricks n8n node is currently only available in the n8n self-hosted version as a community node. To
|
||||
install it go to "Settings" -> "Community Nodes" and install @formbricks/n8n-nodes-formbricks.
|
||||
</Note>
|
||||
|
||||
n8n allows you to build flexible workflows focused on deep data integration. And with sharable templates and a user-friendly UI, the less technical people on your team can collaborate on them too. Unlike other tools, complexity is not a limitation. So you can build whatever you want — without stressing over budget. Hook up Formbricks with n8n and you can send your data to 350+ other apps. Here is how to do it.
|
||||
|
||||
## Step 1: Setup your survey incl. `questionId` for every question
|
||||
|
||||
<Note>
|
||||
Nailed down your survey? Any changes in the survey cause additional work in the n8n node. It makes sense to
|
||||
first settle on the survey you want to run and then get to setting up n8n.
|
||||
Nail down your survey? Any changes in the survey cause additional work in the n8n node. It makes
|
||||
sense to first settle on the survey you want to run and then get to setting up n8n.
|
||||
</Note>
|
||||
|
||||
## Step 1: Setup your survey incl. `questionId` for every question
|
||||
|
||||
When setting up the node your life will be easier when you change the `questionId`s of your survey questions. You can only do so **before** you publish your survey.
|
||||
|
||||
<MdxImage
|
||||
|
||||
@@ -10,11 +10,6 @@ export const metadata = {
|
||||
|
||||
# React Native: In App Surveys
|
||||
|
||||
<Note>
|
||||
The React Native SDK is currently in beta and APIs are subject to change. We will do our best to notify you
|
||||
of any changes.
|
||||
</Note>
|
||||
|
||||
### Overview
|
||||
|
||||
The Formbricks React Native SDK can be used for seamlessly integrating App Surveys into your React Native Apps. Here, w'll explore how to leverage the SDK for in app surveys. The SDK is [available on npm.](https://www.npmjs.com/package/@formbricks/react-native)
|
||||
|
||||
@@ -13,11 +13,6 @@ export const metadata = {
|
||||
|
||||
# API Overview
|
||||
|
||||
<Note>
|
||||
The Formbricks API is currently in beta and is subject to change. We will do our best to notify you of any
|
||||
changes.
|
||||
</Note>
|
||||
|
||||
Formbricks offers two types of APIs: the **Public Client API** and the **Management API**. Each API serves a different purpose, has different authentication requirements, and provides access to different data and settings.
|
||||
|
||||
View our [API Documentation](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh) in more than 30 frameworks and languages.
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
@@ -6,7 +6,7 @@ import IndvInvite from "./images/individual-invite.webp";
|
||||
import MenuItem from "./images/organization-settings-menu.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "User Management",
|
||||
title: "Organization Access Roles",
|
||||
description:
|
||||
"Assign different roles to organization members to grant them specific rights like creating surveys, viewing responses, or managing organization members.",
|
||||
};
|
||||
@@ -134,7 +134,7 @@ There are two ways to invite organization members: One by one or in bulk.
|
||||
|
||||
<Note>
|
||||
Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free**
|
||||
and **Startup** plan in the Cloud you can invite unlimited organization members as `Owners`.
|
||||
and **Startup** plan in the Cloud you can invite unlimited organization members as `Admins`.
|
||||
</Note>
|
||||
|
||||
Formbricks sends an email to the organization member with an invitation link. The organization member can accept the invitation or create a new account by clicking on the link.
|
||||
|
||||
BIN
apps/docs/app/global/recall/images/StepEight.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/docs/app/global/recall/images/StepFive.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
apps/docs/app/global/recall/images/StepFour.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
apps/docs/app/global/recall/images/StepSeven.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/docs/app/global/recall/images/StepSix.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
@@ -1,75 +1,131 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
|
||||
import StepEight from "./images/StepEight.webp";
|
||||
import StepFive from "./images/StepFive.webp";
|
||||
import StepFour from "./images/StepFour.webp";
|
||||
import StepOne from "./images/StepOne.webp";
|
||||
import StepSeven from "./images/StepSeven.webp";
|
||||
import StepSix from "./images/StepSix.webp";
|
||||
import StepThree from "./images/StepThree.webp";
|
||||
import StepTwo from "./images/StepTwo.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Recall Data in Formbricks Surveys",
|
||||
title: "Recall Functionality for Formbricks Surveys",
|
||||
description:
|
||||
"Personalize your surveys by dynamically inserting data from URL parameters or previous answers into questions and descriptions. The Recall Data feature helps create engaging, adaptive survey experiences tailored to each respondent."};
|
||||
"Enhance your surveys with the Recall Functionality to create more engaging and personalized experiences. This feature allows users to dynamically include responses from previous answers in subsequent questions or descriptions, adapting the survey content based on individual responses.",
|
||||
};
|
||||
|
||||
# Recall Data
|
||||
# Recall Functionality
|
||||
|
||||
Personalize your surveys by dynamically inserting data from URL parameters or previous answers into questions and descriptions. The Recall Data feature helps create engaging, adaptive survey experiences tailored to each respondent.
|
||||
Enhance your surveys with the Recall Functionality to create more engaging and personalized experiences. This feature allows users to dynamically include responses from previous answers in subsequent questions or descriptions, adapting the survey content based on individual responses.
|
||||
|
||||
## Recall Sources
|
||||
You can recall data from the following sources:
|
||||
|
||||
- The response of a previous question
|
||||
- The URL using a [Hidden Field](/docs/global/hidden-fields)
|
||||
|
||||
## Recalling from a previous question
|
||||
### **How to Insert Recall References**
|
||||
|
||||
<Note>
|
||||
The recall functionality is disabled on the first question of the survey since there’s no preceding question
|
||||
to recall data from.
|
||||
to recall
|
||||
</Note>
|
||||
|
||||
### **Pre-requisite**
|
||||
|
||||
Ensure the answer you wish to recall precedes the question in which it will be recalled. Here’s an example of setting up the first question:
|
||||
|
||||
<MdxImage
|
||||
src={StepThree}
|
||||
alt="Survey setup example with link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
### **Step 1: Recall Data**
|
||||
|
||||
Type **`@`** in the question or description field where you want to insert a recall. This triggers a dropdown menu listing all preceding questions. Select the question you want to recall data from.
|
||||
|
||||
<MdxImage
|
||||
src={StepTwo}
|
||||
alt="Dropdown menu for recalling data in survey"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
### **Step 2: Set a Fallback**
|
||||
|
||||
To ensure the survey remains coherent when a response is missing (or the question is optional), you should set a fallback option.
|
||||
**Pre-requisite:** Ensure the answer you wish to recall precedes the question in which it will be recalled. Here’s an example of setting up the first question:
|
||||
|
||||
<MdxImage
|
||||
src={StepOne}
|
||||
alt="Setting fallback option in survey question"
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
## Recalling from the URL
|
||||
1. Create a hidden field, [here is how →](/docs/global/hidden-fields)
|
||||
2. Use the `@` symbol in a question or description to recall the value of the hidden field
|
||||
3. Set a fallback in case the hidden field is not being filled by a URL parameter
|
||||
4. Use [Data Prefilling](/docs/link-surveys/data-prefilling) to set the hidden field value when the survey is accessed
|
||||
### **Steps to Initiate Recall**
|
||||
|
||||
1. **Initiate Recall**: In the survey editor, type **`@`** in the question or description field where you want to insert a recall. This triggers a dropdown menu listing all preceding questions.
|
||||
|
||||
<MdxImage
|
||||
src={StepTwo}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
## Live Demo
|
||||
2. **Select a Question**: Navigate through the dropdown to choose your question. The first question is automatically focused, making it easy to select by pressing **`ENTER`**. Once selected, the question becomes a linked placeholder within the text field.
|
||||
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/cm393eiiq0001kxphzc6lbbku" />
|
||||
### **Configuring Fallback Options**
|
||||
|
||||
To ensure the survey remains coherent when a response is missing (or the question is optional), you can set a fallback option.
|
||||
|
||||
<MdxImage
|
||||
src={StepThree}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
1. **Access Fallback Settings**: Click the linked placeholder to open the configuration pop-up for fallback settings.
|
||||
2. **Set Fallback Text**: Input the fallback text that should appear if the recalled question lacks a response. This text ensures smooth survey progression.
|
||||
|
||||
### **Example of Recall Usage**
|
||||
|
||||
For example, you might structure a survey about favorite fruits as follows:
|
||||
|
||||
- **Question 1**: "What is your favorite fruit?"
|
||||
- **Question 2**: "Why do you like `@What is your favorite fruit?` so much?"
|
||||
|
||||
If "Question 1" is unanswered, you can set a fallback like "the fruit" to make "Question 2" read: "Why do you like the fruit so much?"
|
||||
|
||||
### **User Experience**
|
||||
|
||||
If a respondent answers “Mango” to the first question, the recall functionality automatically adjusts the following question to "Why is Mango your favorite?”
|
||||
|
||||
<MdxImage
|
||||
src={StepFour}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
As you can see, the questions that use this recall automatically use the response value:
|
||||
|
||||
<MdxImage
|
||||
src={StepFive}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
<MdxImage
|
||||
src={StepSix}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
### **Visual Indicators in UI**
|
||||
|
||||
When setting up your questions, the UI will visually indicate where recalls are used:
|
||||
|
||||
- **Recall Indicators**: Linked questions will be highlighted with a light grey background in the survey editor, making it clear where dynamic content is being utilized.
|
||||
- **Fallback Indicators**: Any fallbacks set will also be displayed in a subtle yet distinct manner, ensuring you are aware of all conditional text paths in your survey.
|
||||
|
||||
### Response Summary Page
|
||||
|
||||
On the Formbricks dashboard, summary responses and individual response cards reflect recalled information, ensuring data coherence and enhancing analysis.
|
||||
|
||||
- Summary Tab
|
||||
|
||||
<MdxImage
|
||||
src={StepSeven}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
- Response Card:
|
||||
|
||||
<MdxImage
|
||||
src={StepEight}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
## **Conclusion**
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import "@/styles/tailwind.css";
|
||||
import glob from "fast-glob";
|
||||
import { type Metadata } from "next";
|
||||
import { Jost } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -28,18 +27,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full" suppressHydrationWarning>
|
||||
<head>
|
||||
{process.env.NEXT_PUBLIC_LAYER_API_KEY && (
|
||||
<Script
|
||||
strategy="afterInteractive"
|
||||
src="https://storage.googleapis.com/generic-assets/buildwithlayer-widget-4.js"
|
||||
primary-color="#00C4B8"
|
||||
api-key={process.env.NEXT_PUBLIC_LAYER_API_KEY}
|
||||
walkthrough-enabled="false"
|
||||
design-style="copilot"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`flex min-h-full bg-white antialiased dark:bg-zinc-900 ${jost.className}`}>
|
||||
<Providers>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -8,123 +8,6 @@ export const metadata = {
|
||||
|
||||
# Migration Guide
|
||||
|
||||
## v2.7
|
||||
|
||||
<Note>
|
||||
This release sets the foundation for our upcoming AI features, currently in private beta. Formbricks now
|
||||
requires the `pgvector` extension to be installed in the PostgreSQL database. For users of our one-click
|
||||
setup, simply use the `pgvector/pgvector:pg15` image instead of `postgres:15-alpine`.
|
||||
</Note>
|
||||
|
||||
Formbricks v2.7 includes all the features and improvements developed by the community during hacktoberfest 2024. Additionally we introduce an advanced team-based access control system (requires Formbricks Enterprise Edition).
|
||||
|
||||
### Additional Updates
|
||||
|
||||
If you previously used organization-based access control (enterprise feature) as well as the `DEFAULT_ORGANIZATION_ROLE` environment variable, make sure to update the value to one of the following roles: `owner`, `manager`, `member`. Read more about the new roles in the [Docs](/global/access-roles).
|
||||
|
||||
### Steps to Migrate
|
||||
|
||||
This guide is for users who are self-hosting Formbricks using our one-click setup. If you are using a different setup, you might adjust the commands accordingly.
|
||||
|
||||
To run all these steps, please navigate to the `formbricks` folder where your `docker-compose.yml` file is located.
|
||||
|
||||
1. **Backup your Database**: This is a crucial step. Please make sure to backup your database before proceeding with the upgrade. You can use the following command to backup your database:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Backup Postgres">
|
||||
|
||||
```bash
|
||||
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.7_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
<Note>
|
||||
If you run into “No such container”, use `docker ps` to find your container name, e.g.
|
||||
`formbricks_postgres_1`.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
If you prefer storing the backup as an `*.sql` file remove the `-Fc` (custom format) option. In case of a
|
||||
restore scenario you will need to use `psql` then with an empty `formbricks` database.
|
||||
</Note>
|
||||
|
||||
2. If you use an older `docker-compose.yml` file from the one-click setup, modify it to use the `pgvector/pgvector:pg15` image instead of `postgres:15-alpine`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
```
|
||||
|
||||
3. Pull the latest version of Formbricks:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the containers">
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
4. Stop the running Formbricks instance & remove the related containers:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the containers">
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
5. Restarting the containers with the latest version of Formbricks:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Restart the containers">
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
6. Now let's migrate the data to the latest schema:
|
||||
|
||||
<Note>To find your Docker Network name for your Postgres Database, find it using `docker network ls`</Note>
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Migrate the data">
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/formbricks/data-migrations:latest && \
|
||||
docker run --rm \
|
||||
--network=formbricks_default \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
|
||||
-e UPGRADE_TO_VERSION="v2.7" \
|
||||
ghcr.io/formbricks/data-migrations:v2.7.0
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
The above command will migrate your data to the latest schema. This is a crucial step to migrate your existing data to the new structure. Only if the script runs successful, changes are made to the database. The script can safely run multiple times.
|
||||
|
||||
7. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
|
||||
|
||||
## v2.6
|
||||
|
||||
Formbricks v2.6 introduces advanced logic jumps for surveys, allowing you to add more advanced branching logic to your surveys including variables, and/or conditions and many more. This release also includes a lot of bug fixes, big performance improvements to website and app surveys and a lot of stability improvements.
|
||||
|
||||
@@ -126,7 +126,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
|
||||
],
|
||||
},
|
||||
{ title: "User Management", href: "/global/access-roles" },
|
||||
{ title: "Organization and User Management", href: "/global/access-roles" },
|
||||
{ title: "Styling Theme", href: "/global/styling-theme" },
|
||||
],
|
||||
},
|
||||
@@ -156,6 +156,8 @@ export const navigation: Array<NavGroup> = [
|
||||
title: "Contributing",
|
||||
children: [
|
||||
{ title: "Get started", href: "/developer-docs/contributing/get-started" },
|
||||
{ title: "Codespaces", href: "/developer-docs/contributing/codespaces" },
|
||||
{ title: "Gitpod", href: "/developer-docs/contributing/gitpod" },
|
||||
{ title: "Troubleshooting", href: "/developer-docs/contributing/troubleshooting" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -19,11 +19,7 @@ interface InviteOrganizationMemberProps {
|
||||
|
||||
const ZInviteOrganizationMemberDetails = z.object({
|
||||
email: z.string().email(),
|
||||
inviteMessage: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => !/https?:\/\/|<script/i.test(value), "Invite message cannot contain URLs or scripts"),
|
||||
inviteMessage: z.string().trim().min(1),
|
||||
});
|
||||
type TInviteOrganizationMemberDetails = z.infer<typeof ZInviteOrganizationMemberDetails>;
|
||||
|
||||
|
||||
@@ -142,30 +142,6 @@ export const EditorCardMenu = ({
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<ArrowUpIcon
|
||||
className={cn(
|
||||
"h-4 cursor-pointer text-slate-500",
|
||||
cardIdx === 0 ? "cursor-not-allowed opacity-50" : "hover:text-slate-600"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (cardIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ArrowDownIcon
|
||||
className={cn(
|
||||
"h-4 cursor-pointer text-slate-500",
|
||||
lastCard ? "cursor-not-allowed opacity-50" : "hover:text-slate-600"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!lastCard) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CopyIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ const Page = async ({ params, searchParams }) => {
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
getActionClasses(params.environmentId),
|
||||
getAttributeClasses(params.environmentId, undefined, { skipArchived: true }),
|
||||
getAttributeClasses(params.environmentId),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
|
||||
@@ -20,17 +20,13 @@ export const getSegmentsByAttributeClassAction = authenticatedActionClient
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "read",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const segments = await getSegmentsByAttributeClassName(
|
||||
parsedInput.environmentId,
|
||||
parsedInput.attributeClass.name
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { Switch } from "@formbricks/ui/components/Switch";
|
||||
import { AttributeDetailModal } from "./AttributeDetailModal";
|
||||
import { AttributeClassDataRow } from "./AttributeRowData";
|
||||
@@ -50,15 +49,8 @@ export const AttributeClassesTable = ({
|
||||
{hasArchived && (
|
||||
<div className="my-4 flex items-center justify-end text-right">
|
||||
<div className="flex items-center text-sm font-medium">
|
||||
<Label htmlFor="showArchivedToggle" className="cursor-pointer">
|
||||
{t("environments.attributes.show_archived")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="showArchivedToggle"
|
||||
className="mx-3"
|
||||
checked={showArchived}
|
||||
onCheckedChange={toggleShowArchived}
|
||||
/>
|
||||
{t("environments.attributes.show_archived")}
|
||||
<Switch className="mx-3" checked={showArchived} onCheckedChange={toggleShowArchived} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -192,9 +192,9 @@ export const PersonTable = ({
|
||||
/>
|
||||
<div className="w-full overflow-x-auto rounded-xl border border-slate-200">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="pointer-events-auto">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
@@ -204,7 +204,7 @@ export const PersonTable = ({
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableRow>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const Page = async ({ params }) => {
|
||||
const [environment, segments, attributeClasses, organization, product] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getSegments(params.environmentId),
|
||||
getAttributeClasses(params.environmentId, undefined, { skipArchived: true }),
|
||||
getAttributeClasses(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { createActionClassAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useEffect, useState } from "react";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { LoadingSpinner } from "@formbricks/ui/components/LoadingSpinner";
|
||||
@@ -19,25 +15,15 @@ import { getActiveInactiveSurveysAction } from "../actions";
|
||||
interface ActivityTabProps {
|
||||
actionClass: TActionClass;
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
otherEnvironment: TEnvironment;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ActionActivityTab = ({
|
||||
actionClass,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
environmentId,
|
||||
environment,
|
||||
isReadOnly,
|
||||
}: ActivityTabProps) => {
|
||||
export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabProps) => {
|
||||
const t = useTranslations();
|
||||
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);
|
||||
|
||||
@@ -46,6 +32,7 @@ export const ActionActivityTab = ({
|
||||
const getActiveInactiveSurveysResponse = await getActiveInactiveSurveysAction({
|
||||
actionClassId: actionClass.id,
|
||||
});
|
||||
console.log(getActiveInactiveSurveysResponse, "randike");
|
||||
if (getActiveInactiveSurveysResponse?.data) {
|
||||
setActiveSurveys(getActiveInactiveSurveysResponse.data.activeSurveys);
|
||||
setInactiveSurveys(getActiveInactiveSurveysResponse.data.inactiveSurveys);
|
||||
@@ -59,57 +46,6 @@ export const ActionActivityTab = ({
|
||||
updateState();
|
||||
}, [actionClass.id, environmentId]);
|
||||
|
||||
const actionClassNames = useMemo(
|
||||
() => otherEnvActionClasses.map((actionClass) => actionClass.name),
|
||||
[otherEnvActionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useMemo(() => {
|
||||
const codeActionClasses: TActionClassInputCode[] = otherEnvActionClasses.filter(
|
||||
(actionClass) => actionClass.type === "code"
|
||||
) as TActionClassInputCode[];
|
||||
|
||||
return codeActionClasses.map((actionClass) => actionClass.key);
|
||||
}, [otherEnvActionClasses]);
|
||||
|
||||
const copyAction = async (data: TActionClassInput) => {
|
||||
const { type } = data;
|
||||
let copyName = data.name;
|
||||
try {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
|
||||
if (copyName && actionClassNames.includes(copyName)) {
|
||||
while (actionClassNames.includes(copyName)) {
|
||||
copyName += " (copy)";
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "code" && data.key && actionClassKeys.includes(data.key)) {
|
||||
throw new Error(t("environments.actions.action_with_key_already_exists", { key: data.key }));
|
||||
}
|
||||
|
||||
let updatedAction = {
|
||||
...data,
|
||||
name: copyName.trim(),
|
||||
environmentId: otherEnvironment.id,
|
||||
};
|
||||
|
||||
const createActionClassResponse = await createActionClassAction({
|
||||
action: updatedAction as TActionClassInput,
|
||||
});
|
||||
|
||||
if (!createActionClassResponse?.data) {
|
||||
throw new Error(t("environments.actions.action_copy_failed", {}));
|
||||
}
|
||||
|
||||
toast.success(t("environments.actions.action_copied_successfully"));
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <ErrorComponent />;
|
||||
|
||||
@@ -163,22 +99,6 @@ export const ActionActivityTab = ({
|
||||
<p className="text-sm text-slate-700">{capitalizeFirstLetter(actionClass.type)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<Label className="text-xs font-normal text-slate-500">Environment</Label>
|
||||
<div className="items-center-center flex gap-2">
|
||||
<p className="text-xs text-slate-700">
|
||||
{environment.type === "development" ? "Development" : "Production"}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
copyAction(actionClass);
|
||||
}}
|
||||
className="m-0 p-0 text-xs font-medium text-black underline underline-offset-4 focus:ring-0 focus:ring-offset-0"
|
||||
variant="minimal">
|
||||
{environment.type === "development" ? "Copy to Production" : "Copy to Development"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,27 +2,20 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionDetailModal } from "./ActionDetailModal";
|
||||
|
||||
interface ActionClassesTableProps {
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
environment: TEnvironment;
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
isReadOnly: boolean;
|
||||
otherEnvironment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export const ActionClassesTable = ({
|
||||
environmentId,
|
||||
actionClasses,
|
||||
environment,
|
||||
children: [TableHeading, actionRows],
|
||||
isReadOnly,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionClassesTableProps) => {
|
||||
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||
|
||||
@@ -55,14 +48,11 @@ export const ActionClassesTable = ({
|
||||
{activeActionClass && (
|
||||
<ActionDetailModal
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
open={isActionDetailModalOpen}
|
||||
setOpen={setActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
isReadOnly={isReadOnly}
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
otherEnvironment={otherEnvironment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
interface ActionDetailModalProps {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
isReadOnly: boolean;
|
||||
otherEnvironment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export const ActionDetailModal = ({
|
||||
@@ -24,25 +20,13 @@ export const ActionDetailModal = ({
|
||||
setOpen,
|
||||
actionClass,
|
||||
actionClasses,
|
||||
environment,
|
||||
isReadOnly,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionDetailModalProps) => {
|
||||
const t = useTranslations();
|
||||
const tabs = [
|
||||
{
|
||||
title: t("common.activity"),
|
||||
children: (
|
||||
<ActionActivityTab
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isReadOnly={isReadOnly}
|
||||
environment={environment}
|
||||
actionClass={actionClass}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
),
|
||||
children: <ActionActivityTab actionClass={actionClass} environmentId={environmentId} />,
|
||||
},
|
||||
{
|
||||
title: t("common.settings"),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
@@ -45,17 +44,6 @@ const Page = async ({ params }) => {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const currentEnvironment = environments.find((env) => env.id === params.environmentId);
|
||||
|
||||
if (!currentEnvironment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
|
||||
|
||||
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
@@ -81,9 +69,6 @@ const Page = async ({ params }) => {
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
||||
<ActionClassesTable
|
||||
environment={currentEnvironment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}>
|
||||
|
||||
@@ -59,13 +59,15 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, environment.productId);
|
||||
|
||||
if (isMember && !productPermission) {
|
||||
if (!isOwnerOrManager && !productPermission) {
|
||||
throw new Error(t("common.product_permission_not_found"));
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ export const MainNavigation = ({
|
||||
{
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
hidden: !isFormbricksCloud || isPricingDisabled,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -77,12 +77,11 @@ export const ManageIntegration = ({
|
||||
{showReconnectButton && (
|
||||
<div className="mb-4 flex w-full items-center justify-between space-x-4">
|
||||
<p className="text-amber-700">
|
||||
{t.rich("environments.integrations.slack.slack_reconnect_button_description", {
|
||||
b: (chunks) => <b>{chunks}</b>,
|
||||
})}
|
||||
<strong>Note:</strong> We recently changed our Slack integration to also support private channels.
|
||||
Please reconnect your Slack workspace.
|
||||
</p>
|
||||
<Button onClick={handleSlackAuthorization} variant="secondary">
|
||||
{t("environments.integrations.slack.slack_reconnect_button")}
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -43,11 +43,7 @@ export const SlackWrapper = ({
|
||||
|
||||
const getSlackChannels = async () => {
|
||||
const getSlackChannelsResponse = await getSlackChannelsAction({ environmentId: environment.id });
|
||||
|
||||
if (
|
||||
getSlackChannelsResponse?.serverError &&
|
||||
getSlackChannelsResponse.serverError.includes("missing_scope")
|
||||
) {
|
||||
if (getSlackChannelsResponse?.serverError && getSlackChannelsResponse?.serverError === "missing_scope") {
|
||||
setShowReconnectButton(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
||||
<PageHeader pageTitle={"environments.integrations.slack.slack_integration"} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<SlackWrapper
|
||||
isEnabled={isEnabled}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud || loading,
|
||||
hidden: !isFormbricksCloud || isPricingDisabled || loading,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { Alert, AlertDescription } from "@formbricks/ui/components/Alert";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
@@ -21,7 +18,6 @@ interface IndividualInviteTabProps {
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const IndividualInviteTab = ({
|
||||
setOpen,
|
||||
onSubmit,
|
||||
@@ -29,13 +25,6 @@ export const IndividualInviteTab = ({
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
}: IndividualInviteTabProps) => {
|
||||
const ZFormSchema = z.object({
|
||||
name: ZUserName,
|
||||
email: z.string().email("Invalid email address"),
|
||||
role: ZOrganizationRole,
|
||||
});
|
||||
|
||||
type TFormData = z.infer<typeof ZFormSchema>;
|
||||
const t = useTranslations();
|
||||
const {
|
||||
register,
|
||||
@@ -44,13 +33,12 @@ export const IndividualInviteTab = ({
|
||||
reset,
|
||||
control,
|
||||
watch,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<TFormData>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
formState: { isSubmitting },
|
||||
} = useForm<{
|
||||
name: string;
|
||||
email: string;
|
||||
role: TOrganizationRole;
|
||||
}>();
|
||||
|
||||
const submitEventClass = async () => {
|
||||
const data = getValues();
|
||||
@@ -67,10 +55,9 @@ export const IndividualInviteTab = ({
|
||||
<Label htmlFor="memberNameInput">{t("common.full_name")}</Label>
|
||||
<Input
|
||||
id="memberNameInput"
|
||||
placeholder="Hans Wurst"
|
||||
placeholder="e.g. Hans Wurst"
|
||||
{...register("name", { required: true, validate: (value) => value.trim() !== "" })}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="memberEmailInput">{t("common.email")}</Label>
|
||||
|
||||
@@ -196,9 +196,9 @@ export const ResponseTable = ({
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }} id="response-table">
|
||||
<TableHeader className="pointer-events-auto">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
@@ -208,7 +208,7 @@ export const ResponseTable = ({
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableRow>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import {
|
||||
BellRing,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
MoreVertical,
|
||||
SquarePenIcon,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { BellRing, Code2Icon, Eye, LinkIcon, MoreVertical, SquarePenIcon, UsersRound } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -143,8 +135,12 @@ export const SurveyAnalysisCTA = ({
|
||||
)}
|
||||
|
||||
{survey.type === "link" && (
|
||||
<Button variant="secondary" size="sm" onClick={handleCopyLink} EndIcon={CopyIcon}>
|
||||
{t("common.copy_link")}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(getPreviewUrl(), "_blank")}
|
||||
EndIcon={Eye}>
|
||||
{t("common.preview")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -172,11 +168,9 @@ export const SurveyAnalysisCTA = ({
|
||||
<DropdownMenuGroup>
|
||||
{survey.type === "link" && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
onClick={() => window.open(getPreviewUrl(), "_blank")}
|
||||
className="flex w-full items-center">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.preview")}
|
||||
<button onClick={handleCopyLink} className="flex w-full items-center">
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy_link")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const EmbedView = ({
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
{!disableBack && (
|
||||
<div className="border-b border-slate-200 py-2 pl-2">
|
||||
<div className="border-b border-slate-200 py-2">
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="focus:ring-0"
|
||||
@@ -63,7 +63,6 @@ export const EmbedView = ({
|
||||
variant="minimal"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
autoFocus={tab.id === activeId}
|
||||
className={cn(
|
||||
"rounded-md border px-4 py-2 text-slate-600",
|
||||
// "focus:ring-0 focus:ring-offset-0", // enable these classes to remove the focus rings on buttons
|
||||
|
||||
@@ -6,22 +6,8 @@ import {
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import {
|
||||
differenceInDays,
|
||||
endOfMonth,
|
||||
endOfQuarter,
|
||||
endOfYear,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfQuarter,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subMonths,
|
||||
subQuarters,
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { differenceInDays, format, startOfDay, subDays } from "date-fns";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -52,13 +38,6 @@ enum FilterDropDownLabels {
|
||||
ALL_TIME = "environments.surveys.summary.all_time",
|
||||
LAST_7_DAYS = "environments.surveys.summary.last_7_days",
|
||||
LAST_30_DAYS = "environments.surveys.summary.last_30_days",
|
||||
THIS_MONTH = "environments.surveys.summary.this_month",
|
||||
LAST_MONTH = "environments.surveys.summary.last_month",
|
||||
LAST_6_MONTHS = "environments.surveys.summary.last_6_months",
|
||||
THIS_QUARTER = "environments.surveys.summary.this_quarter",
|
||||
LAST_QUARTER = "environments.surveys.summary.last_quarter",
|
||||
THIS_YEAR = "environments.surveys.summary.this_year",
|
||||
LAST_YEAR = "environments.surveys.summary.last_year",
|
||||
CUSTOM_RANGE = "environments.surveys.summary.custom_range",
|
||||
}
|
||||
|
||||
@@ -66,62 +45,15 @@ interface CustomFilterProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
const getDateRangeLabel = (from: Date, to: Date): FilterDropDownLabels => {
|
||||
const dateRanges = [
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_7_DAYS,
|
||||
matches: () => differenceInDays(to, from) === 7,
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_30_DAYS,
|
||||
matches: () => differenceInDays(to, from) === 30,
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.THIS_MONTH,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfMonth(new Date()), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(getTodayDate(), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_MONTH,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfMonth(subMonths(new Date(), 1)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfMonth(subMonths(getTodayDate(), 1)), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_6_MONTHS,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfMonth(subMonths(new Date(), 6)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfMonth(getTodayDate()), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.THIS_QUARTER,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfQuarter(new Date()), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfQuarter(getTodayDate()), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_QUARTER,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfQuarter(subQuarters(new Date(), 1)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfQuarter(subQuarters(getTodayDate(), 1)), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.THIS_YEAR,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfYear(new Date()), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfYear(getTodayDate()), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_YEAR,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfYear(subYears(new Date(), 1)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfYear(subYears(getTodayDate(), 1)), "yyyy-MM-dd"),
|
||||
},
|
||||
];
|
||||
|
||||
const matchedRange = dateRanges.find((range) => range.matches());
|
||||
return matchedRange ? matchedRange.label : FilterDropDownLabels.CUSTOM_RANGE;
|
||||
const getDifferenceOfDays = (from, to) => {
|
||||
const days = differenceInDays(to, from);
|
||||
if (days === 7) {
|
||||
return FilterDropDownLabels.LAST_7_DAYS;
|
||||
} else if (days === 30) {
|
||||
return FilterDropDownLabels.LAST_30_DAYS;
|
||||
} else {
|
||||
return FilterDropDownLabels.CUSTOM_RANGE;
|
||||
}
|
||||
};
|
||||
|
||||
export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
@@ -131,7 +63,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
|
||||
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
|
||||
dateRange.from && dateRange.to
|
||||
? getDateRangeLabel(dateRange.from, dateRange.to)
|
||||
? getDifferenceOfDays(dateRange.from, dateRange.to)
|
||||
: FilterDropDownLabels.ALL_TIME
|
||||
);
|
||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||
@@ -312,67 +244,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_30_DAYS)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.THIS_MONTH)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_MONTH)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.THIS_QUARTER)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_QUARTER)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_6_MONTHS)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.THIS_YEAR)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_YEAR)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
|
||||
@@ -2,19 +2,16 @@
|
||||
|
||||
import { TwoFactor } from "@/app/(auth)/auth/login/components/TwoFactor";
|
||||
import { TwoFactorBackup } from "@/app/(auth)/auth/login/components/TwoFactorBackup";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Controller, FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { PasswordInput } from "@formbricks/ui/components/PasswordInput";
|
||||
import { AzureButton } from "@formbricks/ui/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@formbricks/ui/components/SignupOptions/components/GithubButton";
|
||||
@@ -56,23 +53,6 @@ export const SigninForm = ({
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const formMethods = useForm<TSigninFormState>();
|
||||
const callbackUrl = searchParams?.get("callbackUrl");
|
||||
const ZSignInInput = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
totpCode: z.string().optional(),
|
||||
backupCode: z.string().optional(),
|
||||
});
|
||||
|
||||
type TSignInInput = z.infer<typeof ZSignInInput>;
|
||||
const form = useForm<TSignInInput>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
totpCode: "",
|
||||
backupCode: "",
|
||||
},
|
||||
resolver: zodResolver(ZSignInInput),
|
||||
});
|
||||
const t = useTranslations();
|
||||
const onSubmit: SubmitHandler<TSigninFormState> = async (data) => {
|
||||
setLoggingIn(true);
|
||||
@@ -157,11 +137,11 @@ export const SigninForm = ({
|
||||
|
||||
const TwoFactorComponent = useMemo(() => {
|
||||
if (totpBackup) {
|
||||
return <TwoFactorBackup form={form} />;
|
||||
return <TwoFactorBackup />;
|
||||
}
|
||||
|
||||
if (totpLogin) {
|
||||
return <TwoFactor form={form} />;
|
||||
return <TwoFactor />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -173,58 +153,53 @@ export const SigninForm = ({
|
||||
<h1 className="mb-4 text-slate-700">{formLabel}</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<form onSubmit={formMethods.handleSubmit(onSubmit)} className="space-y-2">
|
||||
{TwoFactorComponent}
|
||||
|
||||
{showLogin && (
|
||||
<div className={cn(totpLogin && "hidden", "space-y-2")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={field.value}
|
||||
onChange={(email) => field.onChange(email)}
|
||||
placeholder="work@email.com"
|
||||
defaultValue={searchParams?.get("email") || ""}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
onFocus={() => setIsPasswordFocused(true)}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className={cn(totpLogin && "hidden")}>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t("common.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="work@email.com"
|
||||
defaultValue={searchParams?.get("email") || ""}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...formMethods.register("email", {
|
||||
required: true,
|
||||
pattern: /\S+@\S+\.\S+/,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<Controller
|
||||
name="password"
|
||||
control={formMethods.control}
|
||||
render={({ field }) => (
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
onFocus={() => setIsPasswordFocused(true)}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{passwordResetEnabled && isPasswordFocused && (
|
||||
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
|
||||
<Link
|
||||
|
||||
@@ -2,24 +2,11 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { OTPInput } from "@formbricks/ui/components/OTPInput";
|
||||
|
||||
interface TwoFactorProps {
|
||||
form: UseFormReturn<
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export const TwoFactor = ({ form }: TwoFactorProps) => {
|
||||
export const TwoFactor = () => {
|
||||
const { control } = useFormContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -29,15 +16,11 @@ export const TwoFactor = ({ form }: TwoFactorProps) => {
|
||||
{t("auth.login.enter_your_two_factor_authentication_code")}
|
||||
</label>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<Controller
|
||||
control={control}
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,25 +2,11 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { FormControl } from "@formbricks/ui/components/Form";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
|
||||
interface TwoFactorBackupProps {
|
||||
form: UseFormReturn<
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export const TwoFactorBackup = ({ form }: TwoFactorBackupProps) => {
|
||||
export const TwoFactorBackup = () => {
|
||||
const { register } = useFormContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -29,23 +15,12 @@ export const TwoFactorBackup = ({ form }: TwoFactorBackupProps) => {
|
||||
<label htmlFor="totpBackup" className="sr-only">
|
||||
{t("auth.login.backup_code")}
|
||||
</label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
id="totpBackup"
|
||||
required
|
||||
placeholder="XXXXX-XXXXX"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
<Input
|
||||
id="totpBackup"
|
||||
required
|
||||
placeholder="XXXXX-XXXXX"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -35,7 +35,6 @@ interface PricingTableProps {
|
||||
SCALE: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
hasBillingRights: boolean;
|
||||
}
|
||||
|
||||
export const PricingTable = ({
|
||||
@@ -45,7 +44,6 @@ export const PricingTable = ({
|
||||
productFeatureKeys,
|
||||
responseCount,
|
||||
stripePriceLookupKeys,
|
||||
hasBillingRights,
|
||||
}: PricingTableProps) => {
|
||||
const t = useTranslations();
|
||||
const [planPeriod, setPlanPeriod] = useState<TOrganizationBillingPeriod>(
|
||||
@@ -222,50 +220,48 @@ export const PricingTable = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBillingRights && (
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<div
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("yearly")}>
|
||||
{t("environments.settings.billing.annually")}
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")} 🔥
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="flex gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<div
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{CLOUD_PRICING_DATA.plans.map((plan) => (
|
||||
<PricingCard
|
||||
planPeriod={planPeriod}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onUpgrade={async () => {
|
||||
await onUpgrade(plan.id);
|
||||
}}
|
||||
organization={organization}
|
||||
productFeatureKeys={productFeatureKeys}
|
||||
onManageSubscription={openCustomerPortal}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`items-centerrounded-md flex-1 whitespace-nowrap py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("yearly")}>
|
||||
{t("environments.settings.billing.annually")}
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")} 🔥
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{CLOUD_PRICING_DATA.plans.map((plan) => (
|
||||
<PricingCard
|
||||
planPeriod={planPeriod}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onUpgrade={async () => {
|
||||
await onUpgrade(plan.id);
|
||||
}}
|
||||
organization={organization}
|
||||
productFeatureKeys={productFeatureKeys}
|
||||
onManageSubscription={openCustomerPortal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Billing",
|
||||
@@ -26,7 +29,10 @@ const BillingLayout = async ({ children, params }) => {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
return <>{!isMember ? <>{children}</> : <ErrorComponent />}</>;
|
||||
};
|
||||
|
||||
export default BillingLayout;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { PRODUCT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
@@ -34,8 +33,6 @@ const Page = async ({ params }) => {
|
||||
]);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
const hasBillingRights = !isMember;
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
@@ -58,7 +55,6 @@ const Page = async ({ params }) => {
|
||||
responseCount={responseCount}
|
||||
stripePriceLookupKeys={STRIPE_PRICE_LOOKUP_KEYS}
|
||||
productFeatureKeys={PRODUCT_FEATURE_KEYS}
|
||||
hasBillingRights={hasBillingRights}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -26,33 +26,19 @@ export const GET = async (req: NextRequest) => {
|
||||
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
|
||||
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
|
||||
|
||||
const formData = {
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
const encodedKey = encodeURIComponent(property);
|
||||
const encodedValue = encodeURIComponent(formData[property]);
|
||||
formBody.push(encodedKey + "=" + encodedValue);
|
||||
}
|
||||
const bodyString = formBody.join("&");
|
||||
const formData = new FormData();
|
||||
formData.append("code", code ?? "");
|
||||
formData.append("client_id", SLACK_CLIENT_ID ?? "");
|
||||
formData.append("client_secret", SLACK_CLIENT_SECRET ?? "");
|
||||
|
||||
if (code) {
|
||||
const response = await fetch("https://slack.com/api/oauth.v2.access", {
|
||||
method: "POST",
|
||||
body: bodyString,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
return responses.badRequestResponse(data.error);
|
||||
}
|
||||
|
||||
const slackCredentials: TIntegrationSlackCredential = {
|
||||
app_id: data.app_id,
|
||||
authed_user: data.authed_user,
|
||||
|
||||
@@ -45,7 +45,6 @@ interface LinkSurveyProps {
|
||||
PRIVACY_URL?: string;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
locale: string;
|
||||
isPreview: boolean;
|
||||
}
|
||||
|
||||
export const LinkSurvey = ({
|
||||
@@ -65,11 +64,11 @@ export const LinkSurvey = ({
|
||||
PRIVACY_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
locale,
|
||||
isPreview,
|
||||
}: LinkSurveyProps) => {
|
||||
const t = useTranslations();
|
||||
const responseId = singleUseResponse?.id;
|
||||
const searchParams = useSearchParams();
|
||||
const isPreview = searchParams?.get("preview") === "true";
|
||||
const skipPrefilled = searchParams?.get("skipPrefilled") === "true";
|
||||
const sourceParam = searchParams?.get("source");
|
||||
const suId = searchParams?.get("suId");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LegalFooter } from "@/app/s/[surveyId]/components/LegalFooter";
|
||||
import { SurveyLoadingAnimation } from "@/app/s/[surveyId]/components/SurveyLoadingAnimation";
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProduct, TProductStyling } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
@@ -61,9 +61,9 @@ export const LinkSurveyWrapper = ({
|
||||
<div>
|
||||
<SurveyLoadingAnimation survey={survey} isBackgroundLoaded={isBackgroundLoaded} />
|
||||
<MediaBackground survey={survey} product={product} onBackgroundLoaded={handleBackgroundLoaded}>
|
||||
<div className="flex max-h-dvh min-h-dvh items-end justify-center overflow-clip sm:items-center">
|
||||
<div className="flex max-h-dvh min-h-dvh items-end justify-center overflow-clip md:items-center">
|
||||
{!styling.isLogoHidden && product.logo?.url && <ClientLogo product={product} />}
|
||||
<div className="h-full w-full space-y-6 p-0 sm:max-w-lg">
|
||||
<div className="h-full w-full space-y-6 p-0 md:max-w-md">
|
||||
{isPreview && (
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
|
||||
@@ -29,7 +29,6 @@ interface PinScreenProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
isEmbed: boolean;
|
||||
locale: string;
|
||||
isPreview: boolean;
|
||||
}
|
||||
|
||||
export const PinScreen = (props: PinScreenProps) => {
|
||||
@@ -49,7 +48,6 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
attributeClasses,
|
||||
isEmbed,
|
||||
locale,
|
||||
isPreview,
|
||||
} = props;
|
||||
|
||||
const [localPinEntry, setLocalPinEntry] = useState<string>("");
|
||||
@@ -137,7 +135,6 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
locale={locale}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,7 +28,6 @@ interface LinkSurveyPageProps {
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,7 +45,6 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
|
||||
if (!validId.success) {
|
||||
notFound();
|
||||
}
|
||||
const isPreview = searchParams.preview === "true";
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const locale = findMatchingLocale();
|
||||
const suId = searchParams.suId;
|
||||
@@ -64,7 +62,7 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
|
||||
}
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
|
||||
|
||||
if (survey && survey.status !== "inProgress" && !isPreview) {
|
||||
if (survey && survey.status !== "inProgress") {
|
||||
return (
|
||||
<SurveyInactive
|
||||
status={survey.status}
|
||||
@@ -176,7 +174,6 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
|
||||
attributeClasses={attributeClasses}
|
||||
isEmbed={isEmbed}
|
||||
locale={locale}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -199,7 +196,6 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
locale={locale}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ CacheHandler.onCreation(async () => {
|
||||
// Fallback to LRU handler if Redis client is not available.
|
||||
// The application will still work, but the cache will be in memory only and not shared.
|
||||
handler = createLruHandler();
|
||||
console.log("Using LRU handler for caching.");
|
||||
console.warn("Falling back to LRU handler because Redis client is not available.");
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -342,7 +342,7 @@ export function AdvancedTargetingCard({
|
||||
}}
|
||||
size="sm"
|
||||
variant={isSegmentUsedInOtherSurveys ? "minimal" : "secondary"}>
|
||||
{t("environments.segments.edit_segment")}
|
||||
{t("common.edit_segment")}
|
||||
<PencilIcon className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation";
|
||||
import { AccessView } from "@/modules/ee/teams/product-teams/components/access-view";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getMultiLanguagePermission, getRoleManagementPermission } from "@formbricks/ee/lib/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
@@ -13,7 +12,6 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
import { getTeamsByOrganizationId, getTeamsByProductId } from "./lib/teams";
|
||||
|
||||
export const ProductTeams = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const t = await getTranslations();
|
||||
const [product, session, organization] = await Promise.all([
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
@@ -21,13 +19,13 @@ export const ProductTeams = async ({ params }: { params: { environmentId: string
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
@@ -39,20 +37,20 @@ export const ProductTeams = async ({ params }: { params: { environmentId: string
|
||||
const teams = await getTeamsByProductId(product.id);
|
||||
|
||||
if (!teams) {
|
||||
throw new Error(t("common.teams_not_found"));
|
||||
throw new Error("Teams not found");
|
||||
}
|
||||
|
||||
const organizationTeams = await getTeamsByOrganizationId(organization.id);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
throw new Error("Organization Teams not found");
|
||||
}
|
||||
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.configuration")}>
|
||||
<PageHeader pageTitle="Configuration">
|
||||
<ProductConfigNavigation
|
||||
environmentId={params.environmentId}
|
||||
activeId="teams"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen
|
||||
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
|
||||
import { getTeams } from "@/modules/ee/teams/team-list/lib/teams";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRoleManagementPermission } from "@formbricks/ee/lib/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
@@ -14,15 +13,14 @@ import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
export const TeamsPage = async ({ params }) => {
|
||||
const t = await getTranslations();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new Error("Unauthenticated");
|
||||
}
|
||||
const organization = await getOrganizationByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
@@ -36,12 +34,12 @@ export const TeamsPage = async ({ params }) => {
|
||||
const teams = await getTeams(session.user.id, organization.id);
|
||||
|
||||
if (!teams) {
|
||||
throw new Error(t("common.teams_not_found"));
|
||||
throw new Error("Teams not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<PageHeader pageTitle="Organization Settings">
|
||||
<OrganizationSettingsNavbar
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -54,7 +54,7 @@ const getChannelTag = (
|
||||
|
||||
case 2:
|
||||
// Return labels for two channels concatenated with "or", removing "Survey"
|
||||
return labels.map(removeSurveySuffix).join(" " + t("common.or") + " ");
|
||||
return labels.map(removeSurveySuffix).join(t(" " + t("common.or") + " "));
|
||||
|
||||
case 3:
|
||||
return t("environments.surveys.templates.all_channels");
|
||||
@@ -76,14 +76,12 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
const channelTag = useMemo(() => getChannelTag(template.channels, t), [template.channels]);
|
||||
const getIndustryTag = (industries: TProductConfigIndustry[] | undefined): string | undefined => {
|
||||
// if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce".
|
||||
if (selectedFilter[1] !== null) {
|
||||
const industry = industryMapping.find((industry) => industry.value === selectedFilter[1]);
|
||||
if (industry) return t(industry.label);
|
||||
}
|
||||
if (selectedFilter[1] !== null)
|
||||
return industryMapping.find((industry) => industry.value === selectedFilter[1])?.label;
|
||||
if (!industries || industries.length === 0) return undefined;
|
||||
return industries.length > 1
|
||||
? t("environments.surveys.templates.multiple_industries")
|
||||
: t(industryMapping.find((industry) => industry.value === industries[0])?.label);
|
||||
: industryMapping.find((industry) => industry.value === industries[0])?.label;
|
||||
};
|
||||
|
||||
const industryTag = useMemo(
|
||||
@@ -97,7 +95,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
{industryTag && (
|
||||
<div
|
||||
className={cn("rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500")}>
|
||||
{industryTag}
|
||||
{t(industryTag)}
|
||||
</div>
|
||||
)}
|
||||
{channelTag && (
|
||||
@@ -105,7 +103,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
className={cn(
|
||||
"flex-nowrap rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500"
|
||||
)}>
|
||||
{channelTag}
|
||||
{t(channelTag)}
|
||||
</div>
|
||||
)}
|
||||
{template.preset.questions.some((question) => question.logic && question.logic.length > 0) && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "2.7.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
|
||||
128
docker-compose.yml
Normal file
@@ -0,0 +1,128 @@
|
||||
# This should be the same as below if you are running via docker compose up
|
||||
x-webapp-url: &webapp_url http://localhost:3000
|
||||
|
||||
x-nextauth-url: &nextauth_url http://localhost:3000
|
||||
|
||||
# PostgreSQL DB for Formbricks to connect to
|
||||
x-database-url: &database_url postgresql://postgres:postgres@postgres:5432/formbricks?schema=public
|
||||
|
||||
x-redis-url: &redis_url
|
||||
# NextJS Auth
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: `openssl rand -hex 32` to generate one
|
||||
|
||||
|
||||
x-nextauth-secret: &nextauth_secret
|
||||
# Encryption key
|
||||
# You can use: `openssl rand -hex 32` to generate one
|
||||
|
||||
|
||||
x-cron-secret: &cron_secret
|
||||
# Set the below to use it instead of API Key for the API & use as an auth for cronjobs
|
||||
# You can use: $(openssl rand -hex 32) to generate a secure one
|
||||
|
||||
|
||||
x-encryption-key: &encryption_key
|
||||
|
||||
x-mail-from: &mail_from
|
||||
x-smtp-host: &smtp_host
|
||||
x-smtp-port: &smtp_port
|
||||
x-smtp-secure-enabled: &smtp_secure_enabled # Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||
|
||||
|
||||
x-smtp-user: &smtp_user
|
||||
x-smtp-password: &smtp_password
|
||||
|
||||
x-smtp-reject-unauthorized-tls: &smtp_reject_unauthorized_tls 1 # If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs.
|
||||
|
||||
x-short-url-base:
|
||||
&short_url_base # Set the below value if you have and want to share a shorter base URL than the x-survey-base-url
|
||||
|
||||
|
||||
x-email-verification-disabled: &email_verification_disabled 1
|
||||
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
x-password-reset-disabled: &password_reset_disabled 1
|
||||
|
||||
# Signup. Disable the ability for new users to create an account.
|
||||
x-signup-disabled: &signup_disabled 1
|
||||
|
||||
# Email login. Disable the ability for users to login with email.
|
||||
x-auth-disabled: &email_auth_disabled 0
|
||||
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
x-invite-disabled: &invite_disabled 0
|
||||
|
||||
# Set the below values to display privacy policy, imprint and terms of service links in the footer of signup & public pages.
|
||||
x-privacy-url: &privacy_url
|
||||
x-terms-url: &terms_url
|
||||
x-imprint-url: &imprint_url
|
||||
|
||||
x-github-id: &github_id
|
||||
x-github-secret: &github_secret
|
||||
|
||||
x-google-client-id: &google_client_id
|
||||
x-google-client-secret: &google_client_secret
|
||||
|
||||
x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error # Disable Sentry warning
|
||||
|
||||
|
||||
x-next-public-sentry-dsn: &next_public_sentry_dsn # Enable Sentry Error Tracking
|
||||
|
||||
|
||||
services:
|
||||
postgres:
|
||||
restart: always
|
||||
image: pgvector/pgvector:pg17
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
||||
formbricks:
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- 3000:3000
|
||||
|
||||
environment:
|
||||
WEBAPP_URL: *webapp_url
|
||||
DATABASE_URL: *database_url
|
||||
NEXTAUTH_SECRET: *nextauth_secret
|
||||
MAIL_FROM: *mail_from
|
||||
SMTP_HOST: *smtp_host
|
||||
SMTP_PORT: *smtp_port
|
||||
SMTP_SECURE_ENABLED: *smtp_secure_enabled
|
||||
SMTP_USER: *smtp_user
|
||||
SMTP_PASSWORD: *smtp_password
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: *smtp_reject_unauthorized_tls
|
||||
ENCRYPTION_KEY: *encryption_key
|
||||
SHORT_URL_BASE: *short_url_base
|
||||
PRIVACY_URL: *privacy_url
|
||||
TERMS_URL: *terms_url
|
||||
IMPRINT_URL: *imprint_url
|
||||
EMAIL_VERIFICATION_DISABLED: *email_verification_disabled
|
||||
PASSWORD_RESET_DISABLED: *password_reset_disabled
|
||||
EMAIL_AUTH_DISABLED: *email_auth_disabled
|
||||
SIGNUP_DISABLED: *signup_disabled
|
||||
INVITE_DISABLED: *invite_disabled
|
||||
SENTRY_IGNORE_API_RESOLUTION_ERROR: *sentry_ignore_api_resolution_error
|
||||
NEXT_PUBLIC_SENTRY_DSN: *next_public_sentry_dsn
|
||||
GITHUB_ID: *github_id
|
||||
GITHUB_SECRET: *github_secret
|
||||
GOOGLE_CLIENT_ID: *google_client_id
|
||||
GOOGLE_CLIENT_SECRET: *google_client_secret
|
||||
CRON_SECRET: *cron_secret
|
||||
REDIS_URL: *redis_url
|
||||
|
||||
volumes:
|
||||
- uploads:/home/nextjs/apps/web/uploads/
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
uploads:
|
||||
@@ -54,8 +54,7 @@
|
||||
"data-migration:segments-actions-cleanup": "ts-node ./data-migrations/20240904091113_removed_actions_table/data-migration.ts",
|
||||
"data-migration:migrate-survey-types": "ts-node ./data-migrations/20241002123456_migrate_survey_types/data-migration.ts",
|
||||
"data-migration:v2.6": "pnpm data-migration:add-display-id-to-response && pnpm data-migration:address-question && pnpm data-migration:advanced-logic && pnpm data-migration:segments-actions-cleanup && pnpm data-migration:migrate-survey-types",
|
||||
"data-migration:add-teams": "ts-node ./data-migrations/20241107161932_add_teams/data-migration.ts",
|
||||
"data-migration:v2.7": "pnpm data-migration:add-teams"
|
||||
"data-migration:add-teams": "ts-node ./data-migrations/20241107161932_add_teams/data-migration.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.20.0",
|
||||
|
||||
@@ -48,11 +48,7 @@ export const getAttributeClass = reactCache(
|
||||
);
|
||||
|
||||
export const getAttributeClasses = reactCache(
|
||||
async (
|
||||
environmentId: string,
|
||||
page?: number,
|
||||
options?: { skipArchived: boolean }
|
||||
): Promise<TAttributeClass[]> =>
|
||||
async (environmentId: string, page?: number): Promise<TAttributeClass[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
@@ -61,7 +57,6 @@ export const getAttributeClasses = reactCache(
|
||||
const attributeClasses = await prisma.attributeClass.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
...(options?.skipArchived ? { archived: false } : {}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DEFAULT_ORGANIZATION_ID,
|
||||
DEFAULT_ORGANIZATION_ROLE,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
GITHUB_ID,
|
||||
GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
OIDC_ISSUER,
|
||||
OIDC_SIGNING_ALGORITHM,
|
||||
} from "./constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "./crypto";
|
||||
import { verifyToken } from "./jwt";
|
||||
import { createMembership } from "./membership/service";
|
||||
import { createOrganization, getOrganization } from "./organization/service";
|
||||
@@ -54,8 +52,6 @@ export const authOptions: NextAuthOptions = {
|
||||
type: "password",
|
||||
placeholder: "Your password",
|
||||
},
|
||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
let user;
|
||||
@@ -83,54 +79,6 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled && credentials.backupCode) {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!user.backupCodes) throw new Error("No backup codes found");
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) throw new Error("Invalid backup code");
|
||||
|
||||
// delete verified backup code and re-encrypt remaining
|
||||
backupCodes[index] = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), ENCRYPTION_KEY),
|
||||
},
|
||||
});
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!credentials.totpCode) {
|
||||
throw new Error("second factor required");
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY);
|
||||
if (secret.length !== 32) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const isValidToken = (await import("./totp")).totpAuthenticatorCheck(credentials.totpCode, secret);
|
||||
if (!isValidToken) {
|
||||
throw new Error("Invalid second factor code");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"add_filter": "Filter hinzufügen",
|
||||
"add_logo": "Logo hinzufügen",
|
||||
"add_product": "Produkt hinzufügen",
|
||||
"all": "Alle",
|
||||
"all_questions": "Alle Fragen",
|
||||
"allow": "erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
||||
@@ -350,7 +349,6 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"styling": "Styling",
|
||||
"submit": "Abschicken",
|
||||
"summary": "Zusammenfassung",
|
||||
"survey": "Umfrage",
|
||||
"survey_completed": "Umfrage abgeschlossen.",
|
||||
@@ -369,9 +367,7 @@
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Teamzugriff",
|
||||
"team_not_found": "Team nicht gefunden",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams nicht gefunden",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
"time_to_finish": "Zeit zum Fertigstellen",
|
||||
@@ -488,8 +484,6 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Aktion erfolgreich kopiert",
|
||||
"action_copy_failed": "Aktion konnte nicht kopiert werden",
|
||||
"action_created_successfully": "Aktion erfolgreich erstellt",
|
||||
"action_deleted_successfully": "Aktion erfolgreich gelöscht",
|
||||
"action_type": "Aktionstyp",
|
||||
@@ -688,9 +682,7 @@
|
||||
"select_channel": "Kanal auswählen",
|
||||
"slack_integration": "Slack Integration",
|
||||
"slack_integration_description": "Sende Antworten direkt an Slack.",
|
||||
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.",
|
||||
"slack_reconnect_button": "Erneut verbinden",
|
||||
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
|
||||
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert."
|
||||
},
|
||||
"slack_integration_description": "Verbinde deinen Slack Arbeitsbereich sofort mit Formbricks",
|
||||
"to_configure_it": "es zu konfigurieren.",
|
||||
@@ -964,7 +956,7 @@
|
||||
"value_must_be_a_number": "Wert muss eine Zahl sein.",
|
||||
"view_filters": "Filter anzeigen",
|
||||
"where": "Wo",
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK"
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK."
|
||||
},
|
||||
"settings": {
|
||||
"billing": {
|
||||
@@ -1136,7 +1128,6 @@
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
|
||||
@@ -1576,9 +1567,9 @@
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
|
||||
"waiting_period": "Wartezeit",
|
||||
"welcome_message": "Willkommensnachricht",
|
||||
"when": "Wenn",
|
||||
"when": "Wann",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Wenn die Bedingungen übereinstimmen, wird die Wartezeit ignoriert und die Umfrage angezeigt.",
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK",
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_product_to_work_with_translations": "Du musst zwei oder mehr Sprachen in deinem Produkt einrichten, um mit Übersetzungen zu arbeiten.",
|
||||
"your_description_here_recall_information_with": "Deine Beschreibung hier. Informationen abrufen mit @",
|
||||
@@ -1699,11 +1690,7 @@
|
||||
"is_equal_to": "Ist gleich",
|
||||
"is_less_than": "ist weniger als",
|
||||
"last_30_days": "Letzte 30 Tage",
|
||||
"last_6_months": "Letzte 6 Monate",
|
||||
"last_7_days": "Letzte 7 Tage",
|
||||
"last_month": "Letztes Monat",
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"learn_how_to": "Lerne, wie man",
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
|
||||
@@ -1736,9 +1723,6 @@
|
||||
"static_iframe": "Statisch (iframe)",
|
||||
"survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"to_connect_your_app_with_formbricks": "um deine App mit Formbricks zu verbinden",
|
||||
"to_connect_your_web_app_with_formbricks": "um deine Web-App mit Formbricks zu verbinden",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"add_filter": "Add filter",
|
||||
"add_logo": "Add logo",
|
||||
"add_product": "Add product",
|
||||
"all": "All",
|
||||
"all_questions": "All questions",
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
@@ -350,7 +349,6 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
"survey": "Survey",
|
||||
"survey_completed": "Survey completed.",
|
||||
@@ -369,9 +367,7 @@
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Team Access",
|
||||
"team_not_found": "Team not found",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams not found",
|
||||
"text": "Text",
|
||||
"time": "Time",
|
||||
"time_to_finish": "Time to finish",
|
||||
@@ -488,8 +484,6 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Action copied successfully",
|
||||
"action_copy_failed": "Action copy failed",
|
||||
"action_created_successfully": "Action created successfully",
|
||||
"action_deleted_successfully": "Action deleted successfully",
|
||||
"action_type": "Action Type",
|
||||
@@ -688,9 +682,7 @@
|
||||
"select_channel": "Select Channel",
|
||||
"slack_integration": "Slack Integration",
|
||||
"slack_integration_description": "Send responses directly to Slack.",
|
||||
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.",
|
||||
"slack_reconnect_button": "Reconnect",
|
||||
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
|
||||
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks."
|
||||
},
|
||||
"slack_integration_description": "Instantly connect your Slack Workspace with Formbricks",
|
||||
"to_configure_it": "to configure it.",
|
||||
@@ -794,7 +786,7 @@
|
||||
"tag_of_your_app": "tag of your app",
|
||||
"to_the": "to the",
|
||||
"to_the_url_where_you_load_the": "to the URL where you load the",
|
||||
"to_the_url_where_you_load_the_formbricks_sdk": "to the URL where you load the Formbricks SDK",
|
||||
"to_the_url_where_you_load_the_formbricks_sdk": "to the URL where you load the Formbricks SDK.",
|
||||
"want_to_learn_how_to_add_user_attributes": "Want to learn how to add user attributes, custom events and more?",
|
||||
"you_also_need_to_pass_a": "you also need to pass a",
|
||||
"you_are_done": "You're done 🎉",
|
||||
@@ -964,7 +956,7 @@
|
||||
"value_must_be_a_number": "Value must be a number.",
|
||||
"view_filters": "View filters",
|
||||
"where": "Where",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK"
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK."
|
||||
},
|
||||
"settings": {
|
||||
"billing": {
|
||||
@@ -1136,7 +1128,6 @@
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
|
||||
@@ -1578,7 +1569,7 @@
|
||||
"welcome_message": "Welcome message",
|
||||
"when": "When",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "When conditions match, waiting time will be ignored and survey shown.",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK.",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_product_to_work_with_translations": "You need to have two or more languages set up in your product to work with translations.",
|
||||
"your_description_here_recall_information_with": "Your description here. Recall information with @",
|
||||
@@ -1699,11 +1690,7 @@
|
||||
"is_equal_to": "Is equal to",
|
||||
"is_less_than": "Is less than",
|
||||
"last_30_days": "Last 30 days",
|
||||
"last_6_months": "Last 6 months",
|
||||
"last_7_days": "Last 7 days",
|
||||
"last_month": "Last month",
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"learn_how_to": "Learn how to",
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
|
||||
@@ -1736,9 +1723,6 @@
|
||||
"static_iframe": "Static (iframe)",
|
||||
"survey_results_are_public": "Your survey results are public!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.",
|
||||
"this_month": "This month",
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"to_connect_your_app_with_formbricks": "to connect your app with Formbricks",
|
||||
"to_connect_your_web_app_with_formbricks": "to connect your web app with Formbricks",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logo",
|
||||
"add_product": "Adicionar produto",
|
||||
"all": "Todos",
|
||||
"all_questions": "Todas as perguntas",
|
||||
"allow": "permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
||||
@@ -350,7 +349,6 @@
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"styling": "estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
"survey": "Pesquisa",
|
||||
"survey_completed": "Pesquisa concluída.",
|
||||
@@ -369,9 +367,7 @@
|
||||
"targeting": "mirando",
|
||||
"team": "Time",
|
||||
"team_access": "Acesso da equipe",
|
||||
"team_not_found": "Equipe não encontrada",
|
||||
"teams": "Times",
|
||||
"teams_not_found": "Equipes não encontradas",
|
||||
"text": "Texto",
|
||||
"time": "tempo",
|
||||
"time_to_finish": "Hora de terminar",
|
||||
@@ -488,8 +484,6 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Ação copiada com sucesso",
|
||||
"action_copy_failed": "Falha ao copiar a ação",
|
||||
"action_created_successfully": "Ação criada com sucesso",
|
||||
"action_deleted_successfully": "Ação deletada com sucesso",
|
||||
"action_type": "Tipo de Ação",
|
||||
@@ -688,9 +682,7 @@
|
||||
"select_channel": "Selecionar Canal",
|
||||
"slack_integration": "Integração com o Slack",
|
||||
"slack_integration_description": "Manda as respostas direto pro Slack.",
|
||||
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.",
|
||||
"slack_reconnect_button": "Reconectar",
|
||||
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
|
||||
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks."
|
||||
},
|
||||
"slack_integration_description": "Conecte instantaneamente seu Workspace do Slack com o Formbricks",
|
||||
"to_configure_it": "configurar isso.",
|
||||
@@ -1136,7 +1128,6 @@
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
|
||||
@@ -1699,11 +1690,7 @@
|
||||
"is_equal_to": "É igual a",
|
||||
"is_less_than": "É menor que",
|
||||
"last_30_days": "Últimos 30 dias",
|
||||
"last_6_months": "Últimos 6 meses",
|
||||
"last_7_days": "Últimos 7 dias",
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"learn_how_to": "Aprenda como",
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
|
||||
@@ -1736,9 +1723,6 @@
|
||||
"static_iframe": "Estático (iframe)",
|
||||
"survey_results_are_public": "Os resultados da sua pesquisa são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"to_connect_your_app_with_formbricks": "conectar seu app com o Formbricks",
|
||||
"to_connect_your_web_app_with_formbricks": "conectar seu app web com o Formbricks",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||
@@ -66,7 +66,7 @@ export const getSlackChannels = async (environmentId: string): Promise<TIntegrat
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw new UnknownError(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -409,7 +409,7 @@ export const Survey = ({
|
||||
<AutoCloseWrapper survey={localSurvey} onClose={onClose} offset={offset}>
|
||||
<div
|
||||
className={cn(
|
||||
"fb-no-scrollbar sm:fb-rounded-custom fb-rounded-t-custom fb-bg-survey-bg fb-flex fb-h-full fb-w-full fb-flex-col fb-justify-between fb-overflow-hidden fb-transition-all fb-duration-1000 fb-ease-in-out",
|
||||
"fb-no-scrollbar md:fb-rounded-custom fb-rounded-t-custom fb-bg-survey-bg fb-flex fb-h-full fb-w-full fb-flex-col fb-justify-between fb-overflow-hidden fb-transition-all fb-duration-1000 fb-ease-in-out",
|
||||
cardArrangement === "simple" ? "fb-survey-shadow" : "",
|
||||
offset === 0 || cardArrangement === "simple" ? "fb-opacity-100" : "fb-opacity-0"
|
||||
)}>
|
||||
@@ -430,7 +430,7 @@ export const Survey = ({
|
||||
)}>
|
||||
{content()}
|
||||
</div>
|
||||
<div className="fb-mx-6 fb-mb-10 fb-mt-2 fb-space-y-3 sm:fb-mb-6 sm:fb-mt-6">
|
||||
<div className="fb-mx-6 fb-mb-10 fb-mt-2 fb-space-y-3 md:fb-mb-6 md:fb-mt-6">
|
||||
{isBrandingEnabled && <FormbricksBranding />}
|
||||
{showProgressBar && <ProgressBar survey={localSurvey} questionId={questionId} />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZOrganizationRole } from "./memberships";
|
||||
import { ZUserName } from "./user";
|
||||
|
||||
export const ZInvite = z.object({
|
||||
id: z.string(),
|
||||
@@ -17,7 +16,7 @@ export type TInvite = z.infer<typeof ZInvite>;
|
||||
|
||||
export const ZInvitee = z.object({
|
||||
email: z.string().email(),
|
||||
name: ZUserName,
|
||||
name: z.string(),
|
||||
role: ZOrganizationRole,
|
||||
});
|
||||
export type TInvitee = z.infer<typeof ZInvitee>;
|
||||
|
||||
@@ -1041,7 +1041,7 @@ export const ZSurvey = z
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.Cal) {
|
||||
if (question.calHost !== undefined) {
|
||||
const hostnameRegex = /^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-)){1,}$/i;
|
||||
const hostnameRegex = /^[a-zA-Z0-9]+(?<domain>\.[a-zA-Z0-9]+)+$/;
|
||||
if (!hostnameRegex.test(question.calHost)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
||||
@@ -22,17 +22,14 @@ export const ZUserNotificationSettings = z.object({
|
||||
unsubscribedOrganizationIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ZUserName = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" })
|
||||
.regex(/^[a-zA-Z0-9\s]+$/, { message: "Name should only contain letters, numbers, and spaces" });
|
||||
|
||||
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: ZUserName,
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
@@ -49,7 +46,7 @@ export const ZUser = z.object({
|
||||
export type TUser = z.infer<typeof ZUser>;
|
||||
|
||||
export const ZUserUpdateInput = z.object({
|
||||
name: ZUserName.optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
emailVerified: z.date().nullish(),
|
||||
role: ZRole.optional(),
|
||||
@@ -62,7 +59,10 @@ export const ZUserUpdateInput = z.object({
|
||||
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
|
||||
|
||||
export const ZUserCreateInput = z.object({
|
||||
name: ZUserName,
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().optional(),
|
||||
role: ZRole.optional(),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeSelect } from "./index";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/BadgeSelect",
|
||||
component: BadgeSelect,
|
||||
title: "ui/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
@@ -16,7 +16,7 @@ const meta = {
|
||||
size: { control: "select", options: ["small", "normal", "large"] },
|
||||
className: { control: "text" },
|
||||
},
|
||||
} satisfies Meta<typeof BadgeSelect>;
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ export const PreviewSurvey = ({
|
||||
<ClientLogo environmentId={environment.id} product={product} previewSurvey />
|
||||
)}
|
||||
</div>
|
||||
<div className="z-0 w-full max-w-lg rounded-lg border-transparent">
|
||||
<div className="z-0 w-full max-w-md rounded-lg border-transparent">
|
||||
<SurveyInline
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { createUser } from "@formbricks/lib/utils/users";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { Button } from "../Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "../Form";
|
||||
import { Input } from "../Input";
|
||||
import { PasswordInput } from "../PasswordInput";
|
||||
import { AzureButton } from "./components/AzureButton";
|
||||
import { GithubButton } from "./components/GithubButton";
|
||||
@@ -47,35 +41,28 @@ export const SignupOptions = ({
|
||||
oidcDisplayName,
|
||||
userLocale,
|
||||
}: SignupOptionsProps) => {
|
||||
const t = useTranslations();
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [signingUp, setSigningUp] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const ZSignupInput = z.object({
|
||||
name: ZUserName,
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8)
|
||||
.regex(/^(?=.*[A-Z])(?=.*\d).*$/),
|
||||
});
|
||||
|
||||
type TSignupInput = z.infer<typeof ZSignupInput>;
|
||||
const form = useForm<TSignupInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: emailFromSearchParams || "",
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(ZSignupInput),
|
||||
});
|
||||
const [isButtonEnabled, setButtonEnabled] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = async (data: TSignupInput) => {
|
||||
const checkFormValidity = () => {
|
||||
// If all fields are filled, enable the button
|
||||
if (formRef.current) {
|
||||
setButtonEnabled(formRef.current.checkValidity());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
@@ -83,10 +70,16 @@ export const SignupOptions = ({
|
||||
setSigningUp(true);
|
||||
|
||||
try {
|
||||
await createUser(data.name, data.email, data.password, userLocale, inviteToken || "");
|
||||
await createUser(
|
||||
e.target.elements.name.value,
|
||||
e.target.elements.email.value,
|
||||
e.target.elements.password.value,
|
||||
userLocale,
|
||||
inviteToken || ""
|
||||
);
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success`
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(data.email)}`;
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(e.target.elements.email.value)}`;
|
||||
|
||||
router.push(url);
|
||||
} catch (e: any) {
|
||||
@@ -100,105 +93,87 @@ export const SignupOptions = ({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{emailAuthEnabled && (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
{showLogin && (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
<form onSubmit={handleSubmit} ref={formRef} className="space-y-2" onChange={checkFormValidity}>
|
||||
{showLogin && (
|
||||
<div>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="name" className="sr-only">
|
||||
{t("common.full_name")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
ref={nameRef}
|
||||
id="name"
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
value={field.value}
|
||||
name="name"
|
||||
autoFocus
|
||||
onChange={(name) => field.onChange(name)}
|
||||
placeholder="Full name"
|
||||
className="bg-white"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
value={field.value}
|
||||
name="email"
|
||||
onChange={(email) => field.onChange(email)}
|
||||
defaultValue={emailFromSearchParams}
|
||||
placeholder="work@email.com"
|
||||
className="bg-white"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm sm:text-sm"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder={t("common.full_name")}
|
||||
aria-placeholder={"Full name"}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<IsPasswordValid password={form.watch("password")} setIsValid={setIsValid} />
|
||||
</div>
|
||||
)}
|
||||
{showLogin && (
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-10 w-full justify-center"
|
||||
loading={signingUp}
|
||||
disabled={!form.formState.isValid}>
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t("common.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="work@email.com"
|
||||
defaultValue={emailFromSearchParams}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
value={password ? password : ""}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<IsPasswordValid password={password} setIsValid={setIsValid} />
|
||||
</div>
|
||||
)}
|
||||
{showLogin && (
|
||||
<Button
|
||||
size="base"
|
||||
type="submit"
|
||||
className="w-full justify-center"
|
||||
loading={signingUp}
|
||||
disabled={formRef.current ? !isButtonEnabled || !isValid : !isButtonEnabled}>
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!showLogin && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => nameRef.current?.focus(), 100);
|
||||
}}
|
||||
className="h-10 w-full justify-center">
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
{!showLogin && (
|
||||
<Button
|
||||
size="base"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
setButtonEnabled(false);
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => nameRef.current?.focus(), 100);
|
||||
}}
|
||||
className="w-full justify-center">
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{googleOAuthEnabled && (
|
||||
<>
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
"INVITE_DISABLED",
|
||||
"IS_FORMBRICKS_CLOUD",
|
||||
"MAIL_FROM",
|
||||
"NEXT_PUBLIC_LAYER_API_KEY",
|
||||
"NEXT_PUBLIC_DOCSEARCH_APP_ID",
|
||||
"NEXT_PUBLIC_DOCSEARCH_API_KEY",
|
||||
"NEXT_PUBLIC_DOCSEARCH_INDEX_NAME",
|
||||
|
||||