Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3788293bc0 | ||
|
|
89a2e26f25 | ||
|
|
2c453bd491 | ||
|
|
0aebf234f9 | ||
|
|
3e25ef4b5a | ||
|
|
3dc3edb83e | ||
|
|
4ebe144191 | ||
|
|
49cd06a9b4 | ||
|
|
e0208da0ac | ||
|
|
1f41770060 | ||
|
|
3eff06281c | ||
|
|
5848dfb4f3 | ||
|
|
89f27adce5 | ||
|
|
bbab7fa672 | ||
|
|
fb149796fa | ||
|
|
3939013415 | ||
|
|
7fe18e99c3 | ||
|
|
6ddfd29be8 | ||
|
|
46efad94db | ||
|
|
b7ea073204 | ||
|
|
0d9c90ceeb | ||
|
|
3ba23e1787 | ||
|
|
e365718556 | ||
|
|
d65a49a7e7 | ||
|
|
7960aaf5d5 | ||
|
|
32b3a7d1d0 | ||
|
|
53fb976fb6 | ||
|
|
fffe71aa7e | ||
|
|
75b0a3a407 | ||
|
|
158689672a | ||
|
|
24e43dd1a2 | ||
|
|
b34366aaf7 | ||
|
|
2856c8d125 | ||
|
|
4ac1e1d798 | ||
|
|
b15d23035c | ||
|
|
8b2ea63ccb | ||
|
|
b0c65c76e6 | ||
|
|
c65c1af023 | ||
|
|
f10bd9c0d8 | ||
|
|
a6ac78294b | ||
|
|
04c9ead19d | ||
|
|
3c3798ee98 | ||
|
|
8df722ab02 | ||
|
|
dbe5ca60cd |
23
.env.example
@@ -11,33 +11,26 @@ WEBAPP_URL=http://localhost:3000
|
||||
# Required for next-auth. Should be the same as WEBAPP_URL
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# Set this if you want to have a shorter link for surveys
|
||||
SHORT_URL_BASE=
|
||||
|
||||
# Encryption keys
|
||||
# Please set both for now, we will change this in the future
|
||||
|
||||
# You can use: `openssl rand -hex 32` to generate one
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: `openssl rand -hex 32` to generate a secure one
|
||||
NEXTAUTH_SECRET=
|
||||
|
||||
# API Secret for running cron jobs. (mandatory)
|
||||
# You can use: `openssl rand -hex 32` to generate a secure one
|
||||
CRON_SECRET=
|
||||
|
||||
##############
|
||||
# DATABASE #
|
||||
##############
|
||||
|
||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
||||
|
||||
###############
|
||||
# NEXT AUTH #
|
||||
###############
|
||||
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: `openssl rand -hex 32` to generate a secure one
|
||||
NEXTAUTH_SECRET=RANDOM_STRING
|
||||
|
||||
# API Secret for running cron jobs. (mandatory)
|
||||
# You can use: `openssl rand -hex 32` to generate a secure one
|
||||
CRON_SECRET=RANDOM_STRING
|
||||
|
||||
################
|
||||
# MAIL SETUP #
|
||||
################
|
||||
|
||||
4
.github/actions/cache-build-web/action.yml
vendored
@@ -53,10 +53,12 @@ runs:
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Fill ENCRYPTION_KE, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||
shell: bash
|
||||
|
||||
50
.github/workflows/e2e.yml
vendored
@@ -2,6 +2,8 @@ name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
env:
|
||||
TELEMETRY_DISABLED: 1
|
||||
jobs:
|
||||
build:
|
||||
name: Run E2E Tests
|
||||
@@ -25,29 +27,35 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Build & Cache Web Binaries
|
||||
uses: ./.github/actions/cache-build-web
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
e2e_testing_mode: "1"
|
||||
|
||||
- name: Check if pnpm is installed
|
||||
id: pnpm-check
|
||||
run: |
|
||||
if pnpm --version; then
|
||||
echo "pnpm is installed."
|
||||
echo "PNPM_INSTALLED=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "pnpm is not installed."
|
||||
echo "PNPM_INSTALLED=false" >> $GITHUB_ENV
|
||||
fi
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
if: env.PNPM_INSTALLED == 'false'
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.PNPM_INSTALLED == 'false'
|
||||
run: pnpm install
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
shell: bash
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
pnpm build --filter=@formbricks/web...
|
||||
|
||||
- name: Apply Prisma Migrations
|
||||
run: |
|
||||
@@ -70,15 +78,7 @@ jobs:
|
||||
sleep 10
|
||||
done
|
||||
|
||||
- name: Cache Playwright
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run E2E Tests
|
||||
|
||||
8
.github/workflows/lint.yml
vendored
@@ -25,10 +25,12 @@ jobs:
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate Random ENCRYPTION_KEY and fill in .env
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" .env
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
name: Docker for Data Migrations
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -12,7 +7,6 @@ on:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: formbricks/data-migrations
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
|
||||
@@ -23,8 +17,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
@@ -50,6 +42,7 @@ jobs:
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=${{ github.ref_name }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
@@ -66,3 +59,4 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
run: |
|
||||
cosign sign --yes ghcr.io/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
cosign sign --yes ghcr.io/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
8
.github/workflows/test.yml
vendored
@@ -25,10 +25,12 @@ jobs:
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate Random ENCRYPTION_KEY and fill in .env
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" .env
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
2
.gitignore
vendored
@@ -56,5 +56,5 @@ Zone.Identifier
|
||||
packages/lib/uploads
|
||||
|
||||
# Vite Timestamps
|
||||
vite.config.*.timestamp-*
|
||||
*vite.config.*.timestamp-*
|
||||
|
||||
|
||||
@@ -144,6 +144,12 @@ Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the
|
||||
|
||||
[](https://repocloud.io/details/?app_id=254)
|
||||
|
||||
##### Zeabur
|
||||
|
||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||
|
||||
[](https://zeabur.com/templates/G4TUJL)
|
||||
|
||||
<a id="development"></a>
|
||||
|
||||
## 👨💻 Development
|
||||
|
||||
@@ -62,7 +62,7 @@ const AppPage = ({}) => {
|
||||
return (
|
||||
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row">
|
||||
<SurveySwitch value="app" formbricks={formbricks} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
|
||||
@@ -58,7 +58,7 @@ const AppPage = ({}) => {
|
||||
return (
|
||||
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col items-center justify-between md:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row">
|
||||
<SurveySwitch value="website" formbricks={formbricks} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,68 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import StepOne from "./images/StepOne.webp";
|
||||
import StepThree from "./images/StepThree.webp";
|
||||
import StepTwo from "./images/StepTwo.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Custom Start & End Conditions for Surveys",
|
||||
description:
|
||||
"Optimize your survey management with custom Start & End Conditions in Formbricks. This feature allows you to control exactly when your survey is available for responses and when it should close, making it ideal for time-sensitive or number-of-response-limited surveys.",
|
||||
};
|
||||
|
||||
# Custom Start & End Conditions
|
||||
|
||||
Optimize your survey management with custom Start & End Conditions in Formbricks. This feature allows you to control exactly when your survey is available for responses and when it should close, making it ideal for time-sensitive or number-of-response-limited surveys.
|
||||
|
||||
Configure your surveys to open and close based on specific criteria. Here’s how to set up these conditions:
|
||||
|
||||
### **Opening a Survey**
|
||||
|
||||
**1. Schedule a Start Date:**
|
||||
|
||||
- **How to Set**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Release Survey on Date”.
|
||||
|
||||
<MdxImage
|
||||
src={StepOne}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
- **Details**: Choose the date and time when the survey should become available to respondents. All times follow UTC timezone.
|
||||
- **Use Case**: This is useful for launching surveys in alignment with events, product releases, or specific marketing campaigns.
|
||||
|
||||
### **Closing a Survey**
|
||||
|
||||
**1. Limit by Number of Responses:**
|
||||
|
||||
- **How to Set**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on response limit”.
|
||||
{" "}
|
||||
<MdxImage
|
||||
src={StepTwo}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
- **Details**: Set a specific number of responses after which the survey automatically closes.
|
||||
- **Use Case**: Perfect for limited offers, exclusive surveys, or when you need a precise sample size for statistical significance.
|
||||
|
||||
**2. Schedule an End Date:**
|
||||
|
||||
- **How to Set**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on date”.
|
||||
|
||||
<MdxImage
|
||||
src={StepThree}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
- **Details**: Define a specific date and time for the survey to close. This also follows UTC timezone. - **Use
|
||||
Case**: Essential for surveys linked to time-bound events or studies where data collection needs to end at a specific
|
||||
point.
|
||||
|
||||
### **Summary**
|
||||
|
||||
Setting up Custom Start & End Conditions in Formbricks allows you to control the availability and duration of your surveys with precision. Whether you are conducting academic research, market analysis, or gathering event feedback, these settings help ensure that your data collection aligns perfectly with your objectives.
|
||||
|
||||
---
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
28
apps/docs/app/global/limit-submissions/page.mdx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import StepOne from "./images/StepOne.webp";
|
||||
import StepThree from "./images/StepThree.webp";
|
||||
import StepTwo from "./images/StepTwo.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Set a Maximum Number of Submissions for Surveys",
|
||||
description:
|
||||
"Limit the number of responses your survey can receive.",
|
||||
};
|
||||
|
||||
# Limit by Number of Submissions
|
||||
|
||||
Automatically close your survey after a specific number of responses with Formbricks. This feature is perfect for limited offers, exclusive surveys, or when you need a precise sample size for statistical significance.
|
||||
|
||||
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on response limit”.
|
||||
{" "}
|
||||
<MdxImage
|
||||
src={StepTwo}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
- **Details**: Set a specific number of responses after which the survey automatically closes.
|
||||
- **Use Case**: Perfect for limited offers, exclusive surveys, or when you need a precise sample size for statistical significance.
|
||||
|
||||
---
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
51
apps/docs/app/global/schedule-start-end-dates/page.mdx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import StepOne from "./images/StepOne.webp";
|
||||
import StepThree from "./images/StepThree.webp";
|
||||
import StepTwo from "./images/StepTwo.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Schedule Start & End Dates for Surveys",
|
||||
description:
|
||||
"Automatically release and close surveys based on specific dates.",
|
||||
};
|
||||
|
||||
# Schedule Start & End Dates
|
||||
|
||||
Optimize your survey management with custom Start & End Conditions in Formbricks. This feature allows you to control exactly when your survey is available for responses and when it should close, making it ideal for time-sensitive or number-of-response-limited surveys.
|
||||
|
||||
Configure your surveys to open and close based on specific criteria. Here’s how to set up these conditions:
|
||||
|
||||
## **Schedule a Survey Release**
|
||||
|
||||
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Release Survey on Date”.
|
||||
|
||||
<MdxImage
|
||||
src={StepOne}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
- **Details**: Choose the date and time when the survey should become available to respondents. All times follow UTC timezone.
|
||||
- **Use Case**: This is useful for launching surveys in alignment with events, product releases, or specific marketing campaigns.
|
||||
|
||||
## **Automatically Closing a Survey**
|
||||
|
||||
- **How to**: Open the Survey Editor, switch to the Settings tab. Scroll down to Response Options, Toggle the “Close survey on date”.
|
||||
|
||||
<MdxImage
|
||||
src={StepThree}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
- **Details**: Define a specific date and time for the survey to close. This also follows UTC timezone. - **Use
|
||||
Case**: Essential for surveys linked to time-bound events or studies where data collection needs to end at a specific
|
||||
point.
|
||||
|
||||
### **Summary**
|
||||
|
||||
Setting up Start & End Dates in Formbricks allows you to control the availability and duration of your surveys with precision. Whether you are conducting academic research, market analysis, or gathering event feedback, these settings help ensure that your data collection aligns perfectly with your objectives.
|
||||
|
||||
---
|
||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -1,3 +1,16 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import EntraIDAppReg01 from "./images/entra_app_reg_01.png";
|
||||
import EntraIDAppReg02 from "./images/entra_app_reg_02.png";
|
||||
import EntraIDAppReg03 from "./images/entra_app_reg_03.png";
|
||||
import EntraIDAppReg04 from "./images/entra_app_reg_04.png";
|
||||
import EntraIDAppReg05 from "./images/entra_app_reg_05.png";
|
||||
import EntraIDAppReg06 from "./images/entra_app_reg_06.png";
|
||||
import EntraIDAppReg07 from "./images/entra_app_reg_07.png";
|
||||
import EntraIDAppReg08 from "./images/entra_app_reg_08.png";
|
||||
import EntraIDAppReg09 from "./images/entra_app_reg_09.png";
|
||||
import EntraIDAppReg10 from "./images/entra_app_reg_10.png";
|
||||
|
||||
export const metadata = {
|
||||
title: "Configure Formbricks with External auth providers",
|
||||
description:
|
||||
@@ -48,8 +61,6 @@ These variables are present inside your machine’s docker-compose file. Restart
|
||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
|
||||
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
|
||||
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
|
||||
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
|
||||
@@ -135,26 +146,141 @@ GOOGLE_CLIENT_SECRET=your-client-secret-here
|
||||
- Navigate to your Docker setup directory where your `docker-compose.yml` file is located.
|
||||
- Run the following command to bring down your current Docker containers and then bring them back up with the updated environment configuration:
|
||||
|
||||
### Azure SSO OAuth
|
||||
### Microsoft Entra ID (Azure Active Directory) SSO OAuth
|
||||
|
||||
Have an Azure Active Directory (AAD) instance? Integrate it with your Formbricks instance to allow users to log in using their existing AAD credentials. This guide will walk you through the process of setting up Azure SSO for your Formbricks instance.
|
||||
Do you have a Microsoft Entra ID Tenant? Integrate it with your Formbricks instance to allow users to log in using their existing Microsoft credentials. This guide will walk you through the process of setting up an Application Registration for your Formbricks instance.
|
||||
|
||||
### Requirements
|
||||
#### Requirements
|
||||
|
||||
- An Azure Active Directory (AAD) instance.
|
||||
- A Microsoft Entra ID Tenant populated with users. [Create a tenant as per Microsoft's documentation](https://learn.microsoft.com/en-us/entra/fundamentals/create-new-tenant).
|
||||
- A Formbricks instance running and accessible.
|
||||
- The callback URI for your Formbricks instance: `{WEBAPP_URL}/api/auth/callback/azure-ad`
|
||||
|
||||
### Steps
|
||||
#### Creating an App Registration
|
||||
|
||||
1. Create a new Tenant in Azure Active Directory as per their [official documentation](https://learn.microsoft.com/en-us/entra/fundamentals/create-new-tenant).
|
||||
2. Add Users & Groups to your AAD instance.
|
||||
3. Now we need to fill the below environment variables in our Formbricks instance so get them from your AD configuration:
|
||||
- `AZUREAD_CLIENT_ID`
|
||||
- `AZUREAD_CLIENT_SECRET`
|
||||
- `AZUREAD_TENANT_ID`
|
||||
4. Update these environment variables in your `docker-compose.yml` or pass it like your other environment variables to the Formbricks container.
|
||||
5. Restart your Formbricks instance.
|
||||
6. You're all set! Users can now signup & log in using their AAD credentials.
|
||||
1. Login to the [Microsoft Entra admin center](https://entra.microsoft.com/).
|
||||
2. Go to **Applications** > **App registrations** in the left menu.
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg01}
|
||||
alt="App Registration Name Field"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
3. Click the **New registration** button at the top.
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg02}
|
||||
alt="App Registration Name Field"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
4. Name your application something descriptive, such as `Formbricks SSO`.
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg03}
|
||||
alt="App Registration Name Field"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
5. If you have multiple tenants/organizations, choose the appropriate **Supported account types** option. Otherwise, leave the default option for _Single Tenant_.
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg04}
|
||||
alt="Supported Account Types List"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
6. Under **Redirect URI**, select **Web** for the platform and paste your Formbricks callback URI (see Requirements above).
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg05}
|
||||
alt="Redirect URI Field"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
7. Click **Register** to create the App registration. You will be redirected to your new app's _Overview_ page after it is created.
|
||||
|
||||
8. On the _Overview_ page, under **Essentials**:
|
||||
- Copy the entry for **Application (client) ID** to populate the `AZUREAD_CLIENT_ID` variable.
|
||||
- Copy the entry for **Directory (tenant) ID** to populate the `AZUREAD_TENANT_ID` variable.
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg06}
|
||||
alt="Client and Tenant ID Fields"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
9. From your App registration's _Overview_ page, go to **Manage** > **Certificates & secrets**.
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg07}
|
||||
alt="Certificates & secrets link"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
10. Make sure you have the **Client secrets** tab active, and click **New client secret**.
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg08}
|
||||
alt="New Client Secret Tab & Button"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
11. Enter a **Description**, set an **Expires** period, then click **Add**.
|
||||
|
||||
<Note>
|
||||
You will need to create a new client secret using these steps whenever your chosen expiry period ends.
|
||||
</Note>
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg09}
|
||||
alt="Description & Expires Fields"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
12. Copy the entry under **Value** to populate the `AZUREAD_CLIENT_SECRET` variable.
|
||||
|
||||
<Note>
|
||||
Microsoft will only show this value to you immediately after creation, and you will not be able to access it again. If you lose it, simply start from step 9 to create a new secret.
|
||||
</Note>
|
||||
|
||||
<MdxImage
|
||||
src={EntraIDAppReg10}
|
||||
alt="Client Secret Value Field"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
13. Update these environment variables in your `docker-compose.yml` or pass it like your other environment variables to the Formbricks container.
|
||||
|
||||
<Note>
|
||||
You must wrap the `AZUREAD_CLIENT_SECRET` value in double quotes (e.g., `"THis~iS4faKe.53CreTvALu3"`) to prevent issues with special characters.
|
||||
</Note>
|
||||
|
||||
An example `.env` for Microsoft Entra ID in Formbricks would look like:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Formbricks Env for Microsoft Entra ID SSO">
|
||||
```yml {{ title: ".env" }}
|
||||
AZUREAD_CLIENT_ID=a25cadbd-f049-4690-ada3-56a163a72f4c
|
||||
AZUREAD_TENANT_ID=2746c29a-a3a6-4ea1-8762-37816d4b7885
|
||||
AZUREAD_CLIENT_SECRET="THis~iS4faKe.53CreTvALu3"
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
14. Restart your Formbricks instance.
|
||||
15. You're all set! Users can now sign up & log in using their Microsoft credentials associated with your Entra ID Tenant.
|
||||
|
||||
## OpenID Configuration
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export const metadata = {
|
||||
|
||||
## Overview
|
||||
|
||||
The Formbricks Core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises for self-hostesr under a separate Enterprise License.
|
||||
The Formbricks Core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises for self-hosters under a separate Enterprise License.
|
||||
|
||||
<Note>
|
||||
Want to present a proof of concept? Request a free 30-day Enterprise Edition trial by [filling out the form
|
||||
|
||||
@@ -191,12 +191,12 @@ docker compose up -d
|
||||
<CodeGroup title="Migrate the data">
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/formbricks/data-migrations:latest && \
|
||||
docker pull ghcr.io/formbricks/data-migrations:v2.3.0 && \
|
||||
docker run --rm \
|
||||
--network=formbricks_default \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
|
||||
-e UPGRADE_TO_VERSION="v2.3" \
|
||||
ghcr.io/formbricks/data-migrations:latest
|
||||
ghcr.io/formbricks/data-migrations:v2.3.0
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -286,12 +286,12 @@ docker compose up -d
|
||||
<CodeGroup title="Migrate the data">
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/formbricks/data-migrations:latest && \
|
||||
docker pull ghcr.io/formbricks/data-migrations:v2.2 && \
|
||||
docker run --rm \
|
||||
--network=formbricks_default \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
|
||||
-e UPGRADE_TO_VERSION="v2.2" \
|
||||
ghcr.io/formbricks/data-migrations:latest
|
||||
ghcr.io/formbricks/data-migrations:v2.2
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -390,12 +390,12 @@ docker compose up -d
|
||||
<CodeGroup title="Migrate the data">
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/formbricks/data-migrations:latest && \
|
||||
docker pull ghcr.io/formbricks/data-migrations:v2.1.0 && \
|
||||
docker run --rm \
|
||||
--network=formbricks_default \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
|
||||
-e UPGRADE_TO_VERSION="v2.1" \
|
||||
ghcr.io/formbricks/data-migrations:latest
|
||||
ghcr.io/formbricks/data-migrations:v2.1.0
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -504,12 +504,12 @@ docker compose up -d
|
||||
<CodeGroup title="Migrate the data">
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/formbricks/data-migrations:latest && \
|
||||
docker pull ghcr.io/formbricks/data-migrations:v2.0.3 && \
|
||||
docker run --rm \
|
||||
--network=formbricks_default \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
|
||||
-e UPGRADE_TO_VERSION="v2.0" \
|
||||
ghcr.io/formbricks/data-migrations:latest
|
||||
ghcr.io/formbricks/data-migrations:v2.0.3
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
@@ -50,9 +50,10 @@ The script will prompt you for the following information:
|
||||
<CodeGroup title="Docker GPG Keys Overwrite Prompt">
|
||||
|
||||
```bash
|
||||
🧱 Welcome to the Formbricks single instance installer
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
@@ -64,48 +65,21 @@ File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N)
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
2. **Email Address**: Provide your email address for SSL certificate registration with Let's Encrypt.
|
||||
2. **Domain Name**: You will be asked to enter the domain name where you want to host Formbricks. This domain will be used to generate an SSL certificate.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Email Prompt">
|
||||
|
||||
```bash
|
||||
🧱 Welcome to the Formbricks single instance installer
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
🚀 Testing your Docker installation.
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. Youre now ready to run your Formbricks instance!
|
||||
🚗 Installing Traefik...
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
💡 Please enter your email address for the SSL certificate:
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks. Please make sure that port 80 and 443 are open in your VM's Security Group to allow Traefik to create the SSL certificate.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Domain Name for SSL certificate Prompt">
|
||||
|
||||
```bash
|
||||
🧱 Welcome to the Formbricks single instance installer
|
||||
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Dockers official GPG key and setting up the stable repository.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
@@ -113,13 +87,186 @@ File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
|
||||
🚗 Installing Traefik...
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
3. **HTTPS Certificate Prompt**: The script will ask if you want to create an HTTPS certificate for your domain. Enter Y to proceed. This is highly recommended for secure access to your Formbricks instance.
|
||||
|
||||
<Col>
|
||||
<CodeGroup>
|
||||
|
||||
```bash
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
🚀 Testing your Docker installation.
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
my.hosted.url.com
|
||||
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
4. **DNS Setup Prompt**: Ensure that your domain's DNS is correctly configured and ports 80 and 443 are open. Confirm this by entering Y. This step is crucial for proper SSL certificate issuance and secure server access.
|
||||
|
||||
<Col>
|
||||
<CodeGroup>
|
||||
|
||||
```bash
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
🚀 Testing your Docker installation.
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
my.hosted.url.com
|
||||
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
|
||||
Y
|
||||
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
5. **Email Address**: Provide an email address for SSL certificate registration. This email will be used for notifications regarding your SSL certificate from Let's Encrypt.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Email Prompt">
|
||||
|
||||
```bash
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
🚀 Testing your Docker installation.
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
my.hosted.url.com
|
||||
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
|
||||
Y
|
||||
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
|
||||
Y
|
||||
💡 Please enter your email address for the SSL certificate:
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
6. **Enforce HTTPS (HSTS) Prompt**: Enforcing HTTPS with HSTS is a good security practice, as it ensures all communication with your server is encrypted. Enter Y to enable this setting.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Domain Name for SSL certificate Prompt">
|
||||
|
||||
```bash
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
🚀 Testing your Docker installation.
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
my.hosted.url.com
|
||||
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
|
||||
Y
|
||||
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
|
||||
Y
|
||||
💡 Please enter your email address for the SSL certificate:
|
||||
docs@formbricks.com
|
||||
💡 Created traefik.yaml file with your provided email address.
|
||||
💡 Created acme.json file with correct permissions.
|
||||
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
7. **Email Service Setup Prompt**: The script will ask if you want to set up the email service. Enter `Y` to proceed.(default is `N`). You can skip this step if you don't want to set up the email service. You will still be able to use Formbricks without setting up the email service.
|
||||
|
||||
<Col>
|
||||
<CodeGroup>
|
||||
|
||||
```bash
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
🚀 Testing your Docker installation.
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
my.hosted.url.com
|
||||
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
|
||||
Y
|
||||
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
|
||||
Y
|
||||
💡 Please enter your email address for the SSL certificate:
|
||||
docs@formbricks.com
|
||||
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
|
||||
Y
|
||||
🚗 Configuring Traefik...
|
||||
💡 Created traefik.yaml and traefik-dynamic.yaml file.
|
||||
💡 Created acme.json file with correct permissions.
|
||||
📧 Do you want to set up the email service? You will need SMTP credentials for the same! [y/N]
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -131,39 +278,56 @@ docs@formbricks.com
|
||||
<CodeGroup title="Successfully setup Formbricks on your Ubuntu machine">
|
||||
|
||||
```bash
|
||||
🧱 Welcome to the Formbricks single instance installer
|
||||
🚀 Executing default step of installing Formbricks
|
||||
🧱 Welcome to the Formbricks Setup Script
|
||||
|
||||
🛸 Fasten your seatbelts! Were setting up your Formbricks environment on your Ubuntu 22.04.2 LTS server.
|
||||
🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your Ubuntu 24.04 LTS server.
|
||||
|
||||
🧹 Time to sweep away any old Docker installations.
|
||||
🔄 Updating your package list.
|
||||
📦 Installing the necessary dependencies.
|
||||
🔑 Adding Dockers official GPG key and setting up the stable repository.
|
||||
🔑 Adding Docker's official GPG key and setting up the stable repository.
|
||||
File '/etc/apt/keyrings/docker.gpg' exists. Overwrite? (y/N) y
|
||||
🔄 Updating your package list again.
|
||||
🐳 Installing Docker.
|
||||
🚀 Testing your Docker installation.
|
||||
🎉 Docker is installed!
|
||||
🐳 Adding your user to the Docker group to avoid using sudo with docker commands.
|
||||
🎉 Hooray! Docker is all set and ready to go. Youre now ready to run your Formbricks instance!
|
||||
🚗 Installing Traefik...
|
||||
🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!
|
||||
📁 Created Formbricks Quickstart directory at ./formbricks.
|
||||
💡 Please enter your email address for the SSL certificate:
|
||||
docs@formbricks.com
|
||||
💡 Created traefik.yaml file with your provided email address.
|
||||
💡 Created acme.json file with correct permissions.
|
||||
🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):
|
||||
my.hosted.url.com
|
||||
🚙 Updating NEXTAUTH_SECRET in the Formbricks container...
|
||||
🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]
|
||||
Y
|
||||
🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]
|
||||
Y
|
||||
💡 Please enter your email address for the SSL certificate:
|
||||
docs@formbricks.com
|
||||
🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]
|
||||
Y
|
||||
🚗 Configuring Traefik...
|
||||
💡 Created traefik.yaml and traefik-dynamic.yaml file.
|
||||
💡 Created acme.json file with correct permissions.
|
||||
📧 Do you want to set up the email service? You will need SMTP credentials for the same! [y/N] N
|
||||
📥 Downloading docker-compose.yml from Formbricks GitHub repository...
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
Dload Upload Total Spent Left Speed
|
||||
100 6632 100 6632 0 0 24280 0 --:--:-- --:--:-- --:--:-- 24382
|
||||
🚙 Updating docker-compose.yml with your custom inputs...
|
||||
🚗 NEXTAUTH_SECRET updated successfully!
|
||||
🚗 ENCRYPTION_KEY updated successfully!
|
||||
🚗 CRON_SECRET updated successfully!
|
||||
|
||||
[+] Running 4/4
|
||||
✔ Network formbricks_default Created 0.1s
|
||||
✔ Container formbricks-postgres-1 Started 0.5s
|
||||
✔ Container formbricks-formbricks-1 Started 0.7s
|
||||
✔ Container traefik Started 1.1s
|
||||
✔ Network formbricks_default Created 0.2s
|
||||
✔ Container formbricks-postgres-1 Started 1.0s
|
||||
✔ Container formbricks-formbricks-1 Started 1.6s
|
||||
✔ Container traefik Started 2.8s
|
||||
🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!
|
||||
🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance.
|
||||
|
||||
🎉 All done! Check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'
|
||||
🎉 All done! Please setup your Formbricks instance by visiting your domain at https://my.hosted.url.com. You can check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
@@ -15,6 +15,7 @@ const TopLevelNavItem = ({ href, children }: { href: string; children: React.Rea
|
||||
<li>
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
className="text-sm leading-5 text-slate-600 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -171,6 +171,13 @@ const NavigationGroup = ({
|
||||
|
||||
const isParentOpen = (title: string) => openGroups.includes(title);
|
||||
|
||||
const sortedLinks = group.links.map((link) => {
|
||||
if (link.children) {
|
||||
link.children.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return link;
|
||||
});
|
||||
|
||||
return (
|
||||
<li className={clsx("relative mt-6", className)}>
|
||||
<motion.h2 layout="position" className="font-semibold text-slate-900 dark:text-white">
|
||||
@@ -185,7 +192,7 @@ const NavigationGroup = ({
|
||||
{isActiveGroup && <ActivePageMarker group={group} pathname={pathname || "/docs"} />}
|
||||
</AnimatePresence>
|
||||
<ul role="list" className="border-l border-transparent">
|
||||
{group.links.map((link) => (
|
||||
{sortedLinks.map((link) => (
|
||||
<motion.li key={link.title} layout="position" className="relative">
|
||||
{link.href ? (
|
||||
<NavLink
|
||||
|
||||
@@ -40,7 +40,8 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "User Metadata", href: "/global/metadata" }, // global
|
||||
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
|
||||
{ title: "Conditional Logic", href: "/global/conditional-logic" }, // global
|
||||
{ title: "Custom Start & End Conditions", href: "/global/custom-start-end-conditions" }, // global
|
||||
{ title: "Start & End Dates", href: "/global/custom-start-end-conditions" }, // global
|
||||
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
|
||||
{ title: "Recall Functionality", href: "/global/recall" }, // global
|
||||
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
|
||||
],
|
||||
@@ -63,7 +64,8 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "User Metadata", href: "/global/metadata" }, // global
|
||||
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
|
||||
{ title: "Conditional Logic", href: "/global/conditional-logic" }, // global
|
||||
{ title: "Custom Start & End Conditions", href: "/global/custom-start-end-conditions" }, // global
|
||||
{ title: "Start & End Dates", href: "/global/custom-start-end-conditions" }, // global
|
||||
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
|
||||
{ title: "Recall Functionality", href: "/global/recall" }, // global
|
||||
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
|
||||
],
|
||||
@@ -89,7 +91,8 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "User Metadata", href: "/global/metadata" },
|
||||
{ title: "Custom Styling", href: "/global/overwrite-styling" }, // global
|
||||
{ title: "Conditional Logic", href: "/global/conditional-logic" },
|
||||
{ title: "Custom Start & End Conditions", href: "/global/custom-start-end-conditions" },
|
||||
{ title: "Start & End Dates", href: "/global/custom-start-end-conditions" },
|
||||
{ title: "Limit submissions", href: "/global/limit-submissions" }, // global
|
||||
{ title: "Recall Functionality", href: "/global/recall" },
|
||||
{ title: "Verify Email before Survey", href: "/link-surveys/verify-email-before-survey" },
|
||||
{ title: "PIN Protected Surveys", href: "/link-surveys/pin-protected-surveys" },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import nextMDX from "@next/mdx";
|
||||
|
||||
import { recmaPlugins } from "./mdx/recma.mjs";
|
||||
import { rehypePlugins } from "./mdx/rehype.mjs";
|
||||
import { remarkPlugins } from "./mdx/remark.mjs";
|
||||
@@ -105,17 +104,18 @@ const nextConfig = {
|
||||
destination: "/app-surveys/user-identification",
|
||||
permanent: true,
|
||||
},
|
||||
// Global Features
|
||||
{
|
||||
source: "/global/custom-start-end-conditions",
|
||||
destination: "/global/schedule-start-end-dates",
|
||||
permanent: true,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
source: "/integrations/:path",
|
||||
destination: "/developer-docs/integrations/:path",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/global/custom-styling",
|
||||
destination: "/global/overwrite-styling",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,23 +19,23 @@
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.6.1",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-a11y": "^8.2.7",
|
||||
"@storybook/addon-essentials": "^8.2.7",
|
||||
"@storybook/addon-interactions": "^8.2.7",
|
||||
"@storybook/addon-links": "^8.2.7",
|
||||
"@storybook/addon-onboarding": "^8.2.7",
|
||||
"@storybook/blocks": "^8.2.7",
|
||||
"@storybook/react": "^8.2.7",
|
||||
"@storybook/react-vite": "^8.2.7",
|
||||
"@storybook/test": "^8.2.7",
|
||||
"@storybook/addon-a11y": "^8.2.9",
|
||||
"@storybook/addon-essentials": "^8.2.9",
|
||||
"@storybook/addon-interactions": "^8.2.9",
|
||||
"@storybook/addon-links": "^8.2.9",
|
||||
"@storybook/addon-onboarding": "^8.2.9",
|
||||
"@storybook/blocks": "^8.2.9",
|
||||
"@storybook/react": "^8.2.9",
|
||||
"@storybook/react-vite": "^8.2.9",
|
||||
"@storybook/test": "^8.2.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"esbuild": "^0.23.0",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"storybook": "^8.2.7",
|
||||
"tsup": "^8.2.3",
|
||||
"vite": "^5.3.5"
|
||||
"storybook": "^8.2.9",
|
||||
"tsup": "^8.2.4",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ RUN PRISMA_VERSION=$(cat prisma_version.txt) && npm install -g prisma@$PRISMA_VE
|
||||
|
||||
EXPOSE 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
USER nextjs
|
||||
# USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
|
||||
@@ -82,7 +82,6 @@ export const ConnectWithFormbricks = ({
|
||||
</div>
|
||||
<Button
|
||||
id="finishOnboarding"
|
||||
className="text-slate-400 hover:text-slate-700"
|
||||
variant={widgetSetupCompleted ? "primary" : "minimal"}
|
||||
onClick={handleFinishOnboarding}
|
||||
EndIcon={ArrowRight}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { CircleUserRoundIcon, EarthIcon, LinkIcon, XIcon } from "lucide-react";
|
||||
import { GlobeIcon, GlobeLockIcon, LinkIcon, XIcon } from "lucide-react";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
@@ -15,14 +15,14 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
{
|
||||
title: "Public website",
|
||||
description: "Run well-timed pop-up surveys.",
|
||||
icon: EarthIcon,
|
||||
icon: GlobeIcon,
|
||||
iconText: "Built for scale",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
|
||||
},
|
||||
{
|
||||
title: "App with sign up",
|
||||
description: "Run highly-targeted micro-surveys.",
|
||||
icon: CircleUserRoundIcon,
|
||||
icon: GlobeLockIcon,
|
||||
iconText: "Enrich user profiles",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
@@ -258,18 +258,19 @@ export const QuestionsView = ({
|
||||
toast.success("Question duplicated.");
|
||||
};
|
||||
|
||||
const addQuestion = (question: any, index?: number) => {
|
||||
const addQuestion = (question: TSurveyQuestion, index?: number) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
if (backButtonLabel) {
|
||||
question.backButtonLabel = backButtonLabel;
|
||||
}
|
||||
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
const translatedQuestion = translateQuestion(question, languageSymbols);
|
||||
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
|
||||
|
||||
if (index) {
|
||||
updatedSurvey.questions.splice(index, 0, { ...translatedQuestion, isDraft: true });
|
||||
updatedSurvey.questions.splice(index, 0, { ...updatedQuestion, isDraft: true });
|
||||
} else {
|
||||
updatedSurvey.questions.push({ ...translatedQuestion, isDraft: true });
|
||||
updatedSurvey.questions.push({ ...updatedQuestion, isDraft: true });
|
||||
}
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
|
||||
@@ -44,7 +44,6 @@ const Page = async ({ params }) => {
|
||||
getServerSession(authOptions),
|
||||
getSegments(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BlendIcon,
|
||||
BlocksIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
GlobeLockIcon,
|
||||
KeyIcon,
|
||||
LinkIcon,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MousePointerClick,
|
||||
@@ -27,7 +31,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { capitalizeFirstLetter, truncate } from "@formbricks/lib/utils/strings";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -115,7 +119,24 @@ export const MainNavigation = ({
|
||||
}, [organizations]);
|
||||
|
||||
const sortedProducts = useMemo(() => {
|
||||
return [...products].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const channelOrder: (string | null)[] = ["website", "app", "link", null];
|
||||
|
||||
const groupedProducts = products.reduce(
|
||||
(acc, product) => {
|
||||
const channel = product.config.channel;
|
||||
const key = channel !== null ? channel : "null";
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(product);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof products>
|
||||
);
|
||||
|
||||
Object.keys(groupedProducts).forEach((channel) => {
|
||||
groupedProducts[channel].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
return channelOrder.flatMap((channel) => groupedProducts[channel !== null ? channel : "null"] || []);
|
||||
}, [products]);
|
||||
|
||||
const handleEnvironmentChangeByProduct = (productId: string) => {
|
||||
@@ -277,18 +298,27 @@ export const MainNavigation = ({
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center space-x-5",
|
||||
"flex cursor-pointer flex-row items-center space-x-3",
|
||||
isCollapsed ? "pl-2" : "pl-4"
|
||||
)}>
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-1.5 font-bold text-slate-50">
|
||||
XM
|
||||
<div className="rounded-lg bg-slate-900 p-1.5 text-slate-50">
|
||||
{product.config.channel === "website" ? (
|
||||
<GlobeIcon strokeWidth={1.5} />
|
||||
) : product.config.channel === "app" ? (
|
||||
<GlobeLockIcon strokeWidth={1.5} />
|
||||
) : product.config.channel === "link" ? (
|
||||
<LinkIcon strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div>
|
||||
<p
|
||||
title={product.name}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700 transition-opacity duration-200",
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{product.name}
|
||||
@@ -298,7 +328,9 @@ export const MainNavigation = ({
|
||||
"text-sm text-slate-500 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
Product
|
||||
{product.config.channel === "link"
|
||||
? "Link & Email"
|
||||
: capitalizeFirstLetter(product.config.channel)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
@@ -312,7 +344,7 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56 space-y-1 rounded-xl border border-slate-200 shadow-sm"
|
||||
className="w-fit space-y-1 rounded-xl border border-slate-200 shadow-sm"
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
@@ -324,15 +356,28 @@ export const MainNavigation = ({
|
||||
{sortedProducts.map((product) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={product.id}
|
||||
className="cursor-pointer break-all rounded-lg"
|
||||
className="cursor-pointer break-all rounded-lg font-normal"
|
||||
key={product.id}>
|
||||
{product?.name}
|
||||
<div>
|
||||
{product.config.channel === "website" ? (
|
||||
<GlobeIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : product.config.channel === "app" ? (
|
||||
<GlobeLockIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : product.config.channel === "link" ? (
|
||||
<LinkIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
<div className="">{product?.name}</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{isOwnerOrAdmin && (
|
||||
<DropdownMenuItem onClick={() => handleAddProduct(organization.id)} className="rounded-lg">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAddProduct(organization.id)}
|
||||
className="rounded-lg font-normal">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<span>Add product</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -350,7 +395,7 @@ export const MainNavigation = ({
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center space-x-5",
|
||||
"flex cursor-pointer flex-row items-center space-x-3",
|
||||
isCollapsed ? "pl-2" : "pl-4"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
@@ -358,16 +403,15 @@ export const MainNavigation = ({
|
||||
<>
|
||||
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700"
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? (
|
||||
<span>{truncate(user?.name, 30)}</span>
|
||||
) : (
|
||||
<span>{truncate(user?.email, 30)}</span>
|
||||
)}
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p className={cn("text-sm text-slate-500")}>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="max-w-28 truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
revalidateSurveyIdPath,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
|
||||
|
||||
interface SurveyAnalysisNavigationProps {
|
||||
environmentId: string;
|
||||
surveyId: string;
|
||||
responseCount: number | null;
|
||||
survey: TSurvey;
|
||||
initialTotalResponseCount: number | null;
|
||||
activeId: string;
|
||||
}
|
||||
|
||||
export const SurveyAnalysisNavigation = ({
|
||||
environmentId,
|
||||
surveyId,
|
||||
responseCount,
|
||||
survey,
|
||||
initialTotalResponseCount,
|
||||
activeId,
|
||||
}: SurveyAnalysisNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const [filteredResponseCount, setFilteredResponseCount] = useState<number | null>(null);
|
||||
const [totalResponseCount, setTotalResponseCount] = useState<number | null>(initialTotalResponseCount);
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${surveyId}`;
|
||||
const searchParams = useSearchParams();
|
||||
const isShareEmbedModalOpen = searchParams.get("share") === "true";
|
||||
|
||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
const { selectedFilter, dateRange } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[selectedFilter, dateRange]
|
||||
);
|
||||
|
||||
const latestFiltersRef = useRef(filters);
|
||||
latestFiltersRef.current = filters;
|
||||
|
||||
const getResponseCount = () => {
|
||||
if (isSharingPage) return getResponseCountBySurveySharingKeyAction(sharingKey);
|
||||
return getResponseCountAction(survey.id);
|
||||
};
|
||||
|
||||
const fetchResponseCount = async () => {
|
||||
const count = await getResponseCount();
|
||||
setTotalResponseCount(count);
|
||||
};
|
||||
|
||||
const getFilteredResponseCount = () => {
|
||||
if (isSharingPage) return getResponseCountBySurveySharingKeyAction(sharingKey, latestFiltersRef.current);
|
||||
return getResponseCountAction(survey.id, latestFiltersRef.current);
|
||||
};
|
||||
|
||||
const fetchFilteredResponseCount = async () => {
|
||||
const count = await getFilteredResponseCount();
|
||||
setFilteredResponseCount(count);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilteredResponseCount();
|
||||
}, [filters, isSharingPage, sharingKey, survey.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShareEmbedModalOpen) {
|
||||
const interval = setInterval(() => {
|
||||
fetchResponseCount();
|
||||
fetchFilteredResponseCount();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isShareEmbedModalOpen]);
|
||||
|
||||
const getResponseCountString = () => {
|
||||
if (totalResponseCount === null) return "";
|
||||
if (filteredResponseCount === null) return `(${totalResponseCount})`;
|
||||
|
||||
if (totalResponseCount === filteredResponseCount) return `(${totalResponseCount})`;
|
||||
|
||||
return `(${filteredResponseCount} of ${totalResponseCount})`;
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
@@ -33,17 +99,17 @@ export const SurveyAnalysisNavigation = ({
|
||||
href: `${url}/summary?referer=true`,
|
||||
current: pathname?.includes("/summary"),
|
||||
onClick: () => {
|
||||
revalidateSurveyIdPath(environmentId, surveyId);
|
||||
revalidateSurveyIdPath(environmentId, survey.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: `Responses ${responseCount !== null ? `(${responseCount})` : ""}`,
|
||||
label: `Responses ${getResponseCountString()}`,
|
||||
icon: <InboxIcon className="h-5 w-5" />,
|
||||
href: `${url}/responses?referer=true`,
|
||||
current: pathname?.includes("/responses"),
|
||||
onClick: () => {
|
||||
revalidateSurveyIdPath(environmentId, surveyId);
|
||||
revalidateSurveyIdPath(environmentId, survey.id);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -157,7 +157,7 @@ export const ResponsePage = ({
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={survey} />
|
||||
{!isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />}
|
||||
{!isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} />}
|
||||
</div>
|
||||
<ResponseTimeline
|
||||
environment={environment}
|
||||
|
||||
@@ -68,9 +68,9 @@ const Page = async ({ params }) => {
|
||||
}>
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
responseCount={totalResponseCount}
|
||||
surveyId={survey.id}
|
||||
survey={survey}
|
||||
activeId="responses"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryConsent } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
@@ -8,9 +13,33 @@ interface ConsentSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryConsent;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const ConsentSummary = ({ questionSummary, survey, attributeClasses }: ConsentSummaryProps) => {
|
||||
export const ConsentSummary = ({
|
||||
questionSummary,
|
||||
survey,
|
||||
attributeClasses,
|
||||
setFilter,
|
||||
}: ConsentSummaryProps) => {
|
||||
const summaryItems = [
|
||||
{
|
||||
title: "Accepted",
|
||||
percentage: questionSummary.accepted.percentage,
|
||||
count: questionSummary.accepted.count,
|
||||
},
|
||||
{
|
||||
title: "Dismissed",
|
||||
percentage: questionSummary.dismissed.percentage,
|
||||
count: questionSummary.dismissed.count,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
@@ -19,40 +48,41 @@ export const ConsentSummary = ({ questionSummary, survey, attributeClasses }: Co
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold text-slate-700">Accepted</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.accepted.percentage, 1)}%
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<div
|
||||
className="group cursor-pointer"
|
||||
key={summaryItem.title}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
"is",
|
||||
summaryItem.title
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{summaryItem.title}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(summaryItem.percentage, 1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{summaryItem.count} {summaryItem.count === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary.accepted.count}{" "}
|
||||
{questionSummary.accepted.count === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.accepted.percentage / 100} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold text-slate-700">Dismissed</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.dismissed.percentage, 1)}%
|
||||
</p>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={summaryItem.percentage / 100} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary.dismissed.count}{" "}
|
||||
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.dismissed.percentage / 100} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryMatrix } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionSummaryMatrix,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TooltipRenderer } from "@formbricks/ui/Tooltip";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
@@ -7,12 +12,20 @@ interface MatrixQuestionSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryMatrix;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const MatrixQuestionSummary = ({
|
||||
questionSummary,
|
||||
survey,
|
||||
attributeClasses,
|
||||
setFilter,
|
||||
}: MatrixQuestionSummaryProps) => {
|
||||
const getOpacityLevel = (percentage: number): string => {
|
||||
const parsedPercentage = percentage;
|
||||
@@ -74,7 +87,16 @@ export const MatrixQuestionSummary = ({
|
||||
)}>
|
||||
<div
|
||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-default items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline">
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
rowLabel,
|
||||
column
|
||||
)
|
||||
}>
|
||||
{percentage}
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -2,7 +2,13 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionSummaryMultipleChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyType,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||
@@ -15,6 +21,13 @@ interface MultipleChoiceSummaryProps {
|
||||
surveyType: TSurveyType;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const MultipleChoiceSummary = ({
|
||||
@@ -23,6 +36,7 @@ export const MultipleChoiceSummary = ({
|
||||
surveyType,
|
||||
survey,
|
||||
attributeClasses,
|
||||
setFilter,
|
||||
}: MultipleChoiceSummaryProps) => {
|
||||
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
|
||||
|
||||
@@ -55,10 +69,21 @@ export const MultipleChoiceSummary = ({
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<div key={result.value}>
|
||||
<div
|
||||
key={result.value}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" ? "Includes either" : "Includes all",
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
<div>
|
||||
@@ -71,7 +96,9 @@ export const MultipleChoiceSummary = ({
|
||||
{result.count} {result.count === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryNps } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionSummaryNps,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
@@ -8,9 +13,49 @@ interface NPSSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryNps;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const NPSSummary = ({ questionSummary, survey, attributeClasses }: NPSSummaryProps) => {
|
||||
export const NPSSummary = ({ questionSummary, survey, attributeClasses, setFilter }: NPSSummaryProps) => {
|
||||
const applyFilter = (group: string) => {
|
||||
const filters = {
|
||||
promoters: {
|
||||
comparison: "Includes either",
|
||||
values: ["9", "10"],
|
||||
},
|
||||
passives: {
|
||||
comparison: "Includes either",
|
||||
values: ["7", "8"],
|
||||
},
|
||||
detractors: {
|
||||
comparison: "Is less than",
|
||||
values: "7",
|
||||
},
|
||||
dismissed: {
|
||||
comparison: "Skipped",
|
||||
values: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const filter = filters[group];
|
||||
|
||||
if (filter) {
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
filter.comparison,
|
||||
filter.values
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
@@ -19,46 +64,34 @@ export const NPSSummary = ({ questionSummary, survey, attributeClasses }: NPSSum
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors"].map((group) => (
|
||||
<div key={group}>
|
||||
<div className="mb-2 flex justify-between">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold capitalize text-slate-700">{group}</p>
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group].percentage, 1)}%
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary[group].count} {questionSummary[group].count === 1 ? "response" : "responses"}
|
||||
{questionSummary[group]?.count}{" "}
|
||||
{questionSummary[group]?.count === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={questionSummary[group].percentage / 100} />
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{questionSummary.dismissed?.count > 0 && (
|
||||
<div className="border-t bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div key={"dismissed"}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold text-slate-700">dismissed</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary.dismissed.percentage, 1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary.dismissed.count}{" "}
|
||||
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-slate-600" progress={questionSummary.dismissed.percentage / 100} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryPictureSelection } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionSummaryPictureSelection,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
@@ -9,12 +14,20 @@ interface PictureChoiceSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryPictureSelection;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const PictureChoiceSummary = ({
|
||||
questionSummary,
|
||||
survey,
|
||||
attributeClasses,
|
||||
setFilter,
|
||||
}: PictureChoiceSummaryProps) => {
|
||||
const results = questionSummary.choices;
|
||||
|
||||
@@ -26,8 +39,19 @@ export const PictureChoiceSummary = ({
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result) => (
|
||||
<div key={result.id}>
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
key={result.id}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
"Includes all",
|
||||
[`Picture ${index + 1}`]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<div className="relative h-32 w-[220px]">
|
||||
|
||||
@@ -2,7 +2,12 @@ import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryRating } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionSummaryRating,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||
import { RatingResponse } from "@formbricks/ui/RatingResponse";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
@@ -11,9 +16,21 @@ interface RatingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRating;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
setFilter: (
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const RatingSummary = ({ questionSummary, survey, attributeClasses }: RatingSummaryProps) => {
|
||||
export const RatingSummary = ({
|
||||
questionSummary,
|
||||
survey,
|
||||
attributeClasses,
|
||||
setFilter,
|
||||
}: RatingSummaryProps) => {
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = questionSummary.question.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
@@ -36,7 +53,18 @@ export const RatingSummary = ({ questionSummary, survey, attributeClasses }: Rat
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<div key={result.rating}>
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
key={result.rating}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
"Is equal to",
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
|
||||
@@ -5,20 +5,15 @@ import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Confetti } from "@formbricks/ui/Confetti";
|
||||
import { ShareEmbedSurvey } from "./ShareEmbedSurvey";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
webAppUrl: string;
|
||||
user: TUser;
|
||||
}
|
||||
|
||||
export const SuccessMessage = ({ environment, survey, webAppUrl, user }: SummaryMetadataProps) => {
|
||||
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
|
||||
const isAppSurvey = survey.type === "app" || survey.type === "website";
|
||||
@@ -39,26 +34,18 @@ export const SuccessMessage = ({ environment, survey, webAppUrl, user }: Summary
|
||||
position: "bottom-right",
|
||||
}
|
||||
);
|
||||
if (survey.type === "link") {
|
||||
setShowLinkModal(true);
|
||||
}
|
||||
|
||||
// Remove success param from url
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
if (survey.type === "link") {
|
||||
// Add share param to url to open share embed modal
|
||||
url.searchParams.set("share", "true");
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
}, [environment, isAppSurvey, searchParams, survey, widgetSetupCompleted]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
webAppUrl={webAppUrl}
|
||||
user={user}
|
||||
/>
|
||||
{confetti && <Confetti />}
|
||||
</>
|
||||
);
|
||||
return <>{confetti && <Confetti />}</>;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
||||
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
||||
@@ -11,9 +17,13 @@ import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[su
|
||||
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
|
||||
import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary";
|
||||
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
||||
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
|
||||
@@ -25,7 +35,6 @@ interface SummaryListProps {
|
||||
responseCount: number | null;
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
fetchingSummary: boolean;
|
||||
totalResponseCount: number;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
@@ -35,20 +44,72 @@ export const SummaryList = ({
|
||||
environment,
|
||||
responseCount,
|
||||
survey,
|
||||
fetchingSummary,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
}: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const widgetSetupCompleted =
|
||||
survey.type === "app" ? environment.appSetupCompleted : environment.websiteSetupCompleted;
|
||||
|
||||
const setFilter = (
|
||||
questionId: string,
|
||||
label: TI18nString,
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||
const value = {
|
||||
id: questionId,
|
||||
label: getLocalizedValue(label, "default"),
|
||||
questionType: questionType,
|
||||
type: OptionsType.QUESTIONS,
|
||||
};
|
||||
|
||||
// Find the index of the existing filter with the same questionId
|
||||
const existingFilterIndex = filterObject.filter.findIndex(
|
||||
(filter) => filter.questionType.id === questionId
|
||||
);
|
||||
|
||||
if (existingFilterIndex !== -1) {
|
||||
// Replace the existing filter
|
||||
filterObject.filter[existingFilterIndex] = {
|
||||
questionType: value,
|
||||
filterType: {
|
||||
filterComboBoxValue: filterComboBoxValue,
|
||||
filterValue: filterValue,
|
||||
},
|
||||
};
|
||||
toast.success("Filter updated successfully", { duration: 5000 });
|
||||
} else {
|
||||
// Add new filter
|
||||
filterObject.filter.push({
|
||||
questionType: value,
|
||||
filterType: {
|
||||
filterComboBoxValue: filterComboBoxValue,
|
||||
filterValue: filterValue,
|
||||
},
|
||||
});
|
||||
toast.success(
|
||||
constructToastMessage(questionType, filterValue, survey, questionId, filterComboBoxValue) ??
|
||||
"Filter added successfully",
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedFilter({
|
||||
filter: [...filterObject.filter],
|
||||
onlyComplete: filterObject.onlyComplete,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-10 space-y-8">
|
||||
{(survey.type === "app" || survey.type === "website") &&
|
||||
responseCount === 0 &&
|
||||
!widgetSetupCompleted ? (
|
||||
<EmptyAppSurveys environment={environment} surveyType={survey.type} />
|
||||
) : fetchingSummary ? (
|
||||
) : summary.length === 0 ? (
|
||||
<SkeletonLoader type="summary" />
|
||||
) : responseCount === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
@@ -83,6 +144,7 @@ export const SummaryList = ({
|
||||
surveyType={survey.type}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +155,7 @@ export const SummaryList = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +176,7 @@ export const SummaryList = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +187,7 @@ export const SummaryList = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -133,6 +198,7 @@ export const SummaryList = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -176,6 +242,7 @@ export const SummaryList = ({
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ const StatCard = ({ label, percentage, value, tooltipText }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
<p className="flex items-center gap-1 text-sm text-slate-600">
|
||||
{label}
|
||||
{percentage && percentage !== "NaN%" && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{percentage}</span>
|
||||
{typeof percentage === "number" && !isNaN(percentage) && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{percentage}%</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
@@ -67,13 +67,13 @@ export const SummaryMetadata = ({ setShowDropOffs, showDropOffs, surveySummary }
|
||||
/>
|
||||
<StatCard
|
||||
label="Starts"
|
||||
percentage={`${Math.round(startsPercentage)}%`}
|
||||
percentage={Math.round(startsPercentage) > 100 ? null : Math.round(startsPercentage)}
|
||||
value={totalResponses === 0 ? <span>-</span> : totalResponses}
|
||||
tooltipText="Number of times the survey has been started."
|
||||
/>
|
||||
<StatCard
|
||||
label="Completed"
|
||||
percentage={`${Math.round(completedPercentage)}%`}
|
||||
percentage={Math.round(completedPercentage) > 100 ? null : Math.round(completedPercentage)}
|
||||
value={completedResponses === 0 ? <span>-</span> : completedResponses}
|
||||
tooltipText="Number of times the survey has been completed."
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getSummaryBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/actions";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -53,7 +53,6 @@ export const SummaryPage = ({
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
user,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
}: SummaryPageProps) => {
|
||||
@@ -61,49 +60,59 @@ export const SummaryPage = ({
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const isShareEmbedModalOpen = searchParams.get("share") === "true";
|
||||
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
const [isFetchingSummary, setFetchingSummary] = useState<boolean>(true);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedFilter, dateRange]
|
||||
);
|
||||
|
||||
// Use a ref to keep the latest state and props
|
||||
const latestFiltersRef = useRef(filters);
|
||||
latestFiltersRef.current = filters;
|
||||
|
||||
const getResponseCount = () => {
|
||||
if (isSharingPage) return getResponseCountBySurveySharingKeyAction(sharingKey, latestFiltersRef.current);
|
||||
return getResponseCountAction(surveyId, latestFiltersRef.current);
|
||||
};
|
||||
|
||||
const getSummary = () => {
|
||||
if (isSharingPage) return getSummaryBySurveySharingKeyAction(sharingKey, latestFiltersRef.current);
|
||||
return getSurveySummaryAction(surveyId, latestFiltersRef.current);
|
||||
};
|
||||
|
||||
const handleInitialData = async () => {
|
||||
try {
|
||||
const updatedResponseCount = await getResponseCount();
|
||||
const updatedSurveySummary = await getSummary();
|
||||
|
||||
setResponseCount(updatedResponseCount);
|
||||
setSurveySummary(updatedSurveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleInitialData = async () => {
|
||||
try {
|
||||
setFetchingSummary(true);
|
||||
let updatedResponseCount;
|
||||
if (isSharingPage) {
|
||||
updatedResponseCount = await getResponseCountBySurveySharingKeyAction(sharingKey, filters);
|
||||
} else {
|
||||
updatedResponseCount = await getResponseCountAction(surveyId, filters);
|
||||
}
|
||||
setResponseCount(updatedResponseCount);
|
||||
|
||||
let updatedSurveySummary;
|
||||
if (isSharingPage) {
|
||||
updatedSurveySummary = await getSummaryBySurveySharingKeyAction(sharingKey, filters);
|
||||
} else {
|
||||
updatedSurveySummary = await getSurveySummaryAction(surveyId, filters);
|
||||
}
|
||||
|
||||
setSurveySummary(updatedSurveySummary);
|
||||
} finally {
|
||||
setFetchingSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleInitialData();
|
||||
}, [filters, isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
useEffect(() => {
|
||||
if (!isShareEmbedModalOpen) {
|
||||
const interval = setInterval(() => {
|
||||
handleInitialData();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isShareEmbedModalOpen]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default", attributeClasses);
|
||||
@@ -125,14 +134,13 @@ export const SummaryPage = ({
|
||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} />}
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isSharingPage && <ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} user={user} />}
|
||||
{!isSharingPage && <ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} />}
|
||||
</div>
|
||||
<SummaryList
|
||||
summary={surveySummary.summary}
|
||||
responseCount={responseCount}
|
||||
survey={surveyMemoized}
|
||||
environment={environment}
|
||||
fetchingSummary={isFetchingSummary}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surve
|
||||
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 { ShareIcon, SquarePenIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -24,10 +25,36 @@ export const SurveyAnalysisCTA = ({
|
||||
webAppUrl: string;
|
||||
user: TUser;
|
||||
}) => {
|
||||
const [showShareSurveyModal, setShowShareSurveyModal] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const [showShareSurveyModal, setShowShareSurveyModal] = useState(searchParams.get("share") === "true");
|
||||
|
||||
const widgetSetupCompleted =
|
||||
survey.type === "app" ? environment.appSetupCompleted : environment.websiteSetupCompleted;
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("share") === "true") {
|
||||
setShowShareSurveyModal(true);
|
||||
} else {
|
||||
setShowShareSurveyModal(false);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const setOpenShareSurveyModal = (open: boolean) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (open) {
|
||||
searchParams.set("share", "true");
|
||||
setShowShareSurveyModal(true);
|
||||
} else {
|
||||
searchParams.delete("share");
|
||||
setShowShareSurveyModal(false);
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${searchParams.toString()}`);
|
||||
};
|
||||
return (
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{survey.resultShareKey && (
|
||||
@@ -41,7 +68,7 @@ export const SurveyAnalysisCTA = ({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowShareSurveyModal(true);
|
||||
setOpenShareSurveyModal(true);
|
||||
}}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -59,13 +86,13 @@ export const SurveyAnalysisCTA = ({
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showShareSurveyModal}
|
||||
setOpen={setShowShareSurveyModal}
|
||||
setOpen={setOpenShareSurveyModal}
|
||||
webAppUrl={webAppUrl}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
{user && <SuccessMessage environment={environment} survey={survey} webAppUrl={webAppUrl} user={user} />}
|
||||
{user && <SuccessMessage environment={environment} survey={survey} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CodeBlock } from "@formbricks/ui/CodeBlock";
|
||||
|
||||
export const WebpageTab = ({ surveyUrl }) => {
|
||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||
const iframeCode = `<div style="position: relative; height:100vh; overflow:auto;">
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
||||
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
|
||||
};
|
||||
|
||||
export const constructToastMessage = (
|
||||
questionType: TSurveyQuestionTypeEnum,
|
||||
filterValue: string,
|
||||
survey: TSurvey,
|
||||
questionId: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => {
|
||||
const questionIdx = survey.questions.findIndex((question) => question.id === questionId);
|
||||
if (questionType === "matrix") {
|
||||
return `Added filter for responses where answer to question ${questionIdx + 1} is ${filterComboBoxValue} - ${filterValue}`;
|
||||
} else if (filterComboBoxValue === undefined) {
|
||||
return `Added filter for responses where answer to question ${questionIdx + 1} is skipped`;
|
||||
} else {
|
||||
return `Added filter for responses where answer to question ${questionIdx + 1} ${filterValue} ${Array.isArray(filterComboBoxValue) ? filterComboBoxValue.join(",") : filterComboBoxValue}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,9 +76,9 @@ const Page = async ({ params }) => {
|
||||
}>
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
responseCount={totalResponseCount}
|
||||
surveyId={survey.id}
|
||||
survey={survey}
|
||||
activeId="summary"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
|
||||
@@ -48,7 +48,8 @@ export const QuestionFilterComboBox = ({
|
||||
const isMultiple =
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection;
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
|
||||
|
||||
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||
const options = isMultiple
|
||||
@@ -120,7 +121,7 @@ export const QuestionFilterComboBox = ({
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
|
||||
!isMultiple ? (
|
||||
!Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
|
||||
@@ -9,9 +9,7 @@ import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import clsx from "clsx";
|
||||
import { isEqual } from "lodash";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Plus } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
@@ -174,9 +172,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
clearItem();
|
||||
if (!isEqual(filterValue, selectedFilter)) {
|
||||
setSelectedFilter(filterValue);
|
||||
}
|
||||
setSelectedFilter(filterValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -187,10 +183,16 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFilterValue(selectedFilter);
|
||||
}, [selectedFilter]);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
Filter {filterValue.filter.length > 0 && `(${filterValue.filter.length})`}
|
||||
<span>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
@@ -203,7 +205,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
align="start"
|
||||
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]">
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between">
|
||||
<p className="hidden text-lg font-bold text-black sm:block">Show all responses that match</p>
|
||||
<p className="text-slate800 hidden text-lg font-semibold sm:block">Show all responses that match</p>
|
||||
<p className="block text-base text-slate-500 sm:hidden">Show all responses where...</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm font-normal text-slate-600">Only completed</label>
|
||||
|
||||
@@ -9,24 +9,20 @@ import { CopyIcon, DownloadIcon, GlobeIcon, LinkIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { ShareEmbedSurvey } from "../(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurveyResults";
|
||||
|
||||
interface ResultsShareButtonProps {
|
||||
survey: TSurvey;
|
||||
webAppUrl: string;
|
||||
user?: TUser;
|
||||
}
|
||||
|
||||
export const ResultsShareButton = ({ survey, webAppUrl, user }: ResultsShareButtonProps) => {
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProps) => {
|
||||
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
|
||||
|
||||
const [showPublishModal, setShowPublishModal] = useState(false);
|
||||
@@ -43,7 +39,6 @@ export const ResultsShareButton = ({ survey, webAppUrl, user }: ResultsShareButt
|
||||
.then(() => {
|
||||
toast.success("Results unpublished successfully.");
|
||||
setShowPublishModal(false);
|
||||
setShowLinkModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
@@ -62,12 +57,6 @@ export const ResultsShareButton = ({ survey, webAppUrl, user }: ResultsShareButt
|
||||
fetchSharingKey();
|
||||
}, [survey.id, webAppUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showResultsLinkModal) {
|
||||
setShowLinkModal(false);
|
||||
}
|
||||
}, [showResultsLinkModal]);
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUrl = window.location.href;
|
||||
@@ -134,16 +123,6 @@ export const ResultsShareButton = ({ survey, webAppUrl, user }: ResultsShareButt
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{showLinkModal && user && (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
webAppUrl={webAppUrl}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{showResultsLinkModal && (
|
||||
<ShareSurveyResults
|
||||
open={showResultsLinkModal}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { writeData as writeNotionData } from "@formbricks/lib/notion/service";
|
||||
import { processResponseData } from "@formbricks/lib/responses";
|
||||
import { writeDataToSlack } from "@formbricks/lib/slack/service";
|
||||
import { Result } from "@formbricks/types/error-handlers";
|
||||
import { TIntegration } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
@@ -54,16 +55,36 @@ export const handleIntegrations = async (
|
||||
for (const integration of integrations) {
|
||||
switch (integration.type) {
|
||||
case "googleSheets":
|
||||
await handleGoogleSheetsIntegration(integration as TIntegrationGoogleSheets, data, survey);
|
||||
const googleResult = await handleGoogleSheetsIntegration(
|
||||
integration as TIntegrationGoogleSheets,
|
||||
data,
|
||||
survey
|
||||
);
|
||||
if (!googleResult.ok) {
|
||||
console.error("Error in google sheets integration: ", googleResult.error);
|
||||
}
|
||||
break;
|
||||
case "slack":
|
||||
await handleSlackIntegration(integration as TIntegrationSlack, data, survey);
|
||||
const slackResult = await handleSlackIntegration(integration as TIntegrationSlack, data, survey);
|
||||
if (!slackResult.ok) {
|
||||
console.error("Error in slack integration: ", slackResult.error);
|
||||
}
|
||||
break;
|
||||
case "airtable":
|
||||
await handleAirtableIntegration(integration as TIntegrationAirtable, data, survey);
|
||||
const airtableResult = await handleAirtableIntegration(
|
||||
integration as TIntegrationAirtable,
|
||||
data,
|
||||
survey
|
||||
);
|
||||
if (!airtableResult.ok) {
|
||||
console.error("Error in airtable integration: ", airtableResult.error);
|
||||
}
|
||||
break;
|
||||
case "notion":
|
||||
await handleNotionIntegration(integration as TIntegrationNotion, data, survey);
|
||||
const notionResult = await handleNotionIntegration(integration as TIntegrationNotion, data, survey);
|
||||
if (!notionResult.ok) {
|
||||
console.error("Error in notion integration: ", notionResult.error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -73,20 +94,32 @@ const handleAirtableIntegration = async (
|
||||
integration: TIntegrationAirtable,
|
||||
data: TPipelineInput,
|
||||
survey: TSurvey
|
||||
) => {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
);
|
||||
await airtableWriteData(integration.config.key, element, values);
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
);
|
||||
await airtableWriteData(integration.config.key, element, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,25 +127,37 @@ const handleGoogleSheetsIntegration = async (
|
||||
integration: TIntegrationGoogleSheets,
|
||||
data: TPipelineInput,
|
||||
survey: TSurvey
|
||||
) => {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
);
|
||||
const integrationData = structuredClone(integration);
|
||||
integrationData.config.data.forEach((data) => {
|
||||
data.createdAt = new Date(data.createdAt);
|
||||
});
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
);
|
||||
const integrationData = structuredClone(integration);
|
||||
integrationData.config.data.forEach((data) => {
|
||||
data.createdAt = new Date(data.createdAt);
|
||||
});
|
||||
|
||||
await writeData(integrationData, element.spreadsheetId, values);
|
||||
await writeData(integrationData, element.spreadsheetId, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,20 +165,32 @@ const handleSlackIntegration = async (
|
||||
integration: TIntegrationSlack,
|
||||
data: TPipelineInput,
|
||||
survey: TSurvey
|
||||
) => {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
);
|
||||
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const values = await processDataForIntegration(
|
||||
data,
|
||||
survey,
|
||||
!!element.includeMetadata,
|
||||
!!element.includeHiddenFields,
|
||||
element.questionIds
|
||||
);
|
||||
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,14 +242,26 @@ const handleNotionIntegration = async (
|
||||
integration: TIntegrationNotion,
|
||||
data: TPipelineInput,
|
||||
surveyData: TSurvey
|
||||
) => {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const properties = buildNotionPayloadProperties(element.mapping, data, surveyData);
|
||||
await writeNotionData(element.databaseId, properties, integration.config);
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
for (const element of integration.config.data) {
|
||||
if (element.surveyId === data.surveyId) {
|
||||
const properties = buildNotionPayloadProperties(element.mapping, data, surveyData);
|
||||
await writeNotionData(element.databaseId, properties, integration.config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/email";
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { CRON_SECRET } from "@formbricks/lib/constants";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
@@ -15,7 +15,7 @@ import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
// check authentication with x-api-key header and CRON_SECRET env variable
|
||||
if (headers().get("x-api-key") !== INTERNAL_SECRET) {
|
||||
if (headers().get("x-api-key") !== CRON_SECRET) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const jsonInput = await request.json();
|
||||
@@ -106,8 +106,9 @@ export const POST = async (request: Request) => {
|
||||
const survey = surveyData ?? undefined;
|
||||
|
||||
if (integrations.length > 0 && survey) {
|
||||
handleIntegrations(integrations, inputValidation.data, survey);
|
||||
await handleIntegrations(integrations, inputValidation.data, survey);
|
||||
}
|
||||
|
||||
// filter all users that have email notifications enabled for this survey
|
||||
const usersWithNotifications = users.filter((user) => {
|
||||
const notificationSettings: TUserNotificationSettings | null = user.notificationSettings;
|
||||
|
||||
@@ -21,11 +21,10 @@ import {
|
||||
} from "@formbricks/lib/posthogServer";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { getSyncSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/service";
|
||||
import { getSyncSurveys } from "@formbricks/lib/survey/service";
|
||||
import { transformToLegacySurvey } from "@formbricks/lib/survey/utils";
|
||||
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
|
||||
import { TJsAppLegacyStateSync, TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TLegacySurvey } from "@formbricks/types/legacy-surveys";
|
||||
import { TProductLegacy } from "@formbricks/types/product";
|
||||
import { TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
@@ -181,7 +180,7 @@ export const GET = async (
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const updatedProduct: TProductLegacy = {
|
||||
const updatedProduct: any = {
|
||||
...product,
|
||||
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(product.styling.highlightBorderColor?.light && {
|
||||
@@ -194,10 +193,10 @@ export const GET = async (
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[] = surveys;
|
||||
let transformedSurveys: TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state: TJsAppStateSync | TJsAppLegacyStateSync = {
|
||||
let state: TJsAppStateSync = {
|
||||
surveys: !isMonthlyResponsesLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, attributes))
|
||||
: [],
|
||||
@@ -212,13 +211,13 @@ export const GET = async (
|
||||
// Convert to legacy surveys with default language
|
||||
// convert triggers to array of actionClasses Names
|
||||
transformedSurveys = await Promise.all(
|
||||
surveys.map((survey: TSurvey | TLegacySurvey) => {
|
||||
surveys.map((survey) => {
|
||||
const languageCode = "default";
|
||||
return transformToLegacySurvey(survey as TSurvey, languageCode);
|
||||
})
|
||||
);
|
||||
|
||||
state = {
|
||||
const legacyState: any = {
|
||||
surveys: !isMonthlyResponsesLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecallInLegacySurveys(survey, attributes))
|
||||
: [],
|
||||
@@ -227,6 +226,7 @@ export const GET = async (
|
||||
language,
|
||||
product: updatedProduct,
|
||||
};
|
||||
return responses.successResponse({ ...legacyState }, true);
|
||||
}
|
||||
|
||||
return responses.successResponse({ ...state }, true);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TLegacySurvey } from "@formbricks/types/legacy-surveys";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
|
||||
@@ -42,10 +41,7 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes)
|
||||
return surveyTemp;
|
||||
};
|
||||
|
||||
export const replaceAttributeRecallInLegacySurveys = (
|
||||
survey: TLegacySurvey,
|
||||
attributes: TAttributes
|
||||
): TLegacySurvey => {
|
||||
export const replaceAttributeRecallInLegacySurveys = (survey: any, attributes: TAttributes): any => {
|
||||
const surveyTemp = structuredClone(survey);
|
||||
surveyTemp.questions.forEach((question) => {
|
||||
if (question.headline.includes("recall:")) {
|
||||
|
||||
@@ -14,11 +14,10 @@ import {
|
||||
} from "@formbricks/lib/posthogServer";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { getSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { transformToLegacySurvey } from "@formbricks/lib/survey/utils";
|
||||
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
|
||||
import { TJsWebsiteLegacyStateSync, TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js";
|
||||
import { TLegacySurvey } from "@formbricks/types/legacy-surveys";
|
||||
import { TProductLegacy } from "@formbricks/types/product";
|
||||
import { TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
@@ -122,7 +121,7 @@ export const GET = async (
|
||||
// && (!survey.segment || survey.segment.filters.length === 0)
|
||||
);
|
||||
|
||||
const updatedProduct: TProductLegacy = {
|
||||
const updatedProduct: any = {
|
||||
...product,
|
||||
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(product.styling.highlightBorderColor?.light && {
|
||||
@@ -133,8 +132,8 @@ export const GET = async (
|
||||
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
|
||||
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
|
||||
let transformedSurveys: TLegacySurvey[] | TSurvey[] = filteredSurveys;
|
||||
let state: TJsWebsiteStateSync | TJsWebsiteLegacyStateSync = {
|
||||
let transformedSurveys: TSurvey[] = filteredSurveys;
|
||||
let state: TJsWebsiteStateSync = {
|
||||
surveys: !isWebsiteSurveyResponseLimitReached ? transformedSurveys : [],
|
||||
actionClasses,
|
||||
product: updatedProduct,
|
||||
@@ -152,11 +151,16 @@ export const GET = async (
|
||||
})
|
||||
);
|
||||
|
||||
state = {
|
||||
const legacyState: any = {
|
||||
surveys: isWebsiteSurveyResponseLimitReached ? [] : transformedSurveys,
|
||||
noCodeActionClasses,
|
||||
product: updatedProduct,
|
||||
};
|
||||
return responses.successResponse(
|
||||
{ ...legacyState },
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { translateSurvey } from "@formbricks/lib/i18n/utils";
|
||||
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
@@ -38,13 +37,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
if (surveyInput?.questions && surveyInput.questions[0].headline) {
|
||||
const questionHeadline = surveyInput.questions[0].headline;
|
||||
if (typeof questionHeadline === "string") {
|
||||
// its a legacy survey
|
||||
surveyInput = translateSurvey(surveyInput, []);
|
||||
}
|
||||
}
|
||||
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { TPipelineInput } from "@formbricks/types/pipelines";
|
||||
|
||||
export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => {
|
||||
@@ -6,7 +6,7 @@ export const sendToPipeline = async ({ event, surveyId, environmentId, response
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
"x-api-key": CRON_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
environmentId: environmentId,
|
||||
|
||||
@@ -15,15 +15,14 @@ import {
|
||||
TSurveyMetaFieldFilter,
|
||||
TSurveyPersonAttributes,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
const conditionOptions = {
|
||||
openText: ["is"],
|
||||
multipleChoiceSingle: ["Includes either"],
|
||||
multipleChoiceMulti: ["Includes all", "Includes either"],
|
||||
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
|
||||
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped", "Includes either"],
|
||||
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
|
||||
cta: ["is"],
|
||||
tags: ["is"],
|
||||
@@ -278,6 +277,7 @@ export const getFormattedFilters = (
|
||||
op: "skipped",
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
|
||||
@@ -292,6 +292,7 @@ export const getFormattedFilters = (
|
||||
value: filterType.filterComboBoxValue as string[],
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
case TSurveyQuestionTypeEnum.Rating: {
|
||||
@@ -318,7 +319,13 @@ export const getFormattedFilters = (
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "skipped",
|
||||
};
|
||||
} else if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionType.id ?? ""] = {
|
||||
op: "includesOne",
|
||||
value: (filterType.filterComboBoxValue as string[]).map((value) => parseInt(value)),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.CTA: {
|
||||
if (filterType.filterComboBoxValue === "Clicked") {
|
||||
@@ -330,6 +337,7 @@ export const getFormattedFilters = (
|
||||
op: "skipped",
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Consent: {
|
||||
if (filterType.filterComboBoxValue === "Accepted") {
|
||||
@@ -341,6 +349,7 @@ export const getFormattedFilters = (
|
||||
op: "skipped",
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.PictureSelection: {
|
||||
const questionId = questionType.id ?? "";
|
||||
@@ -369,6 +378,7 @@ export const getFormattedFilters = (
|
||||
value: selectedOptions,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
if (
|
||||
@@ -381,6 +391,7 @@ export const getFormattedFilters = (
|
||||
value: { [filterType.filterValue]: filterType.filterComboBoxValue },
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LegalFooter } from "@/app/s/[surveyId]/components/LegalFooter";
|
||||
import React from "react";
|
||||
import { SurveyLoadingAnimation } from "@/app/s/[surveyId]/components/SurveyLoadingAnimation";
|
||||
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";
|
||||
@@ -35,6 +36,13 @@ export const LinkSurveyWrapper = ({
|
||||
webAppUrl,
|
||||
}: LinkSurveyWrapperProps) => {
|
||||
//for embedded survey strip away all surrounding css
|
||||
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
|
||||
|
||||
const handleBackgroundLoaded = (isLoaded: boolean) => {
|
||||
if (isLoaded) {
|
||||
setIsBackgroundLoaded(true);
|
||||
}
|
||||
};
|
||||
const styling = determineStyling();
|
||||
if (isEmbed)
|
||||
return (
|
||||
@@ -44,13 +52,15 @@ export const LinkSurveyWrapper = ({
|
||||
styling.cardArrangement?.linkSurveys === "straight" && "pt-6",
|
||||
styling.cardArrangement?.linkSurveys === "casual" && "px-6 py-10"
|
||||
)}>
|
||||
<SurveyLoadingAnimation survey={survey} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<div>
|
||||
<MediaBackground survey={survey} product={product}>
|
||||
<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 md:items-center">
|
||||
{!styling.isLogoHidden && product.logo?.url && <ClientLogo product={product} />}
|
||||
<div className="h-full w-full space-y-6 p-0 md:max-w-md">
|
||||
|
||||
116
apps/web/app/s/[surveyId]/components/SurveyLoadingAnimation.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import Logo from "@/images/powered-by-formbricks.svg";
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { LoadingSpinner } from "@formbricks/ui/LoadingSpinner";
|
||||
|
||||
interface SurveyLoadingAnimationProps {
|
||||
survey: TSurvey;
|
||||
isBackgroundLoaded?: boolean;
|
||||
}
|
||||
|
||||
export const SurveyLoadingAnimation = ({
|
||||
survey,
|
||||
isBackgroundLoaded = true,
|
||||
}: SurveyLoadingAnimationProps) => {
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const [minTimePassed, setMinTimePassed] = useState(false);
|
||||
const [isMediaLoaded, setIsMediaLoaded] = useState(false); // Tracks if all media (images, iframes) are fully loaded
|
||||
const [isSurveyPackageLoaded, setIsSurveyPackageLoaded] = useState(false); // Tracks if the survey package has been loaded into the DOM
|
||||
const isReadyToTransition = isMediaLoaded && minTimePassed && isBackgroundLoaded;
|
||||
const cardId = survey.welcomeCard.enabled ? `questionCard--1` : `questionCard-0`;
|
||||
|
||||
// Function to check if all media elements (images and iframes) within the survey card are loaded
|
||||
const checkMediaLoaded = useCallback(() => {
|
||||
const cardElement = document.getElementById(cardId);
|
||||
const images = cardElement ? Array.from(cardElement.getElementsByTagName("img")) : [];
|
||||
const iframes = cardElement ? Array.from(cardElement.getElementsByTagName("iframe")) : [];
|
||||
|
||||
const allImagesLoaded = images.every((img) => img.complete && img.naturalHeight !== 0);
|
||||
const allIframesLoaded = iframes.every((iframe) => {
|
||||
const contentWindow = iframe.contentWindow;
|
||||
return contentWindow && contentWindow.document.readyState === "complete";
|
||||
});
|
||||
|
||||
if (allImagesLoaded && allIframesLoaded) {
|
||||
setIsMediaLoaded(true);
|
||||
}
|
||||
}, [cardId]);
|
||||
|
||||
// Effect to monitor when the survey package is loaded and media elements are fully loaded
|
||||
useEffect(() => {
|
||||
if (!isSurveyPackageLoaded) return; // Exit early if the survey package is not yet loaded
|
||||
|
||||
checkMediaLoaded(); // Initial check when the survey package is loaded
|
||||
|
||||
// Add event listeners to detect when individual media elements finish loading
|
||||
const mediaElements = document.querySelectorAll(`#${cardId} img, #${cardId} iframe`);
|
||||
mediaElements.forEach((element) => element.addEventListener("load", checkMediaLoaded));
|
||||
|
||||
return () => {
|
||||
// Cleanup event listeners when the component unmounts or dependencies change
|
||||
mediaElements.forEach((element) => element.removeEventListener("load", checkMediaLoaded));
|
||||
};
|
||||
}, [isSurveyPackageLoaded, checkMediaLoaded, cardId]);
|
||||
|
||||
// Effect to handle the hiding of the animation once both media are loaded and the minimum time has passed
|
||||
useEffect(() => {
|
||||
if (isMediaLoaded && minTimePassed) {
|
||||
const hideTimer = setTimeout(() => {
|
||||
setIsHidden(true);
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(hideTimer);
|
||||
} else {
|
||||
setIsHidden(false);
|
||||
}
|
||||
}, [isMediaLoaded, minTimePassed]);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure the animation is shown for at least 1.5 seconds
|
||||
const minTimeTimer = setTimeout(() => {
|
||||
setMinTimePassed(true);
|
||||
}, 1500);
|
||||
|
||||
// Observe the DOM for when the survey package (child elements) is added to the target node
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.some((mutation) => {
|
||||
if (mutation.addedNodes.length) {
|
||||
setIsSurveyPackageLoaded(true);
|
||||
observer.disconnect();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const targetNode = document.getElementById("formbricks-survey-container");
|
||||
if (targetNode) {
|
||||
observer.observe(targetNode, { childList: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearTimeout(minTimeTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-[5000] flex items-center justify-center transition-colors duration-1000",
|
||||
isReadyToTransition ? "bg-transparent" : "bg-white",
|
||||
isHidden && "hidden"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center space-y-4",
|
||||
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
|
||||
)}>
|
||||
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -93,7 +93,7 @@ const Page = async ({ params, searchParams }: LinkSurveyPageProps) => {
|
||||
if (isSingleUseSurvey) {
|
||||
try {
|
||||
singleUseResponse = singleUseId
|
||||
? (await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined
|
||||
? ((await getResponseBySingleUseId(survey.id, singleUseId)) ?? undefined)
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
singleUseResponse = undefined;
|
||||
|
||||
@@ -41,10 +41,10 @@ const Page = async ({ params }) => {
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation
|
||||
surveyId={survey.id}
|
||||
survey={survey}
|
||||
environmentId={environment.id}
|
||||
activeId="responses"
|
||||
responseCount={totalResponseCount}
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
|
||||
@@ -43,10 +43,10 @@ const Page = async ({ params }) => {
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation
|
||||
surveyId={survey.id}
|
||||
survey={survey}
|
||||
environmentId={environment.id}
|
||||
activeId="summary"
|
||||
responseCount={totalResponseCount}
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<svg width="220" height="220" viewBox="0 0 220 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M39.1602 147.334H95.8321V175.67C95.8321 191.32 83.1457 204.006 67.4962 204.006C51.8466 204.006 39.1602 191.32 39.1602 175.67V147.334Z" fill="url(#paint0_linear_415_2)"/>
|
||||
<path d="M39.1602 81.8071H152.504C168.154 81.8071 180.84 94.4936 180.84 110.143C180.84 125.793 168.154 138.479 152.504 138.479H39.1602V81.8071Z" fill="url(#paint1_linear_415_2)"/>
|
||||
<path d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z" fill="url(#paint2_linear_415_2)"/>
|
||||
<mask id="mask0_415_2" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="39" y="16" width="142" height="189">
|
||||
<path d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z" fill="url(#paint3_linear_415_2)"/>
|
||||
<path d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z" fill="url(#paint4_linear_415_2)"/>
|
||||
<path d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z" fill="url(#paint5_linear_415_2)"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_415_2)">
|
||||
<g filter="url(#filter0_d_415_2)">
|
||||
<mask id="mask1_415_2" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="39" y="16" width="142" height="189">
|
||||
<path d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z" fill="black" fill-opacity="0.1"/>
|
||||
<path d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z" fill="black" fill-opacity="0.1"/>
|
||||
<path d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z" fill="black" fill-opacity="0.1"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_415_2)">
|
||||
<path d="M42.1331 -32.5321C64.3329 -54.1986 120.626 -32.5321 120.626 -32.5321H42.1331C36.6806 -27.2105 33.2847 -19.2749 33.2847 -7.76218C33.2847 50.6243 96.5317 71.8561 96.5317 112.55C96.5317 152.386 35.9231 176.962 33.3678 231.092H120.626C120.626 231.092 33.2847 291.248 33.2847 234.631C33.2847 233.437 33.3128 232.258 33.3678 231.092H-5.11523L2.41417 -32.5321H42.1331Z" fill="black" fill-opacity="0.1"/>
|
||||
</g>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_415_2)">
|
||||
<circle cx="21.4498" cy="179.212" r="53.13" fill="#00C4B8"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_415_2)">
|
||||
<circle cx="21.4498" cy="44.6163" r="53.13" fill="#00C4B8"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_415_2" x="34.5149" y="-11.5917" width="137.209" height="243.47" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="23.2262"/>
|
||||
<feGaussianBlur stdDeviation="13.9357"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_415_2"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_415_2" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_415_2" x="-78.1326" y="79.6296" width="199.165" height="199.165" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_415_2" x="-78.1326" y="-54.9661" width="199.165" height="199.165" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_415_2" x1="96.0786" y1="174.643" x2="39.1553" y2="174.873" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="#00C4B8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_415_2" x1="181.456" y1="109.116" x2="39.1602" y2="110.554" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00DDD0"/>
|
||||
<stop offset="1" stop-color="#01E0C6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_415_2" x1="181.456" y1="43.5891" x2="39.1602" y2="45.0264" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00DDD0"/>
|
||||
<stop offset="1" stop-color="#01E0C6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_415_2" x1="96.0786" y1="174.644" x2="39.1553" y2="174.874" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00FFE1"/>
|
||||
<stop offset="1" stop-color="#01E0C6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_415_2" x1="181.456" y1="109.117" x2="39.1602" y2="110.555" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00FFE1"/>
|
||||
<stop offset="1" stop-color="#01E0C6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_415_2" x1="181.456" y1="43.5891" x2="39.1602" y2="45.0264" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00FFE1"/>
|
||||
<stop offset="1" stop-color="#01E0C6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
4
apps/web/images/powered-by-formbricks.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
@@ -163,8 +163,6 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
env: {
|
||||
INSTANCE_ID: createId(),
|
||||
INTERNAL_SECRET: createId(),
|
||||
NEXTAUTH_URL: process.env.WEBAPP_URL,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -31,31 +31,30 @@
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@react-email/components": "^0.0.22",
|
||||
"@sentry/nextjs": "^8.22.0",
|
||||
"@sentry/nextjs": "^8.26.0",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"@vercel/speed-insights": "^1.0.12",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"encoding": "^0.1.13",
|
||||
"framer-motion": "11.3.20",
|
||||
"framer-motion": "11.3.28",
|
||||
"googleapis": "^140.0.1",
|
||||
"jiti": "^1.21.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.0",
|
||||
"lucide-react": "^0.418.0",
|
||||
"lucide-react": "^0.427.0",
|
||||
"mime": "^4.0.4",
|
||||
"next": "14.2.5",
|
||||
"next-safe-action": "^7.4.3",
|
||||
"next-safe-action": "^7.6.2",
|
||||
"optional": "^0.1.4",
|
||||
"otplib": "^12.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"posthog-js": "^1.154.0",
|
||||
"posthog-js": "^1.155.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"redis": "^4.7.0",
|
||||
"sharp": "^0.33.4",
|
||||
|
||||
@@ -47,13 +47,6 @@ test.describe("JS Package Test", async () => {
|
||||
})();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary/);
|
||||
|
||||
// await expect(page.getByRole("link", { name: "Surveys" })).toBeVisible();
|
||||
// await page.getByRole("link", { name: "Surveys" }).click();
|
||||
|
||||
// await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
// await expect(page.getByRole("heading", { name: "Surveys" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("JS display survey on page and submit response", async () => {
|
||||
@@ -64,18 +57,20 @@ test.describe("JS Package Test", async () => {
|
||||
await page.goto(htmlFile);
|
||||
|
||||
// Formbricks In App Sync has happened
|
||||
const syncApi = await page.waitForResponse((response) => response.url().includes("/app/sync"));
|
||||
const syncApi = await page.waitForResponse(
|
||||
(response) => {
|
||||
return response.url().includes("/app/sync");
|
||||
},
|
||||
{
|
||||
timeout: 120000,
|
||||
}
|
||||
);
|
||||
|
||||
expect(syncApi.status()).toBe(200);
|
||||
|
||||
// Formbricks Modal exists in the DOM
|
||||
await expect(page.locator("#formbricks-modal-container")).toHaveCount(1);
|
||||
|
||||
// const displayApi = await page.waitForResponse((response) => response.url().includes("/display"));
|
||||
// expect(displayApi.status()).toBe(200);
|
||||
|
||||
// Formbricks Modal exists in the DOM
|
||||
// await expect(page.locator("#formbricks-modal-container")).toHaveCount(1);
|
||||
|
||||
// Formbricks Modal is visible
|
||||
await expect(
|
||||
page.locator("#questionCard-0").getByRole("link", { name: "Powered by Formbricks" })
|
||||
@@ -115,7 +110,7 @@ test.describe("JS Package Test", async () => {
|
||||
expect(impressionsCount).toEqual("Impressions\n\n1");
|
||||
|
||||
await expect(page.getByRole("link", { name: "Responses (1)" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Completed100%" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
|
||||
|
||||
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("CTR100%")).toBeVisible();
|
||||
|
||||
@@ -25,7 +25,7 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
// Get URL
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary$/);
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
|
||||
await page.getByLabel("Copy survey link to clipboard").click();
|
||||
url = await page.evaluate("navigator.clipboard.readText()");
|
||||
});
|
||||
@@ -435,7 +435,8 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary$/);
|
||||
// await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary$/);
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
|
||||
await page.getByLabel("Select Language").click();
|
||||
await page.getByText("German").click();
|
||||
await page.getByLabel("Copy survey link to clipboard").click();
|
||||
|
||||
@@ -61,54 +61,164 @@ install_formbricks() {
|
||||
mkdir -p formbricks && cd formbricks
|
||||
echo "📁 Created Formbricks Quickstart directory at ./formbricks."
|
||||
|
||||
# Ask the user for their email address
|
||||
echo "💡 Please enter your email address for the SSL certificate:"
|
||||
read email_address
|
||||
# Ask the user for their domain name
|
||||
echo "🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):"
|
||||
read domain_name
|
||||
|
||||
echo "🔗 Do you want us to set up an HTTPS certificate for you? [Y/n]"
|
||||
read https_setup
|
||||
https_setup=$(echo "$https_setup" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Set default value for HTTPS setup
|
||||
if [[ -z $https_setup ]]; then
|
||||
https_setup="y"
|
||||
fi
|
||||
|
||||
if [[ $https_setup == "y" ]]; then
|
||||
echo "🔗 Please make sure that the domain points to the server's IP address and that ports 80 & 443 are open in your server's firewall. Is everything set up? [Y/n]"
|
||||
read dns_setup
|
||||
dns_setup=$(echo "$dns_setup" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Set default value for DNS setup
|
||||
if [[ -z $dns_setup ]]; then
|
||||
dns_setup="y"
|
||||
fi
|
||||
|
||||
if [[ $dns_setup == "y" ]]; then
|
||||
echo "💡 Please enter your email address for the SSL certificate:"
|
||||
read email_address
|
||||
|
||||
echo "🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]"
|
||||
read hsts_enabled
|
||||
hsts_enabled=$(echo "$hsts_enabled" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Set default value for HSTS
|
||||
if [[ -z $hsts_enabled ]]; then
|
||||
hsts_enabled="y"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "❌ Ports 80 & 443 are not open. We can't help you in providing the SSL certificate."
|
||||
https_setup="n"
|
||||
hsts_enabled="n"
|
||||
fi
|
||||
else
|
||||
https_setup="n"
|
||||
hsts_enabled="n"
|
||||
fi
|
||||
|
||||
# Ask for HSTS configuration for HTTPS redirection if custom certificate is used
|
||||
if [[ $https_setup == "n" ]]; then
|
||||
echo "You have chosen not to set up HTTPS certificate for your domain. Please make sure to set up HTTPS on your own. You can refer to the Formbricks documentation(https://formbricks.com/docs/self-hosting/custom-ssl) for more information."
|
||||
|
||||
echo "🔗 Do you want to enforce HTTPS (HSTS)? [Y/n]"
|
||||
read hsts_enabled
|
||||
hsts_enabled=$(echo "$hsts_enabled" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Set default value for HSTS
|
||||
if [[ -z $hsts_enabled ]]; then
|
||||
hsts_enabled="y"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Installing Traefik
|
||||
echo "🚗 Configuring Traefik..."
|
||||
|
||||
cat <<EOT >traefik.yaml
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
if [[ $hsts_enabled == "y" ]]; then
|
||||
hsts_middlewares="middlewares:
|
||||
- hstsHeader"
|
||||
http_redirection="http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
permanent: true
|
||||
websecure:
|
||||
address: ":443"
|
||||
http:
|
||||
tls:
|
||||
certResolver: default
|
||||
providers:
|
||||
docker:
|
||||
watch: true
|
||||
exposedByDefault: false
|
||||
certificatesResolvers:
|
||||
permanent: true"
|
||||
else
|
||||
hsts_middlewares=""
|
||||
http_redirection=""
|
||||
fi
|
||||
|
||||
if [[ $https_setup == "y" ]]; then
|
||||
certResolver="certResolver: default"
|
||||
certificates_resolvers="certificatesResolvers:
|
||||
default:
|
||||
acme:
|
||||
email: $email_address
|
||||
storage: acme.json
|
||||
caServer: "https://acme-v01.api.letsencrypt.org/directory"
|
||||
tlsChallenge: {}
|
||||
tlsChallenge: {}"
|
||||
else
|
||||
certResolver=""
|
||||
certificates_resolvers=""
|
||||
fi
|
||||
|
||||
cat <<EOT >traefik.yaml
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
$http_redirection
|
||||
websecure:
|
||||
address: ":443"
|
||||
http:
|
||||
tls:
|
||||
$certResolver
|
||||
options: default
|
||||
$hsts_middlewares
|
||||
providers:
|
||||
docker:
|
||||
watch: true
|
||||
exposedByDefault: false
|
||||
file:
|
||||
directory: /
|
||||
$certificates_resolvers
|
||||
EOT
|
||||
|
||||
echo "💡 Created traefik.yaml file with your provided email address."
|
||||
cat <<EOT >traefik-dynamic.yaml
|
||||
# configuring min TLS version
|
||||
tls:
|
||||
options:
|
||||
default:
|
||||
minVersion: VersionTLS12
|
||||
cipherSuites:
|
||||
# TLS 1.2 Ciphers
|
||||
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
|
||||
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
|
||||
touch acme.json
|
||||
chmod 600 acme.json
|
||||
echo "💡 Created acme.json file with correct permissions."
|
||||
# TLS 1.3 Ciphers (These are automatically used for TLS 1.3 connections)
|
||||
- TLS_AES_128_GCM_SHA256
|
||||
- TLS_AES_256_GCM_SHA384
|
||||
- TLS_CHACHA20_POLY1305_SHA256
|
||||
|
||||
# Ask the user for their domain name
|
||||
echo "🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):"
|
||||
read domain_name
|
||||
# Fallback
|
||||
- TLS_FALLBACK_SCSV
|
||||
EOT
|
||||
|
||||
echo "💡 Created traefik.yaml and traefik-dynamic.yaml file."
|
||||
|
||||
if [[ $https_setup == "y" ]]; then
|
||||
touch acme.json
|
||||
chmod 600 acme.json
|
||||
echo "💡 Created acme.json file with correct permissions."
|
||||
fi
|
||||
|
||||
# Prompt for email service setup
|
||||
read -p "Do you want to set up the email service? (yes/no) You will need SMTP credentials for the same! " email_service
|
||||
if [[ $email_service == "yes" ]]; then
|
||||
read -p "📧 Do you want to set up the email service? You will need SMTP credentials for the same! [y/N]" email_service
|
||||
email_service=$(echo "$email_service" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Set default value for email service setup
|
||||
if [[ -z $email_service ]]; then
|
||||
email_service="n"
|
||||
fi
|
||||
|
||||
if [[ $email_service == "y" ]]; then
|
||||
echo "Please provide the following email service details: "
|
||||
|
||||
echo -n "Enter your SMTP configured Email ID: "
|
||||
@@ -163,7 +273,7 @@ EOT
|
||||
sed -i "s|# SMTP_PASSWORD:|SMTP_PASSWORD: \"$smtp_password\"|" docker-compose.yml
|
||||
fi
|
||||
|
||||
awk -v domain_name="$domain_name" '
|
||||
awk -v domain_name="$domain_name" -v hsts_enabled="$hsts_enabled" '
|
||||
/formbricks:/,/^ *$/ {
|
||||
if ($0 ~ /depends_on:/) {
|
||||
inserting_labels=1
|
||||
@@ -171,9 +281,20 @@ EOT
|
||||
if (inserting_labels && ($0 ~ /ports:/)) {
|
||||
print " labels:"
|
||||
print " - \"traefik.enable=true\" # Enable Traefik for this service"
|
||||
print " - \"traefik.http.routers.formbricks.rule=Host(\`" domain_name "\`)\" # Use your actual domain or IP"
|
||||
print " - \"traefik.http.routers.formbricks.rule=Host(`" domain_name "`)\" # Use your actual domain or IP"
|
||||
print " - \"traefik.http.routers.formbricks.entrypoints=websecure\" # Use the websecure entrypoint (port 443 with TLS)"
|
||||
print " - \"traefik.http.routers.formbricks.tls=true\" # Enable TLS"
|
||||
print " - \"traefik.http.routers.formbricks.tls.certresolver=default\" # Specify the certResolver"
|
||||
print " - \"traefik.http.services.formbricks.loadbalancer.server.port=3000\" # Forward traffic to Formbricks on port 3000"
|
||||
if (hsts_enabled == "y") {
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.stsSeconds=31536000\" # Set HSTS (HTTP Strict Transport Security) max-age to 1 year (31536000 seconds)"
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.forceSTSHeader=true\" # Ensure the HSTS header is always included in responses"
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.stsPreload=true\" # Allow the domain to be preloaded in browser HSTS preload list"
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.stsIncludeSubdomains=true\" # Apply HSTS policy to all subdomains as well"
|
||||
} else {
|
||||
print " - \"traefik.http.routers.formbricks_http.entrypoints=web\" # Use the web entrypoint (port 80)"
|
||||
print " - \"traefik.http.routers.formbricks_http.rule=Host(`" domain_name "`)\" # Use your actual domain or IP"
|
||||
}
|
||||
inserting_labels=0
|
||||
}
|
||||
print
|
||||
@@ -192,6 +313,7 @@ EOT
|
||||
print " - \"8080:8080\""
|
||||
print " volumes:"
|
||||
print " - ./traefik.yaml:/traefik.yaml"
|
||||
print " - ./traefik-dynamic.yaml:/traefik-dynamic.yaml"
|
||||
print " - ./acme.json:/acme.json"
|
||||
print " - /var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
print ""
|
||||
@@ -216,6 +338,7 @@ END
|
||||
uninstall_formbricks() {
|
||||
echo "🗑️ Preparing to Uninstalling Formbricks..."
|
||||
read -p "Are you sure you want to uninstall Formbricks? This will delete all the data associated with it! (yes/no): " uninstall_confirmation
|
||||
uninstall_confirmation=$(echo "$uninstall_confirmation" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ $uninstall_confirmation == "yes" ]]; then
|
||||
cd formbricks
|
||||
sudo docker compose down
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"terser": "^5.31.3",
|
||||
"vite": "^5.3.5",
|
||||
"terser": "^5.31.6",
|
||||
"vite": "^5.4.1",
|
||||
"vite-plugin-dts": "^3.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.5",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^2.0.11",
|
||||
"eslint-config-turbo": "^2.0.14",
|
||||
"eslint-plugin-react": "7.35.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.9"
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5"
|
||||
"prettier-plugin-tailwindcss": "^0.6.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7"
|
||||
"@tailwindcss/typography": "^0.5.14",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ module.exports = {
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
fadeIn: "fadeIn 0.2s ease-out",
|
||||
fadeOut: "fadeOut 0.2s ease-out",
|
||||
surveyLoading: "surveyLoadingAnimation 0.5s ease-out forwards",
|
||||
surveyExit: "surveyExitAnimation 0.5s ease-out forwards",
|
||||
},
|
||||
blur: {
|
||||
xxs: "0.33px",
|
||||
@@ -87,6 +89,14 @@ module.exports = {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
surveyLoadingAnimation: {
|
||||
"0%": { transform: "translateY(50px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
surveyExitAnimation: {
|
||||
"0%": { transform: "translateY(0)", opacity: "1" },
|
||||
"100%": { transform: "translateY(-50px)", opacity: "0" },
|
||||
},
|
||||
},
|
||||
width: {
|
||||
"sidebar-expanded": "4rem",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"clean": "rimraf node_modules dist turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.0.2",
|
||||
"@types/node": "22.3.0",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "5.4.5"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
|
||||
// migration script to translate surveys where thankYouCard buttonLabel is a string or question subheaders are strings
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { hasStringSubheaders, translateSurvey } from "./lib/i18n";
|
||||
@@ -8,7 +9,7 @@ const main = async () => {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Translate Surveys
|
||||
const surveys = await tx.survey.findMany({
|
||||
const surveys: any = await tx.survey.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
questions: true,
|
||||
@@ -17,11 +18,6 @@ const main = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!surveys) {
|
||||
// stop the migration if there are no surveys
|
||||
return;
|
||||
}
|
||||
|
||||
for (const survey of surveys) {
|
||||
if (typeof survey.thankYouCard.buttonLabel === "string" || hasStringSubheaders(survey.questions)) {
|
||||
const translatedSurvey = translateSurvey(survey, []);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
|
||||
// migration script to convert range field in rating question from string to number
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
|
||||
// migration script to add empty strings to welcome card headline in default language, if it does not exist
|
||||
// WelcomeCard.headline = {} -> WelcomeCard.headline = {"default":""}
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { AttributeType } from "@prisma/client";
|
||||
import { translateSurvey } from "./lib/i18n";
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
|
||||
import { type TLanguage } from "@formbricks/types/product";
|
||||
import {
|
||||
TLegacySurveyChoice,
|
||||
TLegacySurveyQuestion,
|
||||
TLegacySurveyThankYouCard,
|
||||
TLegacySurveyWelcomeCard,
|
||||
} from "@formbricks/types/legacy-surveys";
|
||||
import { TLanguage } from "@formbricks/types/product";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyChoice,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyNPSQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestions,
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
type TI18nString,
|
||||
type TSurveyCTAQuestion,
|
||||
type TSurveyChoice,
|
||||
type TSurveyConsentQuestion,
|
||||
type TSurveyMultipleChoiceQuestion,
|
||||
type TSurveyNPSQuestion,
|
||||
type TSurveyOpenTextQuestion,
|
||||
type TSurveyQuestion,
|
||||
type TSurveyQuestions,
|
||||
type TSurveyRatingQuestion,
|
||||
type TSurveyWelcomeCard,
|
||||
ZSurveyCTAQuestion,
|
||||
ZSurveyCalQuestion,
|
||||
ZSurveyConsentQuestion,
|
||||
@@ -29,7 +22,6 @@ import {
|
||||
ZSurveyPictureSelectionQuestion,
|
||||
ZSurveyQuestion,
|
||||
ZSurveyRatingQuestion,
|
||||
ZSurveyThankYouCard,
|
||||
ZSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -71,7 +63,7 @@ export const createI18nString = (text: string | TI18nString, languages: string[]
|
||||
};
|
||||
|
||||
// Function to translate a choice label
|
||||
const translateChoice = (choice: TSurveyChoice | TLegacySurveyChoice, languages: string[]): TSurveyChoice => {
|
||||
const translateChoice = (choice: TSurveyChoice, languages: string[]): TSurveyChoice => {
|
||||
if (typeof choice.label !== "undefined") {
|
||||
return {
|
||||
...choice,
|
||||
@@ -86,7 +78,7 @@ const translateChoice = (choice: TSurveyChoice | TLegacySurveyChoice, languages:
|
||||
};
|
||||
|
||||
export const translateWelcomeCard = (
|
||||
welcomeCard: TSurveyWelcomeCard | TLegacySurveyWelcomeCard,
|
||||
welcomeCard: TSurveyWelcomeCard,
|
||||
languages: string[]
|
||||
): TSurveyWelcomeCard => {
|
||||
const clonedWelcomeCard = structuredClone(welcomeCard);
|
||||
@@ -103,10 +95,7 @@ export const translateWelcomeCard = (
|
||||
return ZSurveyWelcomeCard.parse(clonedWelcomeCard);
|
||||
};
|
||||
|
||||
const translateThankYouCard = (
|
||||
thankYouCard: TSurveyThankYouCard | TLegacySurveyThankYouCard,
|
||||
languages: string[]
|
||||
): TSurveyThankYouCard => {
|
||||
const translateThankYouCard = (thankYouCard: any, languages: string[]): any => {
|
||||
const clonedThankYouCard = structuredClone(thankYouCard);
|
||||
|
||||
if (typeof thankYouCard.headline !== "undefined") {
|
||||
@@ -120,14 +109,11 @@ const translateThankYouCard = (
|
||||
if (typeof clonedThankYouCard.buttonLabel !== "undefined") {
|
||||
clonedThankYouCard.buttonLabel = createI18nString(thankYouCard.buttonLabel ?? "", languages);
|
||||
}
|
||||
return ZSurveyThankYouCard.parse(clonedThankYouCard);
|
||||
return clonedThankYouCard;
|
||||
};
|
||||
|
||||
// Function that will translate a single question
|
||||
const translateQuestion = (
|
||||
question: TLegacySurveyQuestion | TSurveyQuestion,
|
||||
languages: string[]
|
||||
): TSurveyQuestion => {
|
||||
const translateQuestion = (question: TSurveyQuestion, languages: string[]): TSurveyQuestion => {
|
||||
// Clone the question to avoid mutating the original
|
||||
const clonedQuestion = structuredClone(question);
|
||||
|
||||
@@ -250,12 +236,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
|
||||
return languages.map((language) => language.id);
|
||||
};
|
||||
|
||||
// Function to translate an entire survey
|
||||
export const translateSurvey = (
|
||||
survey: Pick<TSurvey, "questions" | "welcomeCard" | "thankYouCard">,
|
||||
languageCodes: string[]
|
||||
): Pick<TSurvey, "questions" | "welcomeCard" | "thankYouCard"> => {
|
||||
const translatedQuestions = survey.questions.map((question) => {
|
||||
// Function to translate an entire survey (from old survey format to new survey format)
|
||||
export const translateSurvey = (survey: any, languageCodes: string[]) => {
|
||||
const translatedQuestions = survey.questions.map((question: any) => {
|
||||
return translateQuestion(question, languageCodes);
|
||||
});
|
||||
const translatedWelcomeCard = translateWelcomeCard(survey.welcomeCard, languageCodes);
|
||||
|
||||
@@ -25,13 +25,13 @@ async function runMigration(): Promise<void> {
|
||||
console.log("Starting data migration...");
|
||||
|
||||
// Fetch all surveys
|
||||
const surveys: Survey[] = await tx.survey.findMany({
|
||||
const surveys: Survey[] = (await tx.survey.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
thankYouCard: true,
|
||||
redirectUrl: true,
|
||||
},
|
||||
});
|
||||
})) as Survey[];
|
||||
|
||||
if (surveys.length === 0) {
|
||||
// Stop the migration if there are no surveys
|
||||
@@ -46,7 +46,7 @@ async function runMigration(): Promise<void> {
|
||||
.filter((s) => s.thankYouCard !== null)
|
||||
.map((survey) => {
|
||||
transformedSurveyCount++;
|
||||
const updatedSurvey: UpdatedSurvey = structuredClone(survey);
|
||||
const updatedSurvey: UpdatedSurvey = structuredClone(survey) as UpdatedSurvey;
|
||||
|
||||
if (survey.redirectUrl) {
|
||||
updatedSurvey.endings = [
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { type TSurveyEnding, type TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const startTime = Date.now();
|
||||
console.log("Starting data migration...");
|
||||
|
||||
// Fetch all surveys
|
||||
const surveys: { id: string; questions: TSurveyQuestion[]; endings: TSurveyEnding[] }[] =
|
||||
await tx.survey.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
questions: true,
|
||||
endings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (surveys.length === 0) {
|
||||
// Stop the migration if there are no surveys
|
||||
console.log("No Surveys found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all surveys that have a logic rule that has "end" as the destination
|
||||
const surveysWithEndDestination = surveys.filter((survey) =>
|
||||
survey.questions.some((question) => question.logic?.some((rule) => rule.destination === "end"))
|
||||
);
|
||||
|
||||
console.log(`Total surveys to update found: ${surveysWithEndDestination.length.toString()}`);
|
||||
|
||||
let transformedSurveyCount = 0;
|
||||
|
||||
const updatePromises = surveysWithEndDestination.map((survey) => {
|
||||
const updatedSurvey = structuredClone(survey);
|
||||
|
||||
// Remove logic rule if there are no endings
|
||||
if (updatedSurvey.endings.length === 0) {
|
||||
// remove logic rule if there are no endings
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
if (typeof question.logic === "undefined") {
|
||||
return;
|
||||
}
|
||||
question.logic.forEach((rule, index) => {
|
||||
if (rule.destination === "end") {
|
||||
if (question.logic) question.logic.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// get id of first ending
|
||||
const firstEnding = survey.endings[0];
|
||||
|
||||
// replace logic destination with ending id
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
if (typeof question.logic === "undefined") {
|
||||
return;
|
||||
}
|
||||
question.logic.forEach((rule) => {
|
||||
if (rule.destination === "end") {
|
||||
rule.destination = firstEnding.id;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
transformedSurveyCount++;
|
||||
|
||||
// Return the update promise
|
||||
return tx.survey.update({
|
||||
where: { id: survey.id },
|
||||
data: {
|
||||
questions: updatedSurvey.questions,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
console.log(`${transformedSurveyCount.toString()} surveys transformed`);
|
||||
const endTime = Date.now();
|
||||
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`);
|
||||
},
|
||||
{
|
||||
timeout: 180000, // 3 minutes
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -3,11 +3,15 @@ import { type TActionClassNoCodeConfig } from "@formbricks/types/action-classes"
|
||||
import { type TIntegrationConfig } from "@formbricks/types/integration";
|
||||
import { type TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { type TProductConfig, type TProductStyling } from "@formbricks/types/product";
|
||||
import { type TResponseData, type TResponseMeta, type TResponsePersonAttributes } from "@formbricks/types/responses";
|
||||
import {
|
||||
type TResponseData,
|
||||
type TResponseMeta,
|
||||
type TResponsePersonAttributes,
|
||||
} from "@formbricks/types/responses";
|
||||
import { type TBaseFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
type TSurveyClosedMessage,
|
||||
type TSurveyEndings,
|
||||
type TSurveyEnding,
|
||||
type TSurveyHiddenFields,
|
||||
type TSurveyProductOverwrites,
|
||||
type TSurveyQuestions,
|
||||
@@ -28,7 +32,7 @@ declare global {
|
||||
export type ResponsePersonAttributes = TResponsePersonAttributes;
|
||||
export type SurveyWelcomeCard = TSurveyWelcomeCard;
|
||||
export type SurveyQuestions = TSurveyQuestions;
|
||||
export type SurveyEndings = TSurveyEndings;
|
||||
export type SurveyEnding = TSurveyEnding;
|
||||
export type SurveyHiddenFields = TSurveyHiddenFields;
|
||||
export type SurveyProductOverwrites = TSurveyProductOverwrites;
|
||||
export type SurveyStyling = TSurveyStyling;
|
||||
|
||||
@@ -44,10 +44,11 @@
|
||||
"data-migration:segments-cleanup": "ts-node ./data-migrations/20240712123456_segments_cleanup/data-migration.ts",
|
||||
"data-migration:multiple-endings": "ts-node ./data-migrations/20240801120500_thankYouCard_to_endings/data-migration.ts",
|
||||
"data-migration:simplified-email-verification": "ts-node ./data-migrations/20240726124100_replace_verifyEmail_with_isVerifyEmailEnabled/data-migration.ts",
|
||||
"data-migration:v2.4": "pnpm data-migration:segments-cleanup && pnpm data-migration:multiple-endings && pnpm data-migration:simplified-email-verification"
|
||||
"data-migration:fix-logic-end-destination": "ts-node ./data-migrations/20240806120500_fix-logic-end-destination/data-migration.ts",
|
||||
"data-migration:v2.4": "pnpm data-migration:segments-cleanup && pnpm data-migration:multiple-endings && pnpm data-migration:simplified-email-verification && pnpm data-migration:fix-logic-end-destination"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"@prisma/extension-accelerate": "^1.1.0",
|
||||
"dotenv-cli": "^7.4.2"
|
||||
},
|
||||
@@ -56,7 +57,7 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"prisma": "^5.17.0",
|
||||
"prisma": "^5.18.0",
|
||||
"prisma-dbml-generator": "^0.12.0",
|
||||
"prisma-json-types-generator": "^3.0.4",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||