Merge branch 'main' of https://github.com/formbricks/formbricks into shubham/for-1150-validate-survey-editor-forms

This commit is contained in:
ShubhamPalriwala
2023-09-14 07:33:53 +05:30
303 changed files with 7754 additions and 7733 deletions

View File

@@ -7,5 +7,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@formbricks/formbricks-com", "@formbricks/demo"]
"ignore": ["@formbricks/formbricks-com", "@formbricks/demo", "@formbricks/web"]
}

View File

@@ -95,7 +95,6 @@ GOOGLE_CLIENT_SECRET=
# Stripe Billing Variables
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

View File

@@ -26,5 +26,8 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Lint
run: pnpm lint

View File

@@ -1,3 +1,6 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0
name: "Next.js Bundle Analysis"
on:
@@ -6,31 +9,62 @@ on:
branches:
- main
jobs:
build:
name: Production build
if: ${{ github.event_name == 'push' }}
uses: ./.github/workflows/build-production.yml
secrets: inherit
defaults:
run:
# change this if your nextjs app does not live at the root of the repo
working-directory: ./
permissions:
contents: read # for checkout repository
actions: read # for fetching base branch bundle stats
pull-requests: write # for comments
jobs:
analyze:
needs: build
if: always()
runs-on: ubuntu-latest
steps:
- name: Upload bundle
uses: actions/upload-artifact@v2
- uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
name: bundle
path: apps/web/.next/analyze/__bundle_analysis.json
node-version: 18
# If pnpm is used, you need to switch the previous step with the following one. pnpm does not create a package-lock.json
# so the step above will fail to pull dependencies
- uses: pnpm/action-setup@v2
name: Install pnpm
id: pnpm-install
with:
version: 7
run_install: true
- name: create .env
run: cp .env.example .env
- name: Restore next build
uses: actions/cache@v3
id: restore-build-cache
env:
cache-name: cache-next-build
with:
# if you use a custom build directory, replace all instances of `.next` in this file with your build directory
# ex: if your app builds to `dist`, replace `.next` with `dist`
path: apps/web/.next/cache
# change this if you prefer a more strict cache
key: ${{ runner.os }}-build-${{ env.cache-name }}
- name: Build next.js app
# change this if your site requires a custom build command
run: pnpm build --filter=web...
# Here's the first place where next-bundle-analysis' own script is used
# This step pulls the raw bundle stats for the current bundle
- name: Analyze bundle
run: |
cd apps/web
npx -p nextjs-bundle-analysis@0.5.0 report
run: npx -p nextjs-bundle-analysis report
- name: Upload bundle
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: bundle
path: apps/web/.next/analyze/__bundle_analysis.json
@@ -39,7 +73,7 @@ jobs:
uses: dawidd6/action-download-artifact@v2
if: success() && github.event.number
with:
workflow: nextjs-bundle-analysis.yml
workflow: nextjs_bundle_analysis.yml
branch: ${{ github.event.pull_request.base.ref }}
path: apps/web/.next/analyze/base
@@ -58,38 +92,34 @@ jobs:
# entry in your package.json file.
- name: Compare with base branch bundle
if: success() && github.event.number
run: |
cd apps/web
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
run: ls -laR apps/web/.next/analyze/base && npx -p nextjs-bundle-analysis compare
- name: Get comment body
- name: Get Comment Body
id: get-comment-body
if: success() && github.event.number
# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
run: |
cd apps/web
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
body="${body//'%'/'%25'}"
body="${body//$'\n'/'%0A'}"
body="${body//$'\r'/'%0D'}"
echo ::set-output name=body::$body
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$(cat apps/web/.next/analyze/__bundle_analysis_comment.txt)" >> $GITHUB_OUTPUT
echo EOF >> $GITHUB_OUTPUT
- name: Find Comment
uses: peter-evans/find-comment@v1
uses: peter-evans/find-comment@v2
if: success() && github.event.number
id: fc
with:
issue-number: ${{ github.event.number }}
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
body-includes: "<!-- __NEXTJS_BUNDLE -->"
- name: Create Comment
uses: peter-evans/create-or-update-comment@v1.4.4
uses: peter-evans/create-or-update-comment@v2
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
- name: Update Comment
uses: peter-evans/create-or-update-comment@v1.4.4
uses: peter-evans/create-or-update-comment@v2
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
with:
issue-number: ${{ github.event.number }}

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@heroicons/react": "^2.0.18",
"next": "13.4.12",
"next": "13.4.19",
"react": "18.2.0",
"react-dom": "18.2.0"
},

View File

@@ -0,0 +1,164 @@
import { Fence } from "@/components/shared/Fence";
export const meta = {
title: "Responses API",
description:
"Explore the Formbricks Public Client API for client-side tasks and integration into your website.",
};
#### Management API
# Displays API
The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information.
---
## Mark Survey as Displayed for Person {{ tag: 'POST', label: '/api/v1/client/diplays' }}
<Row>
<Col>
Mark a Survey as seen for a Person provided valid SurveyId and PersonId.
### Mandatory Request Body JSON Keys
<Properties>
<Property name="surveyId" type="string">
Survey ID to mark as viewed for a person
</Property>
</Properties>
<Properties>
<Property name="personId" type="string">
Person ID for whom mark a survey as viewed
</Property>
</Properties>
</Col>
<Col sticky>
<CodeGroup title="Request" tag="POST" label="/api/v1/displays">
```bash {{ title: 'cURL' }}
curl -X POST \
'https://app.formbricks.com/api/v1/client/displays' \
-H 'Content-Type: application/json' \
-d '{
"surveyId": "<survey-id>",
"personId": "<person-id>"
}'
```
</CodeGroup>
<CodeGroup title="Response">
```json {{title:'200 Success'}}
{
"data": {
"id": "clm4qiygr00uqs60h5f5ola5h",
"createdAt": "2023-09-04T10:24:36.603Z",
"updatedAt": "2023-09-04T10:24:36.603Z",
"surveyId": "<survey-id>",
"person": {
"id": "<person-id>",
"attributes": {
"userId": "CYO600",
"email": "wei@google.com",
"Name": "Wei Zhu",
"Role": "Manager",
"Company": "Google",
"Experience": "2 years",
"Usage Frequency": "Daily",
"Company Size": "2401 employees",
"Product Satisfaction Score": "4",
"Recommendation Likelihood": "3"
},
"createdAt": "2023-08-08T18:05:01.483Z",
"updatedAt": "2023-08-08T18:05:01.483Z"
},
"status": "seen"
}
}
```
```json {{ title: '400 Bad Request' }}
{
"code": "bad_request",
"message": "Fields are missing or incorrectly formatted",
"details": {
"surveyId": "Required"
}
}
```
</CodeGroup>
</Col>
</Row>
---
## Mark Survey as Responded for Person {{ tag: 'POST', label: '/api/v1/client/diplays/[displayId]/responded' }}
<Row>
<Col>
Mark a Displayed Survey as responded for a Person.
</Col>
<Col sticky>
<CodeGroup title="Request" tag="POST" label="/api/v1/client/diplays/[displayId]/responded">
```bash {{ title: 'cURL' }}
curl -X POST \
--location \
'https://app.formbricks.com/api/v1/client/displays/<displayId>/responded'
```
</CodeGroup>
<CodeGroup title="Response">
```json {{title:'200 Success'}}
{
"data": {
"id": "<displayId>",
"createdAt": "2023-09-04T10:24:36.603Z",
"updatedAt": "2023-09-04T10:33:56.978Z",
"surveyId": "<surveyId>",
"person": {
"id": "<personId>",
"attributes": {
"userId": "CYO600",
"email": "wei@google.com",
"Name": "Wei Zhu",
"Role": "Manager",
"Company": "Google",
"Experience": "2 years",
"Usage Frequency": "Daily",
"Company Size": "2401 employees",
"Product Satisfaction Score": "4",
"Recommendation Likelihood": "3"
},
"createdAt": "2023-08-08T18:05:01.483Z",
"updatedAt": "2023-08-08T18:05:01.483Z"
},
"status": "responded"
}
}
```
```json {{ title: '500 Internal Server Error' }}
{
"code": "internal_server_error",
"message": "Database operation failed",
"details": {}
}
```
</CodeGroup>
</Col>
</Row>
---

View File

@@ -0,0 +1,76 @@
import { Fence } from "@/components/shared/Fence";
export const meta = {
title: "Responses API",
description:
"Explore the Formbricks Public Client API for client-side tasks and integration into your website.",
};
#### Management API
# People API
The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information.
---
## Get or Create Person {{ tag: 'GET', label: '/api/v1/client/people/getOrCreate' }}
<Row>
<Col>
Fetch a Person or create (if they don't exist) by their userId and the environmentId.
### Mandatory Query Parameters
<Properties>
<Property name="userId" type="string">
User ID to fetch or create (if it does not exist)
</Property>
</Properties>
<Properties>
<Property name="enviornmentId" type="string">
Formbricks Environment ID
</Property>
</Properties>
</Col>
<Col sticky>
<CodeGroup title="Request" tag="GET" label="/api/v1/client/people/getOrCreate">
```bash {{ title: 'cURL' }}
curl --location \
'https://app.formbricks.com/api/v1/client/people/getOrCreate?userId=<user-id>&environmentId=<env-id>'
```
</CodeGroup>
<CodeGroup title="Response">
```json {{title:'200 Success'}}
{
"data": {
"person": {
"id": "clm4bcxms02hspj0ht05k6q5c",
"environmentId": "cll2m30r70004mx0huqkitgqv"
}
}
}
```
```json {{ title: '400 Bad Request' }}
{
"code": "bad_request",
"message": "Fields are missing or incorrectly formatted",
"details": {
"userId": ""
}
}
```
</CodeGroup>
</Col>
</Row>
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,136 @@
import Image from "next/image";
import UpdateQuestionId from "./update-question-id.webp";
import DuplicateSurvey from "./duplicate-survey.webp";
import CreateNewScenario from "./create-new-scenario.webp";
import SearchFormbricks from "./search-formbricks.webp";
import SelectTriggers from "./select-trigger.webp";
import CreateWebhook from "./create-webhook.webp";
import EnterApiKey from "./enter-api-key.webp";
import SelectSurvey from "./select-survey.webp";
import SubmitTestResponse from "./submit-test-response.webp";
import AddModule from "./add-module.webp";
import SelectFields from "./select-fields.webp";
import Result from "./result.webp";
import SelectAction from "./select-action.webp";
export const meta = {
title: "Make.com Setup",
description: "Wire up Formbricks with Make and 1000+ other apps",
};
#### Integrations
# Make.com Setup
Make is a powerful tool to send information between Formbricks and thousands of apps. Here's how to set it up.
<Note>
### Nail down your survey first ?
Any changes in the survey cause additional work in the _Scenario_. It
makes sense to first settle on the survey you want to run and then get to setting up Make.
</Note>
## Step 1: Setup your survey incl. `questionId` for every question
Set up the `questionId`s of your survey questions before publishing.
<Image
src={UpdateQuestionId}
alt="Update Question ID"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
_Update the Question ID field in every question card under Advanced Settings._
<Note>
### Already published? Duplicate survey
You can only update the questionId before publishing the survey. If
already published, simply duplicate it.
<Image
src={DuplicateSurvey}
alt="Duplicate Survey"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
</Note>
## Step 2: Setup Make.com
Visit [Make.com](https://make.com) to start a new scenario.
<Image
src={CreateNewScenario}
alt="Create New Scenario"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
Search for `Formbricks`:
<Image
src={SearchFormbricks}
alt="Search Formbricks"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
Choose the event to trigger the Scenario:
<Image
src={SelectTriggers}
alt="Select Triggers"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
## Step 3: Connect Formbricks with Make
Click "Create a webhook":
<Image
src={CreateWebhook}
alt="Create Webhook"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
Enter the Formbricks API key. Learn how to get one from the [API Key tutorial](/docs/api/api-key-setup).
<Image src={EnterApiKey} alt="Enter API Key" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
## Step 4: Select Survey
Choose from your created surveys:
<Image src={SelectSurvey} alt="Select Survey" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
## Step 5: Send a test response
You need a test response for Make setup. For local Formbricks setup, use the [Demo App](/docs/contributing/demo) to submit a test response.
<Image
src={SubmitTestResponse}
alt="Submit Test Response"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
## Step 6: Set up Google Sheet
Decide on the desired action for the data. Here, we'll send submissions to a Google Sheet:
<Image src={AddModule} alt="Add Module" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Choose "Add a Row" for the action:
<Image src={SelectAction} alt="Select Action" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Specify the spreadsheet details and match the Formbricks data:
<Image src={SelectFields} alt="Select Fields" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
A new row gets added to the spreadsheet for every response:
<Image src={Result} alt="Result" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -30,7 +30,7 @@ export const meta = {
n8n allows you to build flexible workflows focused on deep data integration. And with sharable templates and a user-friendly UI, the less technical people on your team can collaborate on them too. Unlike other tools, complexity is not a limitation. So you can build whatever you want — without stressing over budget. Hook up Formbricks with n8n and you can send your data to 350+ other apps. Here is how to do it.
<Note>
### Nail down your survey first {{ class: "text-white" }}
### Nail down your survey first
Any changes in the survey cause additional work in the n8n node. It makes sense to first settle on the survey
you want to run and then get to setting up n8n.
</Note>
@@ -39,12 +39,12 @@ n8n allows you to build flexible workflows focused on deep data integration. And
When setting up the node your life will be easier when you change the `questionId`s of your survey questions. You can only do so **before** you publish your survey.
<Image src={UpdateQuestionId} alt="Update Question ID" quality="100" className="rounded-lg" />
<Image src={UpdateQuestionId} alt="Update Question ID" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
_In every question card in the Advanced Settings you find the Question ID field. Update it so that you'll recognize the response tied to this question._
<Note>
### Already published? Duplicate survey {{ class: "text-white" }}
### Already published? Duplicate survey
You can only update the questionId when the survey was not yet published. Already published it? Just **duplicate
it** to update the questionIds.
<Image
@@ -59,29 +59,29 @@ _In every question card in the Advanced Settings you find the Question ID field.
Go to [n8n.io](https://n8n.io) and create a new workflow. Search for “Formbricks” to get started:
<Image src={AddFormbricksTrigger} alt="Add Formbricks Trigger" quality="100" className="rounded-lg" />
<Image src={AddFormbricksTrigger} alt="Add Formbricks Trigger" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
## Step 3: Connect Formbricks with n8n
Now, you have to connect n8n with Formbricks via an API Key:
<Image src={CreateNewCredentialBtn} alt="Create new credential button" quality="100" className="rounded-lg" />
<Image src={CreateNewCredentialBtn} alt="Create new credential button" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Click on Create New Credentail button to add your host and API Key
<Image src={AddApiKey} alt="Add host and api key" quality="100" className="rounded-lg" />
<Image src={AddApiKey} alt="Add host and api key" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Now you need an API key. Please refer to the [API Key Setup](/docs/api/api-key-setup) page to learn how to create one.
Once you copied it in the API Key field, hit Save button to test the connection and save the credentials.
<Image src={SuccessConnection} alt="Successful Connection" quality="100" className="rounded-lg" />
<Image src={SuccessConnection} alt="Successful Connection" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
## Step 4: Select Event
Next, you can choose the event you want to trigger the node on. You can select multiple events:
<Image src={SelectEvent} alt="Select Event" quality="100" className="rounded-lg" />
<Image src={SelectEvent} alt="Select Event" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Here, we are adding `Response Finished` as an event, which will trigger when the survey has been filled out.
@@ -89,25 +89,25 @@ Here, we are adding `Response Finished` as an event, which will trigger when the
Next, you can choose from all the surveys you have created in this environment. You can select multiple surveys:
<Image src={SelectSurvey} alt="Select Survey" quality="100" className="rounded-lg" />
<Image src={SelectSurvey} alt="Select Survey" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Here, we are selecting two surveys.
<Image src={SelectedSurveys} alt="Selected Surveys" quality="100" className="rounded-lg" />
<Image src={SelectedSurveys} alt="Selected Surveys" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
## Step 6: Test your trigger
In order to set up n8n you'll need a test response in the selected survey. This allows you to select the individual values of each response in your workflow. If you have Formbricks running locally and you want to set up an in-app survey, you can use our [Demo App](/docs/contributing/demo) to trigger a survey and submit a response.
<Image src={SubmitTestResponse} alt="Submit Test Response" quality="100" className="rounded-lg" />
<Image src={SubmitTestResponse} alt="Submit Test Response" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Next, click on Listen for event button.
<Image src={ListenForEvent} alt="Listen for event" quality="100" className="rounded-lg" />
<Image src={ListenForEvent} alt="Listen for event" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Then, go to the survey which you selected. Fill it out, and wait for the particular event to trigger (in this case it's `Response Finished`). Once the event is triggered you will see the response that you filled out in the survey.
<Image src={TestResponseSuccess} alt="Test Response Success" quality="100" className="rounded-lg" />
<Image src={TestResponseSuccess} alt="Test Response Success" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Now you have all the data you need at hand. The next steps depend on what you want to do with it. In this tutorial, we will send submissions to a discord channel:
@@ -115,12 +115,12 @@ Now you have all the data you need at hand. The next steps depend on what you wa
Click on the plus and search `Discord`.
<Image src={AddDiscord} alt="Add Discord" quality="100" className="rounded-lg" />
<Image src={AddDiscord} alt="Add Discord" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Fill in the `Webhook URL` and the `Content` that you want to receive in the respective discord channel. Next, click on `Execute Node` button to test the node.
<Image src={FillDiscordDetails} alt="Fill Discord Details" quality="100" className="rounded-lg" />
<Image src={FillDiscordDetails} alt="Fill Discord Details" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
Once the execution is successful, you'll receive the content in the discord channel.
<Image src={DiscordResponse} alt="Discord Response" quality="100" className="rounded-lg" />
<Image src={DiscordResponse} alt="Discord Response" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />

View File

@@ -54,39 +54,63 @@ Ensure `docker` & `docker compose` are installed on your server/system. Both are
These variables must also be provided at runtime.
| Variable | Description | Required | Default |
| --------------- | --------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------- |
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
| Variable | Description | Required | Default |
| ------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | --- |
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
| PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to `1` else to `0`. | optional (required if email services are to be enabled) | |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| CRON_SECRET | API Secret for running cron jobs. | optional | |
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
| INTERNAL_SECRET | Internal Secret (Currently we do not use this anywhere). | optional | |
| RENDER_EXTERNAL_URL | External URL for rendering (used instead of WEBAPP_URL). | optional | |
| HEROKU_APP_NAME | Heroku app name (used instead of WEBAPP_URL). | optional | |
| RAILWAY_STATIC_URL | Railway static URL (used instead of WEBAPP_URL). | optional | | |
### Build-time Variables
These variables must be provided at the time of the docker build and can be provided by updating the `.env` file.
| Variable | Description | Required | Default |
| --------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files. | required | `http://localhost:3000` |
| PRISMA_GENERATE_DATAPROXY | Enables a dedicated connection pool for Prisma using Prisma Data Proxy. Uncomment to enable. | optional | |
| NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
| NEXT_PUBLIC_PASSWORD_RESET_DISABLED | Disables password reset functionality if set to `1`. | optional | |
| NEXT_PUBLIC_SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
| NEXT_PUBLIC_INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
| NEXT_PUBLIC_PRIVACY_URL | URL for privacy policy. | optional | |
| NEXT_PUBLIC_TERMS_URL | URL for terms of service. | optional | |
| NEXT_PUBLIC_IMPRINT_URL | URL for imprint. | optional | |
| SENTRY_IGNORE_API_RESOLUTION_ERROR | Disables Sentry warning if set to `1`. | optional | |
| NEXT_PUBLIC_SENTRY_DSN | DSN for Sentry error tracking. | optional | |
| NEXT_PUBLIC_GITHUB_AUTH_ENABLED | Enables GitHub login if set to `1`. | optional | |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| NEXT_PUBLIC_GOOGLE_AUTH_ENABLED | Enables Google login if set to `1`. | optional | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| CRON_SECRET | Secret for running cron jobs. | optional | |
Please refer to the [Formbricks Instructions](https://github.com/formbricks/formbricks) for more details on generating these variables.
| Variable | Description | Required | Default |
| -------------------------------------------------- | -------------------------------------------------------------------------- | -------- | ----------------------- |
| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files. | required | `http://localhost:3000` |
| NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
| NEXT_PUBLIC_PASSWORD_RESET_DISABLED | Disables password reset functionality if set to `1`. | optional | |
| NEXT_PUBLIC_SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
| NEXT_PUBLIC_INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
| NEXT_PUBLIC_PRIVACY_URL | URL for privacy policy. | optional | |
| NEXT_PUBLIC_TERMS_URL | URL for terms of service. | optional | |
| NEXT_PUBLIC_IMPRINT_URL | URL for imprint. | optional | |
| NEXT_PUBLIC_GITHUB_AUTH_ENABLED | Enables GitHub login if set to `1`. | optional | |
| NEXT_PUBLIC_GOOGLE_AUTH_ENABLED | Enables Google login if set to `1`. | optional | |
| NEXT_PUBLIC_FORMBRICKS_API_HOST | Host for the Formbricks API. | optional | |
| NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID | If you already have a preset environment ID of the environment. | optional | |
| NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID | If you already have an onboarding survey ID to show new users. | optional | |
| NEXT_PUBLIC_IS_FORMBRICKS_CLOUD | Uses Formbricks Cloud if set to `1` | optional | |
| NEXT_PUBLIC_POSTHOG_API_KEY | API key for PostHog analytics. | optional | |
| NEXT_PUBLIC_POSTHOG_API_HOST | Host for PostHog analytics. | optional | |
| NEXT_PUBLIC_SENTRY_DSN | DSN for Sentry error tracking. | optional | |
| NEXT_PUBLIC_DOCSEARCH_APP_ID | ID of the DocSearch application. | optional | |
| NEXT_PUBLIC_DOCSEARCH_API_KEY | API key of the DocSearch application. | optional | |
| NEXT_PUBLIC_DOCSEARCH_INDEX_NAME | Name of the DocSearch index. | optional | |
| NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID | Environment ID for Formbricks (if you already have one) | optional | |
| NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID | Survey ID for the feedback survey on the docs site. | optional | |
| NEXT_PUBLIC_FORMBRICKS_COM_API_HOST | Host for the Formbricks API. | optional | |
| NEXT_PUBLIC_VERCEL_URL | Vercel URL (used instead of WEBAPP_URL). | optional |
## Debugging

View File

@@ -228,6 +228,7 @@ export const navigation: Array<NavGroup> = [
links: [
{ title: "Zapier", href: "/docs/integrations/zapier" },
{ title: "n8n", href: "/docs/integrations/n8n" },
{ title: "Make.com", href: "/docs/integrations/make" },
],
},
{
@@ -260,6 +261,8 @@ export const navigation: Array<NavGroup> = [
links: [
{ title: "Overview", href: "/docs/api/overview" },
{ title: "API Key Setup", href: "/docs/api/api-key-setup" },
{ title: "Displays", href: "/docs/api/display" },
{ title: "People", href: "/docs/api/people" },
{ title: "Responses", href: "/docs/api/responses" },
{ title: "Surveys", href: "/docs/api/surveys" },
{ title: "Webhook", href: "/docs/api/webhooks" },

View File

@@ -132,6 +132,18 @@ const nextConfig = {
},
];
},
async rewrites() {
return {
fallback: [
// These rewrites are checked after both pages/public files
// and dynamic routes are checked
{
source: "/:path*",
destination: `https://app.formbricks.com/s/:path*`,
},
],
};
},
};
export default withPlausibleProxy({ customDomain: "https://plausible.formbricks.com" })(

View File

@@ -27,22 +27,22 @@
"@next/mdx": "13.4.19",
"@paralleldrive/cuid2": "^2.2.2",
"@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.9",
"@types/node": "20.5.6",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "20.6.0",
"@types/react-highlight-words": "^0.16.4",
"acorn": "^8.10.0",
"autoprefixer": "^10.4.15",
"clsx": "^2.0.0",
"fast-glob": "^3.3.1",
"flexsearch": "^0.7.31",
"framer-motion": "10.16.1",
"framer-motion": "10.16.4",
"lottie-web": "^5.12.2",
"mdast-util-to-string": "^4.0.0",
"mdx-annotations": "^0.1.3",
"next": "13.4.19",
"next-plausible": "^3.11.1",
"next-seo": "^6.1.0",
"next-sitemap": "^4.2.2",
"next-sitemap": "^4.2.3",
"next-themes": "^0.2.1",
"node-fetch": "^3.3.2",
"prism-react-renderer": "^2.0.6",
@@ -50,17 +50,17 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-highlight-words": "^0.20.0",
"react-icons": "^4.10.1",
"react-icons": "^4.11.0",
"react-markdown": "^8.0.7",
"react-responsive-embed": "^2.1.0",
"remark": "^14.0.3",
"remark-gfm": "^3.0.1",
"remark-mdx": "^2.3.0",
"sharp": "^0.32.4",
"shiki": "^0.14.3",
"sharp": "^0.32.5",
"shiki": "^0.14.4",
"simple-functional-loader": "^1.2.1",
"tailwindcss": "^3.3.3",
"unist-util-filter": "^5.0.0",
"unist-util-filter": "^5.0.1",
"unist-util-visit": "^5.0.0",
"zustand": "^4.4.1"
},

View File

@@ -52,6 +52,9 @@ interface ArticleResponse {
}
export async function getStaticPaths() {
if (!process.env.STRAPI_API_KEY) {
return { paths: [], fallback: true };
}
const response = await fetch(
"https://strapi.formbricks.com/api/articles?populate[meta][populate]=*&filters[category][name][$eq]=learn",
{
@@ -74,6 +77,9 @@ export async function getStaticPaths() {
}
export async function getStaticProps({ params }) {
if (!process.env.STRAPI_API_KEY) {
return { props: { article: null } };
}
const res = await fetch(
`https://strapi.formbricks.com/api/articles?populate[meta][populate]=*&populate[faq][populate]=*&filters[slug][$eq]=${params.slug}`,
{

View File

@@ -6,7 +6,17 @@ import defaultTheme from "tailwindcss/defaultTheme";
import typographyStyles from "./typography";
export default {
content: ["./**/*.{js,mjs,jsx,ts,tsx,mdx}"],
trailingSlash: true,
content: [
// app content
"./app/**/*.{js,mjs,jsx,ts,tsx,mdx}", // Note the addition of the `app` directory.
"./pages/**/*.{js,mjs,jsx,ts,tsx,mdx}",
"./components/**/*.{js,mjs,jsx,ts,tsx,mdx}",
"./lib/**/*.{js,mjs,jsx,ts,tsx,mdx}",
"./mdx/**/*.{js,mjs,jsx,ts,tsx,mdx}",
// include packages if not transpiling
"../../packages/ui/components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
fontSize: {

View File

@@ -1,18 +1,10 @@
{
"extends": "@formbricks/tsconfig/nextjs.json",
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/types/*.d.ts"
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
},
"strictNullChecks": true
},

View File

@@ -1,30 +0,0 @@
# @formbricks/web
## 1.0.3
### Patch Changes
- Updated dependencies [01523393]
- @formbricks/js@1.0.4
## 1.0.2
### Patch Changes
- 3dde021c: Release version 1.0.2
- Updated dependencies [3dde021c]
- @formbricks/js@1.0.3
## 0.1.2
### Patch Changes
- Updated dependencies [a1b447ca]
- @formbricks/js@1.0.2
## 0.1.1
### Patch Changes
- Updated dependencies [3d0d633b]
- @formbricks/js@1.0.1

View File

@@ -8,8 +8,11 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "./testURLmatch";
import { createActionClass } from "@formbricks/lib/services/actionClass";
import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { useRouter } from "next/navigation";
import {
TActionClassInput,
TActionClassNoCodeConfig,
TActionClass,
} from "@formbricks/types/v1/actionClasses";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector";
@@ -18,10 +21,15 @@ interface AddNoCodeActionModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
setActionClassArray?;
}
export default function AddNoCodeActionModal({ environmentId, open, setOpen }: AddNoCodeActionModalProps) {
const router = useRouter();
export default function AddNoCodeActionModal({
environmentId,
open,
setOpen,
setActionClassArray,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
@@ -75,8 +83,13 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A
type: "noCode",
} as TActionClassInput;
await createActionClass(environmentId, updatedData);
router.refresh();
const newActionClass: TActionClass = await createActionClass(environmentId, updatedData);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,
newActionClass,
]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");

View File

@@ -1,502 +1,46 @@
"use client";
export const revalidate = REVALIDATION_INTERVAL;
import FaveIcon from "@/app/favicon.ico";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import {
changeEnvironment,
changeEnvironmentByProduct,
changeEnvironmentByTeam,
} from "@/lib/environments/changeEnvironments";
import { useEnvironment } from "@/lib/environments/environments";
import { formbricksLogout } from "@/lib/formbricks";
import { useMemberships } from "@/lib/memberships";
import { useTeam } from "@/lib/teams/teams";
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
import formbricks from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
CustomersIcon,
DashboardIcon,
ErrorComponent,
FilterIcon,
FormIcon,
Popover,
PopoverContent,
PopoverTrigger,
ProfileAvatar,
SettingsIcon,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import {
AdjustmentsVerticalIcon,
ArrowRightOnRectangleIcon,
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
CodeBracketIcon,
CreditCardIcon,
DocumentCheckIcon,
HeartIcon,
PaintBrushIcon,
PlusIcon,
UserCircleIcon,
UsersIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import { MenuIcon } from "lucide-react";
import Navigation from "@/app/(app)/environments/[environmentId]/Navigation";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
import { getProducts } from "@formbricks/lib/services/product";
import { getTeamByEnvironmentId, getTeamsByUserId } from "@formbricks/lib/services/team";
import { ErrorComponent } from "@formbricks/ui";
import type { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
interface EnvironmentsNavbarProps {
environmentId: string;
session: Session;
}
export default function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
const router = useRouter();
const pathname = usePathname();
export default async function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
const [environment, teams, team] = await Promise.all([
getEnvironment(environmentId),
getTeamsByUserId(session.user.id),
getTeamByEnvironmentId(environmentId),
]);
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const { memberships, isErrorMemberships, isLoadingMemberships } = useMemberships();
const { team } = useTeam(environmentId);
const [currentTeamName, setCurrentTeamName] = useState("");
const [currentTeamId, setCurrentTeamId] = useState("");
const [loading, setLoading] = useState(false);
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [showAddProductModal, setShowAddProductModal] = useState(false);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
} else {
setWidgetSetupCompleted(false);
}
}, [environment]);
useEffect(() => {
if (team && team.name !== "") {
setCurrentTeamName(team.name);
setCurrentTeamId(team.id);
}
}, [team]);
const navigation = useMemo(
() => [
{
name: "Surveys",
href: `/environments/${environmentId}/surveys`,
icon: FormIcon,
current: pathname?.includes("/surveys"),
},
{
name: "People",
href: `/environments/${environmentId}/people`,
icon: CustomersIcon,
current: pathname?.includes("/people"),
},
{
name: "Actions & Attributes",
href: `/environments/${environmentId}/actions`,
icon: FilterIcon,
current: pathname?.includes("/actions") || pathname?.includes("/attributes"),
},
{
name: "Integrations",
href: `/environments/${environmentId}/integrations`,
icon: DashboardIcon,
current: pathname?.includes("/integrations"),
},
{
name: "Settings",
href: `/environments/${environmentId}/settings/profile`,
icon: SettingsIcon,
current: pathname?.includes("/settings"),
},
],
[environmentId, pathname]
);
const dropdownnavigation = [
{
title: "Survey",
links: [
{
icon: AdjustmentsVerticalIcon,
label: "Product Settings",
href: `/environments/${environmentId}/settings/product`,
},
{
icon: PaintBrushIcon,
label: "Look & Feel",
href: `/environments/${environmentId}/settings/lookandfeel`,
},
],
},
{
title: "Account",
links: [
{
icon: UserCircleIcon,
label: "Profile",
href: `/environments/${environmentId}/settings/profile`,
},
{ icon: UsersIcon, label: "Team", href: `/environments/${environmentId}/settings/members` },
{
icon: CreditCardIcon,
label: "Billing & Plan",
href: `/environments/${environmentId}/settings/billing`,
hidden: !IS_FORMBRICKS_CLOUD,
},
],
},
{
title: "Setup",
links: [
{
icon: DocumentCheckIcon,
label: "Setup checklist",
href: `/environments/${environmentId}/settings/setup`,
hidden: widgetSetupCompleted,
},
{
icon: CodeBracketIcon,
label: "Developer Docs",
href: "https://formbricks.com/docs",
target: "_blank",
},
{
icon: HeartIcon,
label: "Contribute to Formbricks",
href: "https://github.com/formbricks/formbricks",
target: "_blank",
},
],
},
];
const handleEnvironmentChange = (environmentType: "production" | "development") => {
changeEnvironment(environmentType, environment, router);
};
const handleEnvironmentChangeByProduct = (productId: string) => {
changeEnvironmentByProduct(productId, environment, router);
};
const handleEnvironmentChangeByTeam = (teamId: string) => {
changeEnvironmentByTeam(teamId, memberships, router);
};
if (isLoadingEnvironment || loading || isLoadingMemberships) {
return <LoadingSpinner />;
}
if (isErrorEnvironment || isErrorMemberships || !environment || !memberships) {
if (!team || !environment) {
return <ErrorComponent />;
}
if (pathname?.includes("/edit")) return null;
const [products, environments] = await Promise.all([
getProducts(team.id),
getEnvironments(environment.productId),
]);
if (!products || !environments || !teams) {
return <ErrorComponent />;
}
return (
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
{environment?.type === "development" && (
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
You&apos;re in development mode. Use it to test surveys, actions and attributes.
</div>
)}
<div className="w-full px-4 sm:px-6">
<div className="flex h-14 justify-between">
<div className="flex space-x-4 py-2">
<Link
href={`/environments/${environmentId}/surveys/`}
className="flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
</Link>
{navigation.map((item) => {
const IconComponent: React.ElementType = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={clsx(
item.current
? "bg-slate-100 text-slate-900"
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
"hidden items-center rounded-md px-2 py-1 text-sm font-medium lg:inline-flex"
)}
aria-current={item.current ? "page" : undefined}>
<IconComponent className="mr-3 h-5 w-5" />
{item.name}
</Link>
);
})}
</div>
{/* Mobile Menu */}
<div className="flex items-center lg:hidden">
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
<span>
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
</span>
</PopoverTrigger>
<PopoverContent className="mr-4 bg-slate-100 shadow">
<div className="flex flex-col">
{navigation.map((navItem) => (
<Link key={navItem.name} href={navItem.href}>
<div
onClick={() => setMobileNavMenuOpen(false)}
className={cn(
"flex items-center space-x-2 rounded-md p-2",
navItem.current && "bg-slate-200"
)}>
<navItem.icon className="h-5 w-5" />
<span className="font-medium text-slate-600">{navItem.name}</span>
</div>
</Link>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* User Dropdown */}
<div className="hidden lg:ml-6 lg:flex lg:items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex cursor-pointer flex-row items-center space-x-5">
{session.user.image ? (
<Image
src={session.user.image}
width="100"
height="100"
className="ph-no-capture h-9 w-9 rounded-full"
alt="Profile picture"
/>
) : (
<ProfileAvatar userId={session.user.id} />
)}
<div>
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
{truncate(environment?.product?.name, 30)}
</p>
<p className="text-sm text-slate-500">{capitalizeFirstLetter(team?.name)}</p>
</div>
<ChevronDownIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="cursor-default break-all">
<span className="ph-no-capture font-normal">Signed in as </span>
{session?.user?.name.length > 30 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>{truncate(session?.user?.name, 30)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
{session?.user?.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
session?.user?.name
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Product Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<div className="flex items-center space-x-1">
<p className="">{truncate(environment?.product?.name, 20)}</p>
{!widgetSetupCompleted && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<div className="mt-0.5 h-2 w-2 rounded-full bg-amber-500 hover:bg-amber-600"></div>
</TooltipTrigger>
<TooltipContent>
<p>Your app is not connected to Formbricks.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className=" block text-xs text-slate-500">Product</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="max-w-[45rem]">
<DropdownMenuRadioGroup
value={environment?.product.id}
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
{environment?.availableProducts?.map((product) => (
<DropdownMenuRadioItem
value={product.id}
className="cursor-pointer break-all"
key={product.id}>
{product?.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowAddProductModal(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<span>Add product</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Team Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<p>{currentTeamName}</p>
<p className="block text-xs text-slate-500">Team</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={currentTeamId}
onValueChange={(teamId) => handleEnvironmentChangeByTeam(teamId)}>
{memberships?.map((membership) => (
<DropdownMenuRadioItem
value={membership.teamId}
className="cursor-pointer"
key={membership.teamId}>
{membership?.team?.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowCreateTeamModal(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<span>Create team</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Environment Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<p>{capitalizeFirstLetter(environment?.type)}</p>
<p className=" block text-xs text-slate-500">Environment</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={environment?.type}
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
<DropdownMenuRadioItem value="production" className="cursor-pointer">
Production
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="development" className="cursor-pointer">
Development
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{dropdownnavigation.map((item) => (
<DropdownMenuGroup key={item.title}>
<DropdownMenuSeparator />
{item.links.map(
(link) =>
!link.hidden && (
<Link href={link.href} target={link.target} key={link.label}>
<DropdownMenuItem key={link.label}>
<div className="flex items-center">
<link.icon className="mr-2 h-4 w-4" />
<span>{link.label}</span>
</div>
</DropdownMenuItem>
</Link>
)
)}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuGroup>
{IS_FORMBRICKS_CLOUD && (
<DropdownMenuItem>
<button
onClick={() => {
formbricks.track("Top Menu: Product Feedback");
}}>
<div className="flex items-center">
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
<span>Product Feedback</span>
</div>
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={async () => {
setLoading(true);
await signOut();
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Logout
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<AddProductModal
open={showAddProductModal}
setOpen={(val) => setShowAddProductModal(val)}
environmentId={environmentId}
/>
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
</nav>
<Navigation
environment={environment}
team={team}
teams={teams}
products={products}
environments={environments}
session={session}
/>
);
}

View File

@@ -0,0 +1,20 @@
export default function NavbarLoading() {
return (
<div>
<div className="flex justify-between space-x-4 px-4 py-2">
<div className="flex">
<div className=" mx-2 h-8 w-8 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
</div>
<div className="flex">
<div className=" mx-2 h-8 w-8 animate-pulse rounded-full bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,495 @@
"use client";
import FaveIcon from "@/app/favicon.ico";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import { formbricksLogout } from "@/lib/formbricks";
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
import formbricks from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { TTeam } from "@formbricks/types/v1/teams";
import {
CustomersIcon,
DashboardIcon,
FilterIcon,
FormIcon,
Popover,
PopoverContent,
PopoverTrigger,
ProfileAvatar,
SettingsIcon,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import {
AdjustmentsVerticalIcon,
ArrowRightOnRectangleIcon,
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
CodeBracketIcon,
CreditCardIcon,
DocumentCheckIcon,
HeartIcon,
PaintBrushIcon,
PlusIcon,
UserCircleIcon,
UsersIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import { MenuIcon } from "lucide-react";
import type { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
interface NavigationProps {
environment: TEnvironment;
teams: TTeam[];
session: Session;
team: TTeam;
products: TProduct[];
environments: TEnvironment[];
}
export default function Navigation({
environment,
teams,
team,
session,
products,
environments,
}: NavigationProps) {
const router = useRouter();
const pathname = usePathname();
const [currentTeamName, setCurrentTeamName] = useState("");
const [currentTeamId, setCurrentTeamId] = useState("");
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [showAddProductModal, setShowAddProductModal] = useState(false);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const product = products.find((product) => product.id === environment.productId);
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
} else {
setWidgetSetupCompleted(false);
}
}, [environment]);
useEffect(() => {
if (team && team.name !== "") {
setCurrentTeamName(team.name);
setCurrentTeamId(team.id);
}
}, [team]);
const navigation = useMemo(
() => [
{
name: "Surveys",
href: `/environments/${environment.id}/surveys`,
icon: FormIcon,
current: pathname?.includes("/surveys"),
},
{
name: "People",
href: `/environments/${environment.id}/people`,
icon: CustomersIcon,
current: pathname?.includes("/people"),
},
{
name: "Actions & Attributes",
href: `/environments/${environment.id}/actions`,
icon: FilterIcon,
current: pathname?.includes("/actions") || pathname?.includes("/attributes"),
},
{
name: "Integrations",
href: `/environments/${environment.id}/integrations`,
icon: DashboardIcon,
current: pathname?.includes("/integrations"),
},
{
name: "Settings",
href: `/environments/${environment.id}/settings/profile`,
icon: SettingsIcon,
current: pathname?.includes("/settings"),
},
],
[environment.id, pathname]
);
const dropdownnavigation = [
{
title: "Survey",
links: [
{
icon: AdjustmentsVerticalIcon,
label: "Product Settings",
href: `/environments/${environment.id}/settings/product`,
},
{
icon: PaintBrushIcon,
label: "Look & Feel",
href: `/environments/${environment.id}/settings/lookandfeel`,
},
],
},
{
title: "Account",
links: [
{
icon: UserCircleIcon,
label: "Profile",
href: `/environments/${environment.id}/settings/profile`,
},
{ icon: UsersIcon, label: "Team", href: `/environments/${environment.id}/settings/members` },
{
icon: CreditCardIcon,
label: "Billing & Plan",
href: `/environments/${environment.id}/settings/billing`,
hidden: !IS_FORMBRICKS_CLOUD,
},
],
},
{
title: "Setup",
links: [
{
icon: DocumentCheckIcon,
label: "Setup checklist",
href: `/environments/${environment.id}/settings/setup`,
hidden: widgetSetupCompleted,
},
{
icon: CodeBracketIcon,
label: "Developer Docs",
href: "https://formbricks.com/docs",
target: "_blank",
},
{
icon: HeartIcon,
label: "Contribute to Formbricks",
href: "https://github.com/formbricks/formbricks",
target: "_blank",
},
],
},
];
const handleEnvironmentChange = (environmentType: "production" | "development") => {
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
if (newEnvironmentId) {
router.push(`/environments/${newEnvironmentId}/`);
}
};
const handleEnvironmentChangeByProduct = (productId: string) => {
router.push(`/products/${productId}/`);
};
const handleEnvironmentChangeByTeam = (teamId: string) => {
router.push(`/teams/${teamId}/`);
};
if (pathname?.includes("/edit")) return null;
return (
<>
{product && (
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
{environment?.type === "development" && (
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
You&apos;re in development mode. Use it to test surveys, actions and attributes.
</div>
)}
<div className="w-full px-4 sm:px-6">
<div className="flex h-14 justify-between">
<div className="flex space-x-4 py-2">
<Link
href={`/environments/${environment.id}/surveys/`}
className="flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
</Link>
{navigation.map((item) => {
const IconComponent: React.ElementType = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={clsx(
item.current
? "bg-slate-100 text-slate-900"
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
"hidden items-center rounded-md px-2 py-1 text-sm font-medium lg:inline-flex"
)}
aria-current={item.current ? "page" : undefined}>
<IconComponent className="mr-3 h-5 w-5" />
{item.name}
</Link>
);
})}
</div>
{/* Mobile Menu */}
<div className="flex items-center lg:hidden">
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
<span>
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
</span>
</PopoverTrigger>
<PopoverContent className="mr-4 bg-slate-100 shadow">
<div className="flex flex-col">
{navigation.map((navItem) => (
<Link key={navItem.name} href={navItem.href}>
<div
onClick={() => setMobileNavMenuOpen(false)}
className={cn(
"flex items-center space-x-2 rounded-md p-2",
navItem.current && "bg-slate-200"
)}>
<navItem.icon className="h-5 w-5" />
<span className="font-medium text-slate-600">{navItem.name}</span>
</div>
</Link>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* User Dropdown */}
<div className="hidden lg:ml-6 lg:flex lg:items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex cursor-pointer flex-row items-center space-x-5">
{session.user.image ? (
<Image
src={session.user.image}
width="100"
height="100"
className="ph-no-capture h-9 w-9 rounded-full"
alt="Profile picture"
/>
) : (
<ProfileAvatar userId={session.user.id} />
)}
<div>
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
{truncate(product!.name, 30)}
</p>
<p className="text-sm text-slate-500">{capitalizeFirstLetter(team?.name)}</p>
</div>
<ChevronDownIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="cursor-default break-all">
<span className="ph-no-capture font-normal">Signed in as </span>
{session?.user?.name.length > 30 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>{truncate(session?.user?.name, 30)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
{session?.user?.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
session?.user?.name
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Product Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<div className="flex items-center space-x-1">
<p className="">{truncate(product!.name, 20)}</p>
{!widgetSetupCompleted && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<div className="mt-0.5 h-2 w-2 rounded-full bg-amber-500 hover:bg-amber-600"></div>
</TooltipTrigger>
<TooltipContent>
<p>Your app is not connected to Formbricks.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className=" block text-xs text-slate-500">Product</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="max-w-[45rem]">
<DropdownMenuRadioGroup
value={product!.id}
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
{products.map((product) => (
<DropdownMenuRadioItem
value={product.id}
className="cursor-pointer break-all"
key={product.id}>
{product?.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowAddProductModal(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<span>Add product</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Team Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<p>{currentTeamName}</p>
<p className="block text-xs text-slate-500">Team</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={currentTeamId}
onValueChange={(teamId) => handleEnvironmentChangeByTeam(teamId)}>
{teams?.map((team) => (
<DropdownMenuRadioItem value={team.id} className="cursor-pointer" key={team.id}>
{team.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowCreateTeamModal(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<span>Create team</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Environment Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<p>{capitalizeFirstLetter(environment?.type)}</p>
<p className=" block text-xs text-slate-500">Environment</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={environment?.type}
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
<DropdownMenuRadioItem value="production" className="cursor-pointer">
Production
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="development" className="cursor-pointer">
Development
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{dropdownnavigation.map((item) => (
<DropdownMenuGroup key={item.title}>
<DropdownMenuSeparator />
{item.links.map(
(link) =>
!link.hidden && (
<Link href={link.href} target={link.target} key={link.label}>
<DropdownMenuItem key={link.label}>
<div className="flex items-center">
<link.icon className="mr-2 h-4 w-4" />
<span>{link.label}</span>
</div>
</DropdownMenuItem>
</Link>
)
)}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuGroup>
{IS_FORMBRICKS_CLOUD && (
<DropdownMenuItem>
<button
onClick={() => {
formbricks.track("Top Menu: Product Feedback");
}}>
<div className="flex items-center">
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
<span>Product Feedback</span>
</div>
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={async () => {
await signOut();
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Logout
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<AddProductModal
open={showAddProductModal}
setOpen={(val) => setShowAddProductModal(val)}
environmentId={environment.id}
/>
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
</nav>
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
"use server";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/errors";
import { ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
import { Team } from "@prisma/client";

View File

@@ -2,6 +2,7 @@ import JsLogo from "@/images/jslogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import n8nLogo from "@/images/n8n.png";
import MakeLogo from "@/images/make-small.png";
import { Card } from "@formbricks/ui";
import Image from "next/image";
@@ -52,6 +53,17 @@ export default function IntegrationsPage({ params }) {
description="Integrate Formbricks with 350+ apps via n8n"
icon={<Image src={n8nLogo} alt="n8n Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/make"
docsText="Docs"
docsNewTab={true}
connectHref="https://www.make.com/en/integrations/formbricks"
connectText="Connect"
connectNewTab={true}
label="Make.com"
description="Integrate Formbricks with 1000+ apps via Make"
icon={<Image src={MakeLogo} alt="Make Logo" />}
/>
</div>
</div>
);

View File

@@ -1,9 +1,12 @@
export const revalidate = REVALIDATION_INTERVAL;
import WebhookRowData from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData";
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading";
import GoBackButton from "@/components/shared/GoBackButton";
import { getSurveys } from "@formbricks/lib/services/survey";
import { getWebhooks } from "@formbricks/lib/services/webhook";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
export default async function CustomWebhookPage({ params }) {
const webhooks = (await getWebhooks(params.environmentId)).sort((a, b) => {

View File

@@ -22,6 +22,7 @@ export function DeletePersonButton({ environmentId, personId }: DeletePersonButt
try {
setIsDeletingPerson(true);
await deletePersonAction(personId);
router.refresh();
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
} catch (error) {

View File

@@ -1,10 +1,11 @@
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
ActivityItemPopover,
ActivityItemIcon,
ActivityItemPopover,
} from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityItemComponents";
import { BackIcon } from "@formbricks/ui";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
import { BackIcon } from "@formbricks/ui";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { TrashIcon } from "lucide-react";
export default function Loading() {
const unifiedList: TActivityFeedItem[] = [

View File

@@ -20,11 +20,20 @@ export default async function ApiKeyList({
};
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
const environments = await getEnvironments(product.id);
const environmentTypeId = findEnvironmentByType(environments, environmentType);
const apiKeys = await getApiKeys(environmentTypeId);
return (
<EditApiKeys environmentTypeId={environmentTypeId} environmentType={environmentType} apiKeys={apiKeys} />
<EditApiKeys
environmentTypeId={environmentTypeId}
environmentType={environmentType}
apiKeys={apiKeys}
environmentId={environmentId}
/>
);
}

View File

@@ -15,10 +15,12 @@ export default function EditAPIKeys({
environmentTypeId,
environmentType,
apiKeys,
environmentId,
}: {
environmentTypeId: string;
environmentType: string;
apiKeys: TApiKey[];
environmentId: string;
}) {
const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false);
const [isDeleteKeyModalOpen, setOpenDeleteKeyModal] = useState(false);
@@ -52,6 +54,7 @@ export default function EditAPIKeys({
<div className="mb-6 text-right">
<Button
variant="darkCTA"
disabled={environmentId !== environmentTypeId}
onClick={() => {
setOpenAddAPIKeyModal(true);
}}>

View File

@@ -4,21 +4,28 @@ import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import ApiKeyList from "./ApiKeyList";
import EnvironmentNotice from "@/components/shared/EnvironmentNotice";
import { getEnvironment } from "@formbricks/lib/services/environment";
export default async function ProfileSettingsPage({ params }) {
const environment = await getEnvironment(params.environmentId);
return (
<div>
<SettingsTitle title="API Keys" />
<SettingsCard
title="Development Env Keys"
description="Add and remove API keys for your Development environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="development" />
</SettingsCard>
<SettingsCard
title="Production Env Keys"
description="Add and remove API keys for your Production environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="production" />
</SettingsCard>
<EnvironmentNotice environment={environment} />
{environment.type === "development" ? (
<SettingsCard
title="Development Env Keys"
description="Add and remove API keys for your Development environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="development" />
</SettingsCard>
) : (
<SettingsCard
title="Production Env Keys"
description="Add and remove API keys for your Production environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="production" />
</SettingsCard>
)}
</div>
);
}

View File

@@ -11,6 +11,9 @@ import { EditHighlightBorder } from "./EditHighlightBorder";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const product = await getProductByEnvironmentId(params.environmentId);
if (!product) {
throw new Error("Product not found");
}
return (
<div>
<SettingsTitle title="Look & Feel" />

View File

@@ -1,41 +0,0 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { ErrorComponent } from "@formbricks/ui";
import { LightBulbIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
export default function EnvironmentNotice({ environmentId }: { environmentId: string }) {
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const router = useRouter();
const changeEnvironment = (environmentType: string) => {
const newEnvironmentId = environment.product.environments.find((e) => e.type === environmentType)?.id;
router.push(`/environments/${newEnvironmentId}/`);
};
if (isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorEnvironment) {
return <ErrorComponent />;
}
return (
<div>
{environment.type === "production" && !environment.widgetSetupCompleted && (
<div className="flex items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<LightBulbIcon className="mr-3 h-6 w-6 text-blue-400" />
<p>
You&apos;re currently in the Production environment.
<a onClick={() => changeEnvironment("development")} className="ml-1 cursor-pointer underline">
Switch to Development environment.
</a>
</p>
</div>
)}
</div>
);
}

View File

@@ -4,11 +4,11 @@ import CodeBlock from "@/components/shared/CodeBlock";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TabBar } from "@formbricks/ui";
import Link from "next/link";
import Prism from "prismjs";
import "prismjs/themes/prism.css";
import { useEffect, useState } from "react";
import { useState } from "react";
import { IoLogoHtml5, IoLogoNpm } from "react-icons/io5";
import packageJson from "@/package.json";
import { WEBAPP_URL } from "@formbricks/lib/constants";
const tabs = [
{ id: "npm", label: "NPM", icon: <IoLogoNpm /> },
@@ -18,10 +18,6 @@ const tabs = [
export default function SetupInstructions({ environmentId }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
useEffect(() => {
Prism.highlightAll();
}, [activeTab]);
return (
<div>
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} />
@@ -37,10 +33,8 @@ export default function SetupInstructions({ environmentId }) {
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${typeof window !== "undefined" && window.location.protocol}//${
typeof window !== "undefined" && window.location.host
}",
debug: true, // remove when in production
apiHost: "${WEBAPP_URL}",
debug: true, // remove when in production
});
}`}</CodeBlock>

View File

@@ -0,0 +1,11 @@
"use server";
import { updateEnvironment } from "@formbricks/lib/services/environment";
import { TEnvironment, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
export async function updateEnvironmentAction(
environmentId: string,
data: Partial<TEnvironmentUpdateInput>
): Promise<TEnvironment> {
return await updateEnvironment(environmentId, data);
}

View File

@@ -0,0 +1,50 @@
function LoadingCard({ title, description, skeletonLines }) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse bg-gray-200 ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
}
export default function Loading() {
const cards = [
{
title: "Widget Status",
description: "Check if the Formbricks widget is alive and kicking.",
skeletonLines: [{ classes: "h-32 max-w-full rounded-md" }],
},
{
title: "How to setup",
description: "Follow these steps to setup the Formbricks widget within your app",
skeletonLines: [
{ classes: "h-6 w-24 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-6 w-24 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
],
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Setup Checklist</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,24 +1,51 @@
export const revalidate = REVALIDATION_INTERVAL;
import { updateEnvironmentAction } from "@/app/(app)/environments/[environmentId]/settings/setup/actions";
import EnvironmentNotice from "@/components/shared/EnvironmentNotice";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getActionsByEnvironmentId } from "@formbricks/lib/services/actions";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { ErrorComponent } from "@formbricks/ui";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import EnvironmentNotice from "./EnvironmentNotice";
import SetupInstructions from "./SetupInstructions";
export default function ProfileSettingsPage({ params }) {
return (
<div className="space-y-4">
<SettingsTitle title="Setup Checklist" />
<SettingsCard title="Widget Status" description="Check if the Formbricks widget is alive and kicking.">
<WidgetStatusIndicator environmentId={params.environmentId} type="large" />
</SettingsCard>
export default async function ProfileSettingsPage({ params }) {
const [environment, actions] = await Promise.all([
await getEnvironment(params.environmentId),
getActionsByEnvironmentId(params.environmentId),
]);
<EnvironmentNotice environmentId={params.environmentId} />
<SettingsCard
title="How to setup"
description="Follow these steps to setup the Formbricks widget within your app"
noPadding>
<SetupInstructions environmentId={params.environmentId} />
</SettingsCard>
</div>
if (!environment) {
return <ErrorComponent />;
}
return (
<>
{environment && (
<div className="space-y-4">
<SettingsTitle title="Setup Checklist" />
<EnvironmentNotice environment={environment} />
<SettingsCard
title="Widget Status"
description="Check if the Formbricks widget is alive and kicking.">
<WidgetStatusIndicator
environment={environment}
actions={actions}
type="large"
updateEnvironmentAction={updateEnvironmentAction}
/>
</SettingsCard>
<SettingsCard
title="How to setup"
description="Follow these steps to setup the Formbricks widget within your app"
noPadding>
<SetupInstructions environmentId={params.environmentId} />
</SettingsCard>
</div>
)}
</>
);
}

View File

@@ -1,335 +1,53 @@
"use client";
import FormbricksSignature from "@/components/preview/FormbricksSignature";
import Modal from "@/components/preview/Modal";
import Progress from "@/components/preview/Progress";
import QuestionConditional from "@/components/preview/QuestionConditional";
import TabOption from "@/components/preview/TabOption";
import ThankYouCard from "@/components/preview/ThankYouCard";
import type { Logic, Question } from "@formbricks/types/questions";
import { SurveyInline } from "@/components/shared/Survey";
import { Survey } from "@formbricks/types/surveys";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
import { ComputerDesktopIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/solid";
import { useEffect, useRef, useState } from "react";
interface PreviewSurveyProps {
survey: TSurvey | Survey;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
questions: Question[];
brandColor: string;
environmentId: string;
surveyType: Survey["type"];
thankYouCard: Survey["thankYouCard"];
autoClose: Survey["autoClose"];
previewType?: "modal" | "fullwidth" | "email";
product: TProduct;
environment: TEnvironment;
}
function QuestionRenderer({
activeQuestionId,
lastActiveQuestionId,
questions,
brandColor,
thankYouCard,
gotoNextQuestion,
showBackButton,
goToPreviousQuestion,
storedResponseValue,
}) {
return (
<div>
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard
brandColor={brandColor}
headline={thankYouCard?.headline || "Thank you!"}
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
/>
) : (
questions.map((question, idx) =>
(activeQuestionId || lastActiveQuestionId) === question.id ? (
<QuestionConditional
key={question.id}
question={question}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
)
)}
</div>
);
}
function PreviewModalContent({
activeQuestionId,
lastActiveQuestionId,
questions,
brandColor,
thankYouCard,
gotoNextQuestion,
showBackButton,
goToPreviousQuestion,
storedResponseValue,
showFormbricksSignature,
}) {
return (
<div className="px-4 py-6 sm:p-6">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
/>
{showFormbricksSignature && <FormbricksSignature />}
</div>
);
}
export default function PreviewSurvey({
survey,
setActiveQuestionId,
activeQuestionId,
questions,
brandColor,
surveyType,
thankYouCard,
autoClose,
previewType,
product,
environment,
}: PreviewSurveyProps) {
const [isModalOpen, setIsModalOpen] = useState(true);
const [progress, setProgress] = useState(0); // [0, 1]
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [lastActiveQuestionId, setLastActiveQuestionId] = useState("");
const [showFormbricksSignature, setShowFormbricksSignature] = useState(false);
const [finished, setFinished] = useState(false);
const [storedResponseValue, setStoredResponseValue] = useState<any>();
const [storedResponse, setStoredResponse] = useState<Record<string, any>>({});
const [previewMode, setPreviewMode] = useState("desktop");
const showBackButton = progress !== 0 && !finished;
const ContentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (product) {
setShowFormbricksSignature(product.formbricksSignature);
}
}, [product]);
const [countdownProgress, setCountdownProgress] = useState(1);
const startRef = useRef(performance.now());
const frameRef = useRef<number | null>(null);
const [countdownStop, setCountdownStop] = useState(false);
const handleStopCountdown = () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
setCountdownStop(true);
}
};
useEffect(() => {
if (!autoClose) return;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
}
const frame = () => {
if (!autoClose || !startRef.current) return;
const timeout = autoClose * 1000;
const elapsed = performance.now() - startRef.current;
const remaining = Math.max(0, timeout - elapsed);
setCountdownProgress(remaining / timeout);
if (remaining > 0) {
frameRef.current = requestAnimationFrame(frame);
} else {
handleStopCountdown();
setIsModalOpen(false);
// reopen the modal after 1 second
setTimeout(() => {
setIsModalOpen(true);
setActiveQuestionId(questions[0]?.id || ""); // set first question as active
}, 1500);
}
};
setCountdownStop(false);
setCountdownProgress(1);
startRef.current = performance.now();
frameRef.current = requestAnimationFrame(frame);
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
}
};
}, [autoClose]);
useEffect(() => {
if (ContentRef.current) {
// scroll to top whenever question changes
ContentRef.current.scrollTop = 0;
}
if (activeQuestionId !== "end") {
setFinished(false);
}
if (activeQuestionId) {
setLastActiveQuestionId(activeQuestionId);
setProgress(calculateProgress(questions, activeQuestionId));
} else if (lastActiveQuestionId) {
setProgress(calculateProgress(questions, lastActiveQuestionId));
}
function calculateProgress(questions, activeQuestionId) {
if (activeQuestionId === "thank-you-card") return 1;
const elementIdx = questions.findIndex((e) => e.id === activeQuestionId);
return elementIdx / questions.length;
}
}, [activeQuestionId, lastActiveQuestionId, questions]);
useEffect(() => {
// close modal if there are no questions left
if (surveyType === "web" && !thankYouCard.enabled) {
if (survey.type === "web" && !survey.thankYouCard.enabled) {
if (activeQuestionId === "thank-you-card") {
setIsModalOpen(false);
setTimeout(() => {
setActiveQuestionId(questions[0].id);
setActiveQuestionId(survey.questions[0].id);
setIsModalOpen(true);
}, 500);
}
}
}, [activeQuestionId, surveyType, questions, setActiveQuestionId, thankYouCard]);
function evaluateCondition(logic: Logic, responseValue: any): boolean {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(responseValue) &&
responseValue.length === 1 &&
responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return responseValue === "accepted";
case "clicked":
return responseValue === "clicked";
case "submitted":
if (typeof responseValue === "string") {
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "dismissed"
);
default:
return false;
}
}
function getNextQuestion(answer: any): string {
// extract activeQuestionId from answer to make it work when form is collapsed.
const activeQuestionId = Object.keys(answer)[0];
if (!activeQuestionId) return "";
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const responseValue = answer[activeQuestionId];
const currentQuestion = questions[currentQuestionIndex];
if (currentQuestion.logic && currentQuestion.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
}
return questions[currentQuestionIndex + 1]?.id || "end";
}
const gotoNextQuestion = (data) => {
setStoredResponse({ ...storedResponse, ...data });
const nextQuestionId = getNextQuestion(data);
setStoredResponseValue(storedResponse[nextQuestionId]);
if (nextQuestionId !== "end") {
setActiveQuestionId(nextQuestionId);
} else {
setFinished(true);
if (thankYouCard?.enabled) {
setActiveQuestionId("thank-you-card");
setProgress(1);
} else {
setIsModalOpen(false);
setTimeout(() => {
setActiveQuestionId(questions[0].id);
setIsModalOpen(true);
}, 500);
}
}
};
function goToPreviousQuestion(data: any) {
setStoredResponse({ ...storedResponse, ...data });
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const previousQuestionId = questions[currentQuestionIndex - 1].id;
setStoredResponseValue(storedResponse[previousQuestionId]);
setActiveQuestionId(previousQuestionId);
}
}, [activeQuestionId, survey.type, survey, setActiveQuestionId]);
function resetQuestionProgress() {
setProgress(0);
setActiveQuestionId(questions[0].id);
setStoredResponse({});
setActiveQuestionId(survey.questions[0].id);
}
useEffect(() => {
@@ -365,22 +83,14 @@ export default function PreviewSurvey({
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
previewMode="mobile">
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<PreviewModalContent
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
showFormbricksSignature={showFormbricksSignature}
<SurveyInline
survey={survey}
brandColor={product.brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
/>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
<div
@@ -388,25 +98,15 @@ export default function PreviewSurvey({
ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md px-4">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
<SurveyInline
survey={survey}
brandColor={product.brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
/>
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</div>
</div>
</div>
)}
</div>
@@ -432,46 +132,29 @@ export default function PreviewSurvey({
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
previewMode="desktop">
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<PreviewModalContent
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
showFormbricksSignature={showFormbricksSignature}
<SurveyInline
survey={survey}
brandColor={product.brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
/>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
<div className="flex flex-grow flex-col overflow-y-auto" ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white p-4 py-6">
<div className="w-full max-w-md">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
<SurveyInline
survey={survey}
brandColor={product.brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
/>
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</div>
</div>
</div>
)}
</div>

View File

@@ -27,7 +27,7 @@ import {
} from "@heroicons/react/24/solid";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
interface SurveyDropDownMenuProps {
@@ -35,6 +35,7 @@ interface SurveyDropDownMenuProps {
survey: TSurveyWithAnalytics;
environment: TEnvironment;
otherEnvironment: TEnvironment;
surveyBaseUrl: string;
}
export default function SurveyDropDownMenu({
@@ -42,11 +43,14 @@ export default function SurveyDropDownMenu({
survey,
environment,
otherEnvironment,
surveyBaseUrl,
}: SurveyDropDownMenuProps) {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const router = useRouter();
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey]);
const handleDeleteSurvey = async (survey) => {
setLoading(true);
try {
@@ -161,9 +165,7 @@ export default function SurveyDropDownMenu({
<button
className="flex w-full items-center"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
);
navigator.clipboard.writeText(surveyUrl);
toast.success("Copied link to clipboard");
}}>
<LinkIcon className="mr-2 h-4 w-4" />

View File

@@ -10,13 +10,18 @@ import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Badge } from "@formbricks/ui";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
const environment = await getEnvironment(environmentId);
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
const environments: TEnvironment[] = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
const totalSubmissions = surveys.reduce((acc, survey) => acc + (survey.analytics?.numResponses || 0), 0);
if (surveys.length === 0) {
@@ -88,7 +93,8 @@ export default async function SurveysList({ environmentId }: { environmentId: st
key={`survey-${survey.id}`}
environmentId={environmentId}
environment={environment}
otherEnvironment={otherEnvironment}
otherEnvironment={otherEnvironment!}
surveyBaseUrl={SURVEY_BASE_URL}
/>
</div>
</div>

View File

@@ -1,10 +1,10 @@
"use client";
import { Template } from "@/../../packages/types/templates";
import { createSurveyAction } from "./actions";
import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { TTemplate } from "@formbricks/types/v1/templates";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -20,7 +20,7 @@ export default function SurveyStarter({
}) {
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
const router = useRouter();
const newSurveyFromTemplate = async (template: Template) => {
const newSurveyFromTemplate = async (template: TTemplate) => {
setIsCreateSurveyLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const autoComplete = surveyType === "web" ? 50 : null;

View File

@@ -1,11 +1,12 @@
"use client";
import { updateResponseNoteAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
import { useProfile } from "@/lib/profile";
import { addResponseNote } from "@/lib/responseNotes/responsesNotes";
import { timeSince } from "@formbricks/lib/time";
import { TResponseNote } from "@formbricks/types/v1/responses";
import { Button } from "@formbricks/ui";
import { PlusIcon } from "@heroicons/react/24/outline";
import { MinusIcon } from "@heroicons/react/24/solid";
import { MinusIcon, PencilIcon, PlusIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { Maximize2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -30,8 +31,11 @@ export default function ResponseNotes({
setIsOpen,
}: ResponseNotesProps) {
const router = useRouter();
const { profile } = useProfile();
const [noteText, setNoteText] = useState("");
const [isCreatingNote, setIsCreatingNote] = useState(false);
const [isUpdatingNote, setIsUpdatingNote] = useState(false);
const [noteId, setNoteId] = useState("");
const divRef = useRef<HTMLDivElement>(null);
const handleNoteSubmission = async (e: FormEvent) => {
@@ -48,6 +52,26 @@ export default function ResponseNotes({
}
};
const handleEditPencil = (note: TResponseNote) => {
setNoteText(note.text);
setIsUpdatingNote(true);
setNoteId(note.id);
};
const handleNoteUpdate = async (e: FormEvent) => {
e.preventDefault();
setIsUpdatingNote(true);
try {
await updateResponseNoteAction(responseId, noteId, noteText);
router.refresh();
setIsUpdatingNote(false);
setNoteText("");
} catch (e) {
toast.error("An error occurred updating a note");
setIsUpdatingNote(false);
}
};
useEffect(() => {
if (divRef.current) {
divRef.current.scrollTop = divRef.current.scrollHeight;
@@ -123,13 +147,20 @@ export default function ResponseNotes({
{timeSince(note.updatedAt.toISOString())}
</time>
</span>
<span className="block text-slate-700">{note.text}</span>
<div className="group/notetext flex items-center">
<span className="block pr-1 text-slate-700">{note.text}</span>
{profile.id === note.user.id && (
<button onClick={() => handleEditPencil(note)}>
<PencilIcon className=" h-3 w-3 text-gray-500 opacity-0 group-hover/notetext:opacity-100" />
</button>
)}
</div>
</div>
))}
</div>
<div className="h-[120px]">
<div className={clsx("absolute bottom-0 w-full px-3 pb-3", !notes.length && "absolute bottom-0")}>
<form onSubmit={handleNoteSubmission}>
<form onSubmit={isUpdatingNote ? handleNoteUpdate : handleNoteSubmission}>
<div className="mt-4">
<textarea
rows={2}
@@ -140,14 +171,16 @@ export default function ResponseNotes({
onKeyDown={(e) => {
if (e.key === "Enter" && noteText) {
e.preventDefault();
handleNoteSubmission(e);
{
isUpdatingNote ? handleNoteUpdate(e) : handleNoteSubmission(e);
}
}
}}
required></textarea>
</div>
<div className="mt-2 flex w-full justify-end">
<Button variant="darkCTA" size="sm" type="submit" loading={isCreatingNote}>
Send
{isUpdatingNote ? "Save" : "Send"}
</Button>
</div>
</form>

View File

@@ -16,9 +16,10 @@ interface ResponsePageProps {
survey: TSurvey;
surveyId: string;
responses: TResponse[];
surveyBaseUrl: string;
}
const ResponsePage = ({ environmentId, survey, surveyId, responses }: ResponsePageProps) => {
const ResponsePage = ({ environmentId, survey, surveyId, responses, surveyBaseUrl }: ResponsePageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const searchParams = useSearchParams();
@@ -35,7 +36,12 @@ const ResponsePage = ({ environmentId, survey, surveyId, responses }: ResponsePa
}, [selectedFilter, responses, survey, dateRange]);
return (
<ContentWrapper>
<SummaryHeader environmentId={environmentId} survey={survey} surveyId={surveyId} />
<SummaryHeader
environmentId={environmentId}
survey={survey}
surveyId={surveyId}
surveyBaseUrl={surveyBaseUrl}
/>
<CustomFilter
environmentId={environmentId}
responses={filterResponses}

View File

@@ -167,7 +167,9 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
<RatingResponse scale={response.scale} answer={response.answer} range={response.range} />
</div>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer}</p>
<p className="ph-no-capture my-1 whitespace-pre-wrap font-semibold text-slate-700">
{response.answer}
</p>
)
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">

View File

@@ -87,8 +87,8 @@ const TagsCombobox: React.FC<ITagsComboboxProps> = ({
onKeyDown={(e) => {
if (e.key === "Enter" && searchValue !== "") {
if (
!tagsToSearch?.find((tag) =>
tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
!tagsToSearch?.find(
(tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
)
) {
createTag?.(searchValue);

View File

@@ -0,0 +1,7 @@
"use server";
import { updateResponseNote } from "@formbricks/lib/services/responseNote";
export const updateResponseNoteAction = async (responseId: string, noteId: string, text: string) => {
await updateResponseNote(responseId, noteId, text);
};

View File

@@ -6,8 +6,10 @@ import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/survey
import { getServerSession } from "next-auth";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
export default async function Page({ params }) {
const surveyBaseUrl = SURVEY_BASE_URL;
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
@@ -21,6 +23,7 @@ export default async function Page({ params }) {
responses={responses}
survey={survey}
surveyId={params.surveyId}
surveyBaseUrl={surveyBaseUrl}
/>
</>
);

View File

@@ -1,11 +1,11 @@
import { CTAQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyCTAQuestion } from "@formbricks/types/v1/surveys";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface CTASummaryProps {
questionSummary: QuestionSummary<CTAQuestion>;
questionSummary: QuestionSummary<TSurveyCTAQuestion>;
}
interface ChoiceResult {

View File

@@ -1,11 +1,11 @@
import { ConsentQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { TSurveyConsentQuestion } from "@formbricks/types/v1/surveys";
interface ConsentSummaryProps {
questionSummary: QuestionSummary<ConsentQuestion>;
questionSummary: QuestionSummary<TSurveyConsentQuestion>;
}
interface ChoiceResult {

View File

@@ -10,9 +10,14 @@ import clsx from "clsx";
interface LinkSurveyShareButtonProps {
survey: TSurvey;
className?: string;
surveyBaseUrl: string;
}
export default function LinkSurveyShareButton({ survey, className }: LinkSurveyShareButtonProps) {
export default function LinkSurveyShareButton({
survey,
className,
surveyBaseUrl,
}: LinkSurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
return (
@@ -26,7 +31,14 @@ export default function LinkSurveyShareButton({ survey, className }: LinkSurveyS
onClick={() => setShowLinkModal(true)}>
<ShareIcon className="h-5 w-5" />
</Button>
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
{showLinkModal && (
<LinkSurveyModal
survey={survey}
open={showLinkModal}
setOpen={setShowLinkModal}
surveyBaseUrl={surveyBaseUrl}
/>
)}
</>
);
}

View File

@@ -1,29 +1,30 @@
"use client";
import CodeBlock from "@/components/shared/CodeBlock";
// import Modal from "@/components/shared/Modal";
import { Dialog, DialogContent } from "@formbricks/ui";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { Button, Dialog, DialogContent } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/outline";
import { CodeBracketIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
interface LinkSurveyModalProps {
survey: TSurvey;
open: boolean;
setOpen: (open: boolean) => void;
surveyBaseUrl: string;
}
export default function LinkSurveyModal({ survey, open, setOpen }: LinkSurveyModalProps) {
export default function LinkSurveyModal({ survey, open, setOpen, surveyBaseUrl }: LinkSurveyModalProps) {
const linkTextRef = useRef(null);
const [showEmbed, setShowEmbed] = useState(false);
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey]);
const iframeCode = `<div style="position: relative; height:100vh; max-height:100vh;
overflow:auto;">
<iframe
src="${window.location.protocol}//${window.location.host}/s/${survey.id}"
src="${surveyUrl}"
frameborder="0" style="position: absolute; left:0;
top:0; width:100%; height:100%; border:0;">
</iframe></div>`;
@@ -67,7 +68,9 @@ top:0; width:100%; height:100%; border:0;">
<span
style={{
wordBreak: "break-all",
}}>{`${window.location.protocol}//${window.location.host}/s/${survey.id}`}</span>
}}>
{surveyUrl}
</span>
</div>
</div>
)}
@@ -89,9 +92,7 @@ top:0; width:100%; height:100%; border:0;">
variant="secondary"
onClick={() => {
setShowEmbed(false);
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
);
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
}}
title="Copy survey link to clipboard"
@@ -105,7 +106,7 @@ top:0; width:100%; height:100%; border:0;">
title="Preview survey"
aria-label="Preview survey"
className="flex justify-center"
href={`${window.location.protocol}//${window.location.host}/s/${survey.id}?preview=true`}
href={`${surveyUrl}?preview=true`}
target="_blank"
EndIcon={EyeIcon}>
Preview

View File

@@ -1,17 +1,17 @@
import {
MultipleChoiceMultiQuestion,
MultipleChoiceSingleQuestion,
QuestionType,
} from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { PersonAvatar, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import Link from "next/link";
import { truncate } from "@/lib/utils";
import {
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
} from "@formbricks/types/v1/surveys";
interface MultipleChoiceSummaryProps {
questionSummary: QuestionSummary<MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion>;
questionSummary: QuestionSummary<TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion>;
environmentId: string;
surveyType: string;
}

View File

@@ -1,11 +1,11 @@
import { NPSQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyNPSQuestion } from "@formbricks/types/v1/surveys";
import { HalfCircle, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface NPSSummaryProps {
questionSummary: QuestionSummary<NPSQuestion>;
questionSummary: QuestionSummary<TSurveyNPSQuestion>;
}
interface Result {

View File

@@ -1,13 +1,13 @@
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { OpenTextQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyOpenTextQuestion } from "@formbricks/types/v1/surveys";
import { PersonAvatar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
interface OpenTextSummaryProps {
questionSummary: QuestionSummary<OpenTextQuestion>;
questionSummary: QuestionSummary<TSurveyOpenTextQuestion>;
environmentId: string;
}

View File

@@ -3,10 +3,11 @@ import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { RatingResponse } from "../RatingResponse";
import { QuestionType, RatingQuestion } from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import { TSurveyRatingQuestion } from "@formbricks/types/v1/surveys";
interface RatingSummaryProps {
questionSummary: QuestionSummary<RatingQuestion>;
questionSummary: QuestionSummary<TSurveyRatingQuestion>;
}
interface ChoiceResult {

View File

@@ -11,9 +11,10 @@ import LinkSurveyModal from "./LinkSurveyModal";
interface SummaryMetadataProps {
environmentId: string;
survey: TSurvey;
surveyBaseUrl: string;
}
export default function SuccessMessage({ environmentId, survey }: SummaryMetadataProps) {
export default function SuccessMessage({ environmentId, survey, surveyBaseUrl }: SummaryMetadataProps) {
const { environment } = useEnvironment(environmentId);
const searchParams = useSearchParams();
const [showLinkModal, setShowLinkModal] = useState(false);
@@ -46,7 +47,14 @@ export default function SuccessMessage({ environmentId, survey }: SummaryMetadat
return (
<>
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
{showLinkModal && (
<LinkSurveyModal
survey={survey}
open={showLinkModal}
setOpen={setShowLinkModal}
surveyBaseUrl={surveyBaseUrl}
/>
)}
{confetti && <Confetti />}
</>
);

View File

@@ -1,18 +1,19 @@
import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/ConsentSummary";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import {
QuestionType,
type CTAQuestion,
type ConsentQuestion,
type MultipleChoiceMultiQuestion,
type MultipleChoiceSingleQuestion,
type NPSQuestion,
type OpenTextQuestion,
type RatingQuestion,
} from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
import {
TSurvey,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyRatingQuestion,
} from "@formbricks/types/v1/surveys";
import CTASummary from "./CTASummary";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
@@ -58,7 +59,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<OpenTextSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<OpenTextQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyOpenTextQuestion>}
environmentId={environmentId}
/>
);
@@ -72,7 +73,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
key={questionSummary.question.id}
questionSummary={
questionSummary as QuestionSummary<
MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
>
}
environmentId={environmentId}
@@ -84,7 +85,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<NPSSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<NPSQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyNPSQuestion>}
/>
);
}
@@ -92,7 +93,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<CTASummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<CTAQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyCTAQuestion>}
/>
);
}
@@ -100,7 +101,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<RatingSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<RatingQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyRatingQuestion>}
/>
);
}
@@ -108,7 +109,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<ConsentSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<ConsentQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyConsentQuestion>}
/>
);
}

View File

@@ -18,9 +18,10 @@ interface SummaryPageProps {
survey: TSurveyWithAnalytics;
surveyId: string;
responses: TResponse[];
surveyBaseUrl: string;
}
const SummaryPage = ({ environmentId, survey, surveyId, responses }: SummaryPageProps) => {
const SummaryPage = ({ environmentId, survey, surveyId, responses, surveyBaseUrl }: SummaryPageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const searchParams = useSearchParams();
@@ -36,7 +37,12 @@ const SummaryPage = ({ environmentId, survey, surveyId, responses }: SummaryPage
return (
<ContentWrapper>
<SummaryHeader environmentId={environmentId} survey={survey} surveyId={surveyId} />
<SummaryHeader
environmentId={environmentId}
survey={survey}
surveyId={surveyId}
surveyBaseUrl={surveyBaseUrl}
/>
<CustomFilter
environmentId={environmentId}
responses={filterResponses}

View File

@@ -5,7 +5,7 @@ import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/survey
import { getServerSession } from "next-auth";
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
import SummaryPage from "./SummaryPage";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
export default async function Page({ params }) {
const session = await getServerSession(authOptions);
@@ -22,6 +22,7 @@ export default async function Page({ params }) {
responses={responses}
survey={survey}
surveyId={params.surveyId}
surveyBaseUrl={SURVEY_BASE_URL}
/>
</>
);

View File

@@ -31,8 +31,9 @@ interface SummaryHeaderProps {
surveyId: string;
environmentId: string;
survey: TSurvey;
surveyBaseUrl: string;
}
const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps) => {
const SummaryHeader = ({ surveyId, environmentId, survey, surveyBaseUrl }: SummaryHeaderProps) => {
const router = useRouter();
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
@@ -56,7 +57,7 @@ const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps)
<span className="text-base font-extralight text-slate-600">{product.name}</span>
</div>
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && <LinkSurveyShareButton survey={survey} />}
{survey.type === "link" && <LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} />}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<SurveyStatusDropdown environmentId={environmentId} surveyId={surveyId} />
) : null}
@@ -78,7 +79,11 @@ const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps)
<DropdownMenuContent align="end" className="p-2">
{survey.type === "link" && (
<>
<LinkSurveyShareButton className="flex w-full justify-center p-1" survey={survey} />
<LinkSurveyShareButton
className="flex w-full justify-center p-1"
survey={survey}
surveyBaseUrl={surveyBaseUrl}
/>
<DropdownMenuSeparator />
</>
)}
@@ -155,7 +160,7 @@ const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps)
</DropdownMenuContent>
</DropdownMenu>
</div>
<SuccessMessage environmentId={environmentId} survey={survey} />
<SuccessMessage environmentId={environmentId} survey={survey} surveyBaseUrl={surveyBaseUrl} />
</div>
);
};

View File

@@ -1,13 +1,12 @@
import React from "react";
import LogicEditor from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor";
import UpdateQuestionId from "./UpdateQuestionId";
import { Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface AdvancedSettingsProps {
question: Question;
question: TSurveyQuestion;
questionIdx: number;
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
}

View File

@@ -1,14 +1,14 @@
"use client";
import { md } from "@formbricks/lib/markdownIt";
import type { CTAQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyCTAQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Editor, Input, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { useState } from "react";
import { BackButtonInput } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard";
interface CTAQuestionFormProps {
localSurvey: Survey;
question: CTAQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyCTAQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
@@ -80,50 +80,57 @@ export default function CTAQuestionForm({
</RadioGroup>
<div className="mt-3 flex justify-between gap-8">
<div className="flex-1">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="flex w-full space-x-2">
<div className="w-full">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
</div>
{questionIdx !== 0 && (
<BackButtonInput
value={question.backButtonLabel}
onChange={(e) => updateQuestion(questionIdx, { backButtonLabel: e.target.value })}
/>
)}
</div>
</div>
{question.buttonExternal && (
<div className="mt-3 flex-1">
<Label htmlFor="buttonLabel">Button URL</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
id="buttonUrl"
name="buttonUrl"
value={question.buttonUrl}
placeholder="https://website.com"
onChange={(e) => updateQuestion(questionIdx, { buttonUrl: e.target.value })}
/>
</div>
</div>
{question.buttonExternal && (
<div className="flex-1">
<Label htmlFor="buttonLabel">Button URL</Label>
<div className="mt-2">
<Input
id="buttonUrl"
name="buttonUrl"
value={question.buttonUrl}
placeholder="https://website.com"
onChange={(e) => updateQuestion(questionIdx, { buttonUrl: e.target.value })}
/>
</div>
</div>
)}
</div>
)}
<div className="mt-3">
{!question.required && (
<div className="flex-1">
<Label htmlFor="buttonLabel">Skip Button Label</Label>
<div className="mt-2">
<Input
id="dismissButtonLabel"
name="dismissButtonLabel"
value={question.dismissButtonLabel}
placeholder="Skip"
onChange={(e) => updateQuestion(questionIdx, { dismissButtonLabel: e.target.value })}
/>
</div>
{!question.required && (
<div className="mt-3 flex-1">
<Label htmlFor="buttonLabel">Skip Button Label</Label>
<div className="mt-2">
<Input
id="dismissButtonLabel"
name="dismissButtonLabel"
value={question.dismissButtonLabel}
placeholder="Skip"
onChange={(e) => updateQuestion(questionIdx, { dismissButtonLabel: e.target.value })}
/>
</div>
)}
</div>
</div>
)}
</form>
);
}

View File

@@ -1,14 +1,13 @@
"use client";
import { md } from "@formbricks/lib/markdownIt";
import type { ConsentQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyConsentQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Editor, Input, Label } from "@formbricks/ui";
import { useState } from "react";
interface ConsentQuestionFormProps {
localSurvey: Survey;
question: ConsentQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyConsentQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
isInValid: boolean;

View File

@@ -1,13 +1,13 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import * as Collapsible from "@radix-ui/react-collapsible";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface EditThankYouCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId: string | null;
}
@@ -19,10 +19,10 @@ export default function EditThankYouCard({
activeQuestionId,
}: EditThankYouCardProps) {
// const [open, setOpen] = useState(false);
let open = activeQuestionId == "thank-you-card";
let open = activeQuestionId == "end";
const setOpen = (e) => {
if (e) {
setActiveQuestionId("thank-you-card");
setActiveQuestionId("end");
} else {
setActiveQuestionId(null);
}
@@ -41,7 +41,7 @@ export default function EditThankYouCard({
return (
<div
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
open ? "scale-100 shadow-lg " : "scale-97 shadow-md",
"flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
)}>
<div
@@ -86,7 +86,7 @@ export default function EditThankYouCard({
)}
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="px-4 pb-4">
<Collapsible.CollapsibleContent className="px-4 pb-6">
<form>
<div className="mt-3">
<Label htmlFor="headline">Headline</Label>

View File

@@ -1,8 +1,5 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { Badge, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import {
CheckCircleIcon,
@@ -15,17 +12,18 @@ import {
import * as Collapsible from "@radix-ui/react-collapsible";
import Link from "next/link";
import { useEffect, useState } from "react";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface HowToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
environmentId: string;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environment: TEnvironment;
}
export default function HowToSendCard({ localSurvey, setLocalSurvey, environmentId }: HowToSendCardProps) {
export default function HowToSendCard({ localSurvey, setLocalSurvey, environment }: HowToSendCardProps) {
const [open, setOpen] = useState(localSurvey.type === "web" ? false : true);
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const { environment } = useEnvironment(environmentId);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
@@ -150,7 +148,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
<p className="text-xs font-normal">
Follow the{" "}
<Link
href={`/environments/${environmentId}/settings/setup`}
href={`/environments/${environment.id}/settings/setup`}
className="underline hover:text-amber-900"
target="_blank">
set up guide

View File

@@ -1,5 +1,5 @@
import { Logic, LogicCondition, Question, QuestionType } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { LogicCondition, QuestionType } from "@formbricks/types/questions";
import { TSurveyLogic, TSurveyQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import {
Button,
DropdownMenu,
@@ -24,9 +24,9 @@ import { toast } from "react-hot-toast";
import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs";
interface LogicEditorProps {
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
questionIdx: number;
question: Question;
question: TSurveyQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
}
@@ -49,7 +49,7 @@ export default function LogicEditor({
if ("choices" in question) {
return question.choices.map((choice) => choice.label);
} else if ("range" in question) {
return Array.from({ length: question.range }, (_, i) => (i + 1).toString());
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
} else if (question.type === QuestionType.NPS) {
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
}
@@ -155,7 +155,7 @@ export default function LogicEditor({
}
}
const newLogic: Logic[] = !question.logic ? [] : question.logic;
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
newLogic.push({
condition: undefined,
value: undefined,

View File

@@ -1,5 +1,3 @@
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import {
Button,
Input,
@@ -14,10 +12,11 @@ import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
import { TSurveyMultipleChoiceMultiQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: MultipleChoiceMultiQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyMultipleChoiceMultiQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,5 +1,3 @@
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import {
Button,
Input,
@@ -14,10 +12,11 @@ import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
import { TSurveyMultipleChoiceSingleQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: MultipleChoiceSingleQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyMultipleChoiceSingleQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,12 +1,11 @@
import type { NPSQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyNPSQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
interface NPSQuestionFormProps {
localSurvey: Survey;
question: NPSQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyNPSQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,12 +1,11 @@
import type { OpenTextQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyOpenTextQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: OpenTextQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyOpenTextQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -4,7 +4,6 @@ import AdvancedSettings from "@/app/(app)/environments/[environmentId]/surveys/[
import { getQuestionTypeName } from "@/lib/questions";
import { cn } from "@formbricks/lib/cn";
import { QuestionType } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import {
ChatBubbleBottomCenterTextIcon,
@@ -28,9 +27,10 @@ import NPSQuestionForm from "./NPSQuestionForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionMenu";
import RatingQuestionForm from "./RatingQuestionForm";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface QuestionCardProps {
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
questionIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
@@ -42,6 +42,23 @@ interface QuestionCardProps {
isInValid: boolean;
}
export function BackButtonInput({ value, onChange }) {
return (
<div className="w-full">
<Label htmlFor="backButtonLabel">&quot;Back&quot; Button Label</Label>
<div className="mt-2">
<Input
id="backButtonLabel"
name="backButtonLabel"
value={value}
placeholder="Back"
onChange={onChange}
/>
</div>
</div>
);
}
export default function QuestionCard({
localSurvey,
questionIdx,
@@ -57,6 +74,7 @@ export default function QuestionCard({
const question = localSurvey.questions[questionIdx];
const open = activeQuestionId === question.id;
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
return (
<Draggable draggableId={question.id} index={questionIdx}>
{(provided) => (
@@ -210,19 +228,36 @@ export default function QuestionCard({
{question.type !== QuestionType.NPS &&
question.type !== QuestionType.Rating &&
question.type !== QuestionType.CTA ? (
<div className="mt-4">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
<div className="mt-4 flex space-x-2">
<div className="w-full">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
</div>
{questionIdx !== 0 && (
<BackButtonInput
value={question.backButtonLabel}
onChange={(e) => updateQuestion(questionIdx, { backButtonLabel: e.target.value })}
/>
)}
</div>
) : null}
{(question.type === QuestionType.Rating || question.type === QuestionType.NPS) &&
questionIdx !== 0 && (
<div className="mt-4">
<BackButtonInput
value={question.backButtonLabel}
onChange={(e) => updateQuestion(questionIdx, { backButtonLabel: e.target.value })}
/>
</div>
)}
<AdvancedSettings
question={question}

View File

@@ -1,8 +1,8 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import React from "react";
import { createId } from "@paralleldrive/cuid2";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { DragDropContext } from "react-beautiful-dnd";
import toast from "react-hot-toast";
import AddQuestionButton from "./AddQuestionButton";
@@ -11,10 +11,11 @@ import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import { Question } from "@formbricks/types/questions";
import { validateQuestion } from "./Validation";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface QuestionsViewProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
environmentId: string;
@@ -38,7 +39,13 @@ export default function QuestionsView({
}, {});
}, []);
const handleQuestionLogicChange = (survey: Survey, compareId: string, updatedId: string): Survey => {
const [backButtonLabel, setbackButtonLabel] = useState(null);
const handleQuestionLogicChange = (
survey: TSurveyWithAnalytics,
compareId: string,
updatedId: string
): TSurveyWithAnalytics => {
survey.questions.forEach((question) => {
if (!question.logic) return;
question.logic.forEach((rule) => {
@@ -68,6 +75,7 @@ export default function QuestionsView({
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
let updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
if ("id" in updatedAttributes) {
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
@@ -89,13 +97,20 @@ export default function QuestionsView({
...updatedSurvey.questions[questionIdx],
...updatedAttributes,
};
if ("backButtonLabel" in updatedAttributes) {
updatedSurvey.questions.forEach((question) => {
question.backButtonLabel = updatedAttributes.backButtonLabel;
});
setbackButtonLabel(updatedAttributes.backButtonLabel);
}
setLocalSurvey(updatedSurvey);
validateSurvey(updatedSurvey.questions[questionIdx]);
};
const deleteQuestion = (questionIdx: number) => {
const questionId = localSurvey.questions[questionIdx].id;
let updatedSurvey: Survey = JSON.parse(JSON.stringify(localSurvey));
let updatedSurvey: TSurveyWithAnalytics = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
@@ -138,6 +153,9 @@ export default function QuestionsView({
const addQuestion = (question: any) => {
const updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
if (backButtonLabel) {
question.backButtonLabel = backButtonLabel;
}
updatedSurvey.questions.push({ ...question, isDraft: true });
setLocalSurvey(updatedSurvey);
setActiveQuestionId(question.id);

View File

@@ -1,14 +1,13 @@
import type { RatingQuestion } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { FaceSmileIcon, HashtagIcon, StarIcon } from "@heroicons/react/24/outline";
import Dropdown from "./RatingTypeDropdown";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { TSurveyRatingQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface RatingQuestionFormProps {
localSurvey: Survey;
question: RatingQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyRatingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { AdvancedOptionToggle, Badge, Input, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
@@ -33,8 +33,8 @@ const displayOptions: DisplayOption[] = [
];
interface RecontactOptionsCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
}

View File

@@ -1,6 +1,6 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { AdvancedOptionToggle, DatePicker, Input, Label } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
@@ -8,8 +8,8 @@ import { useEffect, useState } from "react";
import toast from "react-hot-toast";
interface ResponseOptionsCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
}
export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: ResponseOptionsCardProps) {
@@ -17,7 +17,7 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
useState;
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
@@ -95,6 +95,7 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
subheading?: string;
}) => {
const message = {
enabled: surveyCloseOnDateToggle,
heading: heading ?? surveyClosedMessage.heading,
subheading: subheading ?? surveyClosedMessage.subheading,
};
@@ -149,16 +150,16 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const handleCheckMark = () => {
if (autoComplete) {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: null };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: 25 };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: 25 };
setLocalSurvey(updatedSurvey);
}
};
const handleInputResponse = (e) => {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: parseInt(e.target.value) };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: parseInt(e.target.value) };
setLocalSurvey(updatedSurvey);
};
@@ -168,11 +169,11 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
return;
}
const inputResponses = localSurvey?._count?.responses || 0;
const inputResponses = localSurvey.analytics.numResponses || 0;
if (parseInt(e.target.value) <= inputResponses) {
toast.error(
`Response limit needs to exceed number of received responses (${localSurvey?._count?.responses}).`
`Response limit needs to exceed number of received responses (${localSurvey.analytics.numResponses}).`
);
return;
}
@@ -211,7 +212,11 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
<Input
autoFocus
type="number"
min={localSurvey?._count?.responses ? (localSurvey?._count?.responses + 1).toString() : "1"}
min={
localSurvey?.analytics?.numResponses
? (localSurvey?.analytics?.numResponses + 1).toString()
: "1"
}
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}
@@ -239,32 +244,32 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
</AdvancedOptionToggle>
{/* Redirect on completion */}
<AdvancedOptionToggle
htmlId="redirectUrl"
isChecked={redirectToggle}
onToggle={handleRedirectCheckMark}
title="Redirect on completion"
description="Redirect user to specified link on survey completion"
childBorder={true}>
<div className="w-full p-4">
<div className="flex w-full cursor-pointer items-center">
<p className="mr-2 w-[400px] text-sm font-semibold text-slate-700">
Redirect respondents here:
</p>
<Input
autoFocus
className="w-full bg-white"
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
</div>
</AdvancedOptionToggle>
{localSurvey.type === "link" && (
<>
<AdvancedOptionToggle
htmlId="redirectUrl"
isChecked={redirectToggle}
onToggle={handleRedirectCheckMark}
title="Redirect on completion"
description="Redirect user to specified link on survey completion"
childBorder={true}>
<div className="w-full p-4">
<div className="flex w-full cursor-pointer items-center">
<p className="mr-2 w-[400px] text-sm font-semibold text-slate-700">
Redirect respondents here:
</p>
<Input
autoFocus
className="w-full bg-white"
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
</div>
</AdvancedOptionToggle>
{/* Adjust Survey Closed Message */}
<AdvancedOptionToggle
htmlId="adjustSurveyClosedMessage"

View File

@@ -1,35 +1,44 @@
import type { Survey } from "@formbricks/types/surveys";
import HowToSendCard from "./HowToSendCard";
import RecontactOptionsCard from "./RecontactOptionsCard";
import ResponseOptionsCard from "./ResponseOptionsCard";
import WhenToSendCard from "./WhenToSendCard";
import WhoToSendCard from "./WhoToSendCard";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
interface SettingsViewProps {
environmentId: string;
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
environment: TEnvironment;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
}
export default function SettingsView({ environmentId, localSurvey, setLocalSurvey }: SettingsViewProps) {
export default function SettingsView({
environment,
localSurvey,
setLocalSurvey,
actionClasses,
attributeClasses,
}: SettingsViewProps) {
return (
<div className="mt-12 space-y-3 p-5">
<HowToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
<HowToSendCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} environment={environment} />
<WhoToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
attributeClasses={attributeClasses}
/>
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
actionClasses={actionClasses}
/>
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
@@ -37,7 +46,7 @@ export default function SettingsView({ environmentId, localSurvey, setLocalSurve
<RecontactOptionsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
/>
</div>
);

View File

@@ -1,10 +1,6 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProduct } from "@/lib/products/products";
import { useSurvey } from "@/lib/surveys/surveys";
import type { Survey } from "@formbricks/types/surveys";
import { ErrorComponent } from "@formbricks/ui";
import React from "react";
import { useEffect, useState } from "react";
import PreviewSurvey from "../../PreviewSurvey";
import QuestionsAudienceTabs from "./QuestionsSettingsTabs";
@@ -12,24 +8,31 @@ import QuestionsView from "./QuestionsView";
import SettingsView from "./SettingsView";
import SurveyMenuBar from "./SurveyMenuBar";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TProduct } from "@formbricks/types/v1/product";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
import { ErrorComponent } from "@formbricks/ui";
interface SurveyEditorProps {
environmentId: string;
surveyId: string;
survey: TSurveyWithAnalytics;
product: TProduct;
environment: TEnvironment;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
}
export default function SurveyEditor({
environmentId,
surveyId,
survey,
product,
environment,
actionClasses,
attributeClasses,
}: SurveyEditorProps): JSX.Element {
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<Survey | null>();
const [localSurvey, setLocalSurvey] = useState<TSurveyWithAnalytics | null>();
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId, true);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
useEffect(() => {
if (survey) {
@@ -41,63 +44,65 @@ export default function SurveyEditor({
}
}, [survey]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
return <LoadingSpinner />;
}
// when the survey type changes, we need to reset the active question id to the first question
useEffect(() => {
if (survey?.questions?.length > 0) {
setActiveQuestionId(survey.questions[0].id);
}
}, [localSurvey?.type]);
if (isErrorSurvey || isErrorProduct) {
if (!localSurvey) {
return <ErrorComponent />;
}
return (
<div className="flex h-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environmentId={environmentId}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
{activeView === "questions" ? (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
<>
<div className="flex h-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
product={product}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
{activeView === "questions" ? (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environment.id}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
/>
) : (
<SettingsView
environment={environment}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
activeQuestionId={activeQuestionId}
product={product}
environment={environment}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
/>
) : (
<SettingsView
environmentId={environmentId}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
questions={localSurvey.questions}
brandColor={product.brandColor}
environmentId={environmentId}
product={product}
environment={environment}
surveyType={localSurvey.type}
thankYouCard={localSurvey.thankYouCard}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
autoClose={localSurvey.autoClose}
/>
</aside>
</aside>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,13 +1,10 @@
"use client";
import React from "react";
import AlertDialog from "@/components/shared/AlertDialog";
import DeleteDialog from "@/components/shared/DeleteDialog";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useProduct } from "@/lib/products/products";
import { useSurveyMutation } from "@/lib/surveys/mutateSurveys";
import { deleteSurvey } from "@/lib/surveys/surveys";
import type { Survey } from "@formbricks/types/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { Button, Input } from "@formbricks/ui";
import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { isEqual } from "lodash";
@@ -15,35 +12,38 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { validateQuestion } from "./Validation";
import { TSurvey, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { deleteSurveyAction, surveyMutateAction } from "./actions";
import { TProduct } from "@formbricks/types/v1/product";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { QuestionType } from "@formbricks/types/questions";
interface SurveyMenuBarProps {
localSurvey: Survey;
survey: Survey;
setLocalSurvey: (survey: Survey) => void;
environmentId: string;
localSurvey: TSurveyWithAnalytics;
survey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environment: TEnvironment;
activeId: "questions" | "settings";
setActiveId: (id: "questions" | "settings") => void;
setInvalidQuestions: (invalidQuestions: String[]) => void;
product: TProduct;
}
export default function SurveyMenuBar({
localSurvey,
survey,
environmentId,
environment,
setLocalSurvey,
activeId,
setActiveId,
setInvalidQuestions,
product,
}: SurveyMenuBarProps) {
const router = useRouter();
const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id);
const [audiencePrompt, setAudiencePrompt] = useState(true);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const { product } = useProduct(environmentId);
const [isMutatingSurvey, setIsMutatingSurvey] = useState(false);
let faultyQuestions: String[] = [];
useEffect(() => {
@@ -74,9 +74,10 @@ export default function SurveyMenuBar({
setLocalSurvey(updatedSurvey);
};
const deleteSurveyAction = async (survey) => {
const deleteSurvey = async (surveyId) => {
try {
await deleteSurvey(environmentId, survey.id);
await deleteSurveyAction(surveyId);
router.refresh();
setDeleteDialogOpen(false);
router.back();
} catch (error) {
@@ -85,7 +86,10 @@ export default function SurveyMenuBar({
};
const handleBack = () => {
if (localSurvey.createdAt === localSurvey.updatedAt && localSurvey.status === "draft") {
const createdAt = new Date(localSurvey.createdAt).getTime();
const updatedAt = new Date(localSurvey.updatedAt).getTime();
if (createdAt === updatedAt && localSurvey.status === "draft") {
setDeleteDialogOpen(true);
} else if (!isEqual(localSurvey, survey)) {
setConfirmDialogOpen(true);
@@ -170,6 +174,15 @@ export default function SurveyMenuBar({
}
}
if (
survey.redirectUrl &&
!survey.redirectUrl.includes("https://") &&
!survey.redirectUrl.includes("http://")
) {
toast.error("Please enter a valid URL for redirecting respondents.");
return false;
}
/*
Check whether the count for autocomplete responses is not less
than the current count of accepted response and also it is not set to 0
@@ -181,26 +194,17 @@ export default function SurveyMenuBar({
return false;
}
if (
survey.redirectUrl &&
!survey.redirectUrl.includes("https://") &&
!survey.redirectUrl.includes("http://")
) {
toast.error("Please enter a valid URL for redirecting respondents.");
return false;
}
return true;
};
const saveSurveyAction = (shouldNavigateBack = false) => {
const saveSurveyAction = async (shouldNavigateBack = false) => {
if (localSurvey.questions.length === 0) {
toast.error("Please add at least one question.");
return;
}
// variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question
const strippedSurvey = {
setIsMutatingSurvey(true);
// Create a copy of localSurvey with isDraft removed from every question
const strippedSurvey: TSurvey = {
...localSurvey,
questions: localSurvey.questions.map((question) => {
const { isDraft, ...rest } = question;
@@ -212,28 +216,26 @@ export default function SurveyMenuBar({
return;
}
triggerSurveyMutate({ ...strippedSurvey })
.then(async (response) => {
if (!response?.ok) {
throw new Error(await response?.text());
}
const updatedSurvey = await response.json();
setLocalSurvey(updatedSurvey);
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
try {
await surveyMutateAction({ ...strippedSurvey });
router.refresh();
setIsMutatingSurvey(false);
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary`);
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary`);
} else {
router.push(`/environments/${environmentId}/surveys`);
}
router.push(`/environments/${environment.id}/surveys`);
}
})
.catch((error) => {
console.log(error);
toast.error(`Error saving changes`);
});
}
} catch (e) {
console.error(e);
setIsMutatingSurvey(false);
toast.error(`Error saving changes`);
return;
}
};
return (
@@ -265,7 +267,7 @@ export default function SurveyMenuBar({
className="w-72 border-white hover:border-slate-200 "
/>
</div>
{!!localSurvey?.responseRate && (
{!!localSurvey.analytics.responseRate && (
<div className="mx-auto flex items-center rounded-full border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm">
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
<p className="max-w-[90%] pl-1 text-xs lg:text-sm">
@@ -277,7 +279,7 @@ export default function SurveyMenuBar({
<div className="mr-4 flex items-center">
<SurveyStatusDropdown
surveyId={localSurvey.id}
environmentId={environmentId}
environmentId={environment.id}
updateLocalSurveyStatus={updateLocalSurveyStatus}
/>
</div>
@@ -304,16 +306,19 @@ export default function SurveyMenuBar({
disabled={
localSurvey.type === "web" &&
localSurvey.triggers &&
(localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0)
(localSurvey.triggers[0]?.id === "" || localSurvey.triggers.length === 0)
}
variant="darkCTA"
loading={isMutatingSurvey}
onClick={async () => {
setIsMutatingSurvey(true);
if (!validateSurvey(localSurvey)) {
return;
}
await triggerSurveyMutate({ ...localSurvey, status: "inProgress" });
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
await surveyMutateAction({ ...localSurvey, status: "inProgress" });
router.refresh();
setIsMutatingSurvey(false);
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);
}}>
Publish
</Button>
@@ -323,7 +328,7 @@ export default function SurveyMenuBar({
deleteWhat="Draft"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => deleteSurveyAction(localSurvey)}
onDelete={() => deleteSurvey(localSurvey.id)}
text="Do you want to delete this draft?"
useSaveInsteadOfCancel={true}
onSave={() => saveSurveyAction(true)}

View File

@@ -1,23 +1,23 @@
// extend this object in order to add more validation rules
import {
ConsentQuestion,
MultipleChoiceMultiQuestion,
MultipleChoiceSingleQuestion,
Question,
} from "@formbricks/types/questions";
TSurveyConsentQuestion,
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyQuestion,
} from "@formbricks/types/v1/surveys";
const validationRules = {
multipleChoiceMulti: (question: MultipleChoiceMultiQuestion) => {
multipleChoiceMulti: (question: TSurveyMultipleChoiceMultiQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
multipleChoiceSingle: (question: MultipleChoiceSingleQuestion) => {
multipleChoiceSingle: (question: TSurveyMultipleChoiceSingleQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
consent: (question: ConsentQuestion) => {
consent: (question: TSurveyConsentQuestion) => {
return question.label.trim() !== "";
},
defaultValidation: (question: Question) => {
defaultValidation: (question: TSurveyQuestion) => {
return question.headline.trim() !== "";
},
};

View File

@@ -1,10 +1,7 @@
"use client";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/AddNoCodeActionModal";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEventClasses } from "@/lib/eventClasses/eventClasses";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import {
AdvancedOptionToggle,
Badge,
@@ -20,29 +17,51 @@ import {
import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
interface WhenToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
actionClasses: TActionClass[];
}
export default function WhenToSendCard({ environmentId, localSurvey, setLocalSurvey }: WhenToSendCardProps) {
export default function WhenToSendCard({
environmentId,
localSurvey,
setLocalSurvey,
actionClasses,
}: WhenToSendCardProps) {
const [open, setOpen] = useState(localSurvey.type === "web" ? true : false);
const { eventClasses, isLoadingEventClasses, isErrorEventClasses } = useEventClasses(environmentId);
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [actionClassArray, setActionClassArray] = useState<TActionClass[]>(actionClasses);
const autoClose = localSurvey.autoClose !== null;
let newTrigger = {
id: "", // Set the appropriate value for the id
createdAt: new Date(),
updatedAt: new Date(),
name: "",
type: "code" as const, // Set the appropriate value for the type
environmentId: "",
description: null,
noCodeConfig: null,
};
const addTriggerEvent = () => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers = [...localSurvey.triggers, ""];
updatedSurvey.triggers = [...localSurvey.triggers, newTrigger];
setLocalSurvey(updatedSurvey);
};
const setTriggerEvent = (idx: number, eventClassId: string) => {
const setTriggerEvent = (idx: number, actionClassId: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers[idx] = eventClassId;
updatedSurvey.triggers[idx] = actionClassArray!.find((actionClass) => {
return actionClass.id === actionClassId;
})!;
setLocalSurvey(updatedSurvey);
};
@@ -54,10 +73,10 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
const handleCheckMark = () => {
if (autoClose) {
const updatedSurvey: Survey = { ...localSurvey, autoClose: null };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey: Survey = { ...localSurvey, autoClose: 10 };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: 10 };
setLocalSurvey(updatedSurvey);
}
};
@@ -67,15 +86,21 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
if (value < 1) value = 1;
const updatedSurvey: Survey = { ...localSurvey, autoClose: value };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: value };
setLocalSurvey(updatedSurvey);
};
const handleTriggerDelay = (e: any) => {
let value = parseInt(e.target.value);
const updatedSurvey: Survey = { ...localSurvey, delay: value };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, delay: value };
setLocalSurvey(updatedSurvey);
};
useEffect(() => {
console.log(actionClassArray);
if (activeIndex !== null) {
setTriggerEvent(activeIndex, actionClassArray[actionClassArray.length - 1].id);
}
}, [actionClassArray]);
useEffect(() => {
if (localSurvey.type === "link") {
@@ -90,14 +115,6 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
}
}, []);
if (isLoadingEventClasses) {
return <LoadingSpinner />;
}
if (isErrorEventClasses) {
return <div>Error</div>;
}
return (
<>
<Collapsible.Root
@@ -118,9 +135,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
)}>
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
{!localSurvey.triggers ||
localSurvey.triggers.length === 0 ||
localSurvey.triggers[0] === "" ? (
{!localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0]?.id ? (
<div
className={cn(
localSurvey.type !== "link"
@@ -152,13 +167,13 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="">
<hr className="py-1 text-slate-600" />
{localSurvey.triggers?.map((triggerEventClassId, idx) => (
{localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClassId}
onValueChange={(eventClassId) => setTriggerEvent(idx, eventClassId)}>
value={triggerEventClass.id}
onValueChange={(actionClassId) => setTriggerEvent(idx, actionClassId)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
@@ -168,14 +183,18 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{eventClasses.map((eventClass) => (
<SelectItem value={eventClass.id} key={eventClass.id}>
{eventClass.name}
{actionClassArray.map((actionClass) => (
<SelectItem
value={actionClass.id}
key={actionClass.id}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
@@ -212,7 +231,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
id="triggerDelay"
value={localSurvey.delay.toString()}
onChange={(e) => handleTriggerDelay(e)}
className="ml-2 mr-2 inline w-16 text-center text-sm"
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
seconds before showing the survey.
</p>
@@ -249,6 +268,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
environmentId={environmentId}
open={isAddEventModalOpen}
setOpen={setAddEventModalOpen}
setActionClassArray={setActionClassArray}
/>
</>
);

View File

@@ -1,9 +1,6 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useAttributeClasses } from "@/lib/attributeClasses/attributeClasses";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import {
Badge,
Button,
@@ -17,6 +14,8 @@ import {
import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react"; /* */
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
const filterConditions = [
{ id: "equals", name: "equals" },
@@ -24,23 +23,15 @@ const filterConditions = [
];
interface WhoToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
attributeClasses: TAttributeClass[];
}
export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurvey }: WhoToSendCardProps) {
export default function WhoToSendCard({ localSurvey, setLocalSurvey, attributeClasses }: WhoToSendCardProps) {
const [open, setOpen] = useState(false);
const { attributeClasses, isLoadingAttributeClasses, isErrorAttributeClasses } =
useAttributeClasses(environmentId);
useEffect(() => {
if (!isLoadingAttributeClasses) {
if (localSurvey.attributeFilters?.length > 0) {
setOpen(true);
}
}
}, [isLoadingAttributeClasses]);
const condition = filterConditions[0].id === "equals" ? "equals" : "notEquals";
useEffect(() => {
if (localSurvey.type === "link") {
@@ -52,14 +43,18 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters = [
...localSurvey.attributeFilters,
{ attributeClassId: "", condition: filterConditions[0].id, value: "" },
{ attributeClassId: "", condition: condition, value: "" },
];
setLocalSurvey(updatedSurvey);
};
const setAttributeFilter = (idx: number, attributeClassId: string, condition: string, value: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters[idx] = { attributeClassId, condition, value };
updatedSurvey.attributeFilters[idx] = {
attributeClassId,
condition: condition === "equals" ? "equals" : "notEquals",
value,
};
setLocalSurvey(updatedSurvey);
};
@@ -72,14 +67,6 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
setLocalSurvey(updatedSurvey);
};
if (isLoadingAttributeClasses) {
return <LoadingSpinner />;
}
if (isErrorAttributeClasses) {
return <div>Error</div>;
}
return (
<>
<Collapsible.Root
@@ -187,14 +174,15 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
</Select>
<Input
value={attributeFilter.value}
onChange={(e) =>
onChange={(e) => {
e.preventDefault();
setAttributeFilter(
idx,
attributeFilter.attributeClassId,
attributeFilter.condition,
e.target.value
)
}
);
}}
/>
<button onClick={() => removeAttributeFilter(idx)}>
<TrashIcon className="h-4 w-4 text-slate-400" />

View File

@@ -0,0 +1,12 @@
"use server";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { deleteSurvey, updateSurvey } from "@formbricks/lib/services/survey";
export async function surveyMutateAction(survey: TSurvey): Promise<TSurvey> {
return await updateSurvey(survey);
}
export async function deleteSurveyAction(surveyId: string) {
await deleteSurvey(surveyId);
}

View File

@@ -0,0 +1,27 @@
export default function Loading() {
return (
<div className="flex h-full w-full flex-col items-center justify-between p-6">
{/* Top Part - Loading Navbar */}
<div className="flex h-[10vh] w-full animate-pulse rounded-lg bg-gray-200 font-medium text-slate-900"></div>
{/* Bottom Part - Divided into Left and Right */}
<div className="mt-4 flex h-[85%] w-full flex-row">
{/* Left Part - 7 Horizontal Bars */}
<div className="flex h-full w-1/2 flex-col justify-between space-y-2">
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
</div>
{/* Right Part - Simple Box */}
<div className="ml-4 flex h-full w-1/2 flex-col">
<div className="ph-no-capture h-full animate-pulse rounded-lg bg-gray-200"></div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,35 @@
export const revalidate = REVALIDATION_INTERVAL;
import React from "react";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SurveyEditor from "./SurveyEditor";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getAttributeClasses } from "@formbricks/lib/services/attributeClass";
import { ErrorComponent } from "@formbricks/ui";
export default async function SurveysEditPage({ params }) {
const environment = await getEnvironment(params.environmentId);
const [survey, product, environment, actionClasses, attributeClasses] = await Promise.all([
getSurveyWithAnalytics(params.surveyId),
getProductByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
getActionClasses(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!survey || !environment || !actionClasses || !attributeClasses || !product) {
return <ErrorComponent />;
}
return (
<SurveyEditor environmentId={params.environmentId} surveyId={params.surveyId} environment={environment} />
<>
<SurveyEditor
survey={survey}
product={product}
environment={environment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
/>
</>
);
}

View File

@@ -1,20 +1,35 @@
export const revalidate = REVALIDATION_INTERVAL;
import { updateEnvironmentAction } from "@/app/(app)/environments/[environmentId]/settings/setup/actions";
import ContentWrapper from "@/components/shared/ContentWrapper";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import SurveysList from "./SurveyList";
import { Metadata } from "next";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getActionsByEnvironmentId } from "@formbricks/lib/services/actions";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { Metadata } from "next";
import SurveysList from "./SurveyList";
export const metadata: Metadata = {
title: "Your Surveys",
};
export default async function SurveysPage({ params }) {
const [environment, actions] = await Promise.all([
getEnvironment(params.environmentId),
getActionsByEnvironmentId(params.environmentId),
]);
return (
<ContentWrapper className="flex h-full flex-col justify-between">
<SurveysList environmentId={params.environmentId} />
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
{environment && (
<WidgetStatusIndicator
environment={environment}
actions={actions}
type="mini"
updateEnvironmentAction={updateEnvironmentAction}
/>
)}
</ContentWrapper>
);
}

View File

@@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
import type { Template } from "@formbricks/types/templates";
import type { TTemplate } from "@formbricks/types/v1/templates";
import { useEffect } from "react";
import { replacePresetPlaceholders } from "@/lib/templates";
import { templates } from "./templates";
import { minimalSurvey, templates } from "./templates";
import PreviewSurvey from "../PreviewSurvey";
import TemplateList from "./TemplateList";
import type { TProduct } from "@formbricks/types/v1/product";
@@ -22,7 +22,7 @@ export default function TemplateContainerWithPreview({
product,
environment,
}: TemplateContainerWithPreviewProps) {
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [templateSearch, setTemplateSearch] = useState<string | null>(null);
useEffect(() => {
@@ -68,16 +68,11 @@ export default function TemplateContainerWithPreview({
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
<PreviewSurvey
survey={{ ...minimalSurvey, ...activeTemplate.preset }}
activeQuestionId={activeQuestionId}
questions={activeTemplate.preset.questions}
brandColor={product.brandColor}
product={product}
environment={environment}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
thankYouCard={{ enabled: true }}
autoClose={null}
/>
</div>
)}

View File

@@ -4,9 +4,9 @@ import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProfile } from "@/lib/profile";
import { replacePresetPlaceholders } from "@/lib/templates";
import { cn } from "@formbricks/lib/cn";
import type { Template } from "@formbricks/types/templates";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { TTemplate } from "@formbricks/types/v1/templates";
import {
Button,
ErrorComponent,
@@ -25,7 +25,7 @@ import { customSurvey, templates } from "./templates";
type TemplateList = {
environmentId: string;
onTemplateClick: (template: Template) => void;
onTemplateClick: (template: TTemplate) => void;
environment: TEnvironment;
product: TProduct;
templateSearch?: string;
@@ -41,7 +41,7 @@ export default function TemplateList({
templateSearch,
}: TemplateList) {
const router = useRouter();
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
const [loading, setLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState(RECOMMENDED_CATEGORY_NAME);
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
@@ -153,7 +153,7 @@ export default function TemplateList({
</div>
)}
</button>
{filteredTemplates.map((template: Template) => (
{filteredTemplates.map((template: TTemplate) => (
<div
onClick={() => {
const newTemplate = replacePresetPlaceholders(template, product);

View File

@@ -7,6 +7,10 @@ export default async function SurveyTemplatesPage({ params }) {
const environment = await getEnvironment(environmentId);
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
return (
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
);

View File

@@ -1,5 +1,6 @@
import { QuestionType } from "@formbricks/types/questions";
import type { Template } from "@formbricks/types/templates";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TTemplate } from "@formbricks/types/v1/templates";
import { createId } from "@paralleldrive/cuid2";
const thankYouCardDefault = {
@@ -8,7 +9,7 @@ const thankYouCardDefault = {
subheader: "We appreciate your feedback.",
};
export const templates: Template[] = [
export const templates: TTemplate[] = [
{
name: "Product Market Fit (Superhuman)",
category: "Product Experience",
@@ -2018,7 +2019,7 @@ export const templates: Template[] = [
}, */
];
export const customSurvey: Template = {
export const customSurvey: TTemplate = {
name: "Start from scratch",
description: "Create a survey without template.",
preset: {
@@ -2036,3 +2037,29 @@ export const customSurvey: Template = {
thankYouCard: thankYouCardDefault,
},
};
export const minimalSurvey: TSurvey = {
id: "someUniqueId1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Minimal Survey",
type: "web",
environmentId: "someEnvId1",
status: "draft",
attributeFilters: [],
displayOption: "displayOnce",
autoClose: null,
triggers: [],
redirectUrl: null,
recontactDays: null,
questions: [],
thankYouCard: {
enabled: false,
},
delay: 0, // No delay
autoComplete: null,
closeOnDate: null,
surveyClosedMessage: {
enabled: false,
},
};

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