merge main into branch

This commit is contained in:
tykerr
2023-07-31 18:07:55 +07:00
parent 975dcf66ac
commit 13bc9c06ec
70 changed files with 2559 additions and 1424 deletions

View File

@@ -1,51 +0,0 @@
---
name: Bug report
about: "Found a bug? Please fill out the sections below. \U0001F44D"
title: "[BUG]"
labels: bug
assignees: ""
---
### Issue Summary
<!--
A summary of the issue. This needs to be a clear detailed-rich summary.
-->
(Write your answer here.)
### Steps to Reproduce
1. (for example) Went to ...
2. Clicked on...
3. ...
### Expected behavior
A clear and concise description of what you expected to happen.
### Other information
#### Screenshots
If applicable, add screenshots to help explain your problem.
#### Environment
- [ ] Formbricks Cloud (app.formbricks.com)
- [ ] self-hosted Formbricks, version/commit: [please provide]
#### Desktop (please complete the following information):
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
#### Node.JS version
[e.g. v18.15.0]
#### Anything else?
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
- Anything else that you think could be an issue?

81
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
title: "[BUG]"
labels: bug
assignees: []
body:
- type: textarea
id: issue-summary
attributes:
label: Issue Summary
description: A summary of the issue. This needs to be a clear detailed-rich summary.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
value: |
1. (for example) Went to ...
2. Clicked on...
3. ...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: other-information
attributes:
label: Other information
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: checkboxes
id: environment
attributes:
label: Environment
options:
- label: Formbricks Cloud (app.formbricks.com)
- label: Self-hosted Formbricks
- type: textarea
id: desktop-version
attributes:
label: Desktop (please complete the following information)
description: |
examples:
- **OS**: [e.g. iOS]
- **Browser**: [e.g. chrome, safari]
- **Version**: [e.g. 22]
value: |
- OS:
- Node:
- npm:
render: markdown
validations:
required: true
- type: markdown
id: nodejs-version
attributes:
value: |
#### Node.JS version
[e.g. v18.15.0]
- type: markdown
id: anything-else
attributes:
value: |
#### Anything else?
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
- Anything else that you think could be an issue?

View File

@@ -1,26 +0,0 @@
---
name: Feature request
about: "Suggest an idea for this project \U0001F680"
title: "[FEATURE]"
labels: enhancement
assignees: ""
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
### How we code at Formbricks 🤓
- Everything is type-safe
- All UI components are in the package `formbricks/ui`
- Run `pnpm dev` to find a demo app to test in-app surveys at `localhost:3002`
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right context before you write your prompt.

View File

@@ -0,0 +1,45 @@
name: Feature request
description: "Suggest an idea for this project \U0001F680"
title: "[FEATURE]"
labels: enhancement
assignees: []
body:
- type: textarea
id: problem-description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternate-solution-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: markdown
id: formbricks-info
attributes:
value: |
### How we code at Formbricks 🤓
- Everything is type-safe
- All UI components are in the package `formbricks/ui`
- Run `pnpm dev` to find a demo app to test in-app surveys at `localhost:3002`
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
context before you write your prompt.

View File

@@ -10,14 +10,14 @@ jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ secrets.APP_URL && secrets.CRON_SECRET }}
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
curl ${{ env.APP_URL }}/api/cron/weekly_summary \
-X POST \
-H 'content-type: application/json' \
-H 'x-api-key: ${{ secrets.CRON_SECRET }}' \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail

View File

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

View File

@@ -78,6 +78,7 @@ const navigation = [
{ title: "Get Webhook", href: "/docs/webhook-api/get-webhook" },
{ title: "Create Webhook", href: "/docs/webhook-api/create-webhook" },
{ title: "Delete Webhook", href: "/docs/webhook-api/delete-webhook" },
{ title: "Webhook Payload", href: "/docs/webhook-api/webhook-payload" },
],
},
{

View File

@@ -11,22 +11,22 @@
"lint": "next lint"
},
"dependencies": {
"@calcom/embed-react": "^1.2.2",
"@calcom/embed-react": "^1.3.0",
"@docsearch/react": "^3.5.1",
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^1.7.15",
"@headlessui/react": "^1.7.16",
"@heroicons/react": "^2.0.18",
"@mapbox/rehype-prism": "^0.8.0",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "^13.4.10",
"@next/mdx": "^13.4.12",
"@paralleldrive/cuid2": "^2.2.1",
"clsx": "^2.0.0",
"lottie-web": "^5.12.2",
"next": "13.4.10",
"next-plausible": "^3.10.0",
"next": "13.4.12",
"next-plausible": "^3.10.1",
"next-sitemap": "^4.1.8",
"prism-react-renderer": "^2.0.6",
"prismjs": "^1.29.0",
@@ -35,7 +35,7 @@
"react-icons": "^4.10.1",
"react-responsive-embed": "^2.1.0",
"remark-gfm": "^3.0.1",
"sharp": "^0.32.3"
"sharp": "^0.32.4"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",

View File

@@ -0,0 +1,14 @@
import { OSSFriends } from "@/pages/oss-friends";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// GET
if (req.method === "GET") {
return res.status(200).json({ data: OSSFriends });
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -8,24 +8,56 @@ export const meta = {
"Utilize Docker-Compose for easy deployment on your machine. Clone the repo, configure settings, and build the image to access the app on localhost.",
};
At Formbricks, we understand that different users have different needs, and we strive to cater to a wide variety of situations. This is why we provide two ways of running our application using Docker:
At Formbricks, we understand that different users have different needs, and we strive to cater to a wide variety of situations. This is why we provide three ways of running our application:
1. **Fast Setup with a Pre-built Docker Image:** This method is designed for those who want to quickly set up and start using Formbricks without getting into the technicalities of Docker or the build process. When you choose this method, you're using an image that we've already built for you. The pre-built image is ready-to-run, and it only requires minimal configuration on your part. This approach is perfect for getting a functional instance of Formbricks up and running with minimal hassle. It's as easy as downloading the Docker image and firing up the container.
1. **Production Instance Setup with Shell Script on Ubuntu**: If you want to quickly set up a production instance of Formbricks on a server running Ubuntu, we've got you covered! This method utilizes a convenient shell script that takes care of everything, including Docker, Postgres DB, and SSL certificate configuration. The shell script will automatically install all the required dependencies and configure your server, making the process a breeze.
2. **Manual Setup by Building the Docker Image from Source:** This approach provides the flexibility to configure every aspect of your Formbricks instance, including environment variables that need to be set at build time. While we don't recommend changing the source code of Formbricks, this method allows you to set your own configuration that might be necessary for specific deployment needs. Keep in mind that this method requires a more in-depth understanding of Docker and the build process. However, the trade-off is the additional control and flexibility you gain, making it worth considering if you're a more advanced user or have very specific configuration needs.
2. **Fast Setup with a Pre-built Docker Image:** This method is designed for those who want to quickly set up and start using Formbricks without getting into the technicalities of Docker or the build process. When you choose this method, you're using an image that we've already built for you. The pre-built image is ready-to-run, and it only requires minimal configuration on your part. This approach is perfect for getting a functional instance of Formbricks up and running with minimal hassle. It's as easy as downloading the Docker image and firing up the container.
3. **Manual Setup by Building the Docker Image from Source:** This approach provides the flexibility to configure every aspect of your Formbricks instance, including environment variables that need to be set at build time. While we don't recommend changing the source code of Formbricks, this method allows you to set your own configuration that might be necessary for specific deployment needs. Keep in mind that this method requires a more in-depth understanding of Docker and the build process. However, the trade-off is the additional control and flexibility you gain, making it worth considering if you're a more advanced user or have very specific configuration needs.
Please note that regardless of the method you choose, Formbricks is designed to be easy-to-use and flexible. So choose the method that best fits your comfort level and requirements, and start leveraging the power of Formbricks today!
---
## Requirements
## (Production: Ubuntu) Running the Shell Script
This is the quickest way to get Formbricks up and running on an Ubuntu server. The shell script will automatically install all the required dependencies and configure your server, making the process a breeze.
### Requirements
Before you proceed, make sure you have the following:
- A Linux Ubuntu Virtual Machine deployed with SSH access.
- An A record set up to connect a custom domain to your instance. Formbricks will automatically create an SSL certificate for your domain using Let's Encrypt.
## Single Command Setup
Copy and paste the following command into your terminal:
```bash
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/production.sh)"
```
The script will prompt you for the following information:
1. **Overwriting Docker GPG Keys**: If Docker GPG keys already exist, the script will ask if you want to overwrite them.
2. **Email Address**: Provide your email address for SSL certificate registration with Let's Encrypt.
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
## (Most users: Local Setup) Running the pre-built Docker Image
### Requirements
Ensure `docker` & `docker compose` are installed on your server/system. Both are typically included with Docker utilities, like Docker Desktop and Rancher Desktop.
**Note**: `docker compose` without the hyphen is now the primary method of using docker-compose, according to the Docker documentation.
## (Most users) Running the pre-built Docker Image
This is suitable for those who are testing Formbricks or running it with minimal to no modifications. For this we use the [public Docker image](https://hub.docker.com/r/formbricks/formbricks) and a simple docker-compose file.
1. **Create a New Directory for Formbricks**
@@ -89,6 +121,12 @@ This is suitable for those who are testing Formbricks or running it with minimal
## (Advanced users) Build and Run Formbricks
### Requirements
Ensure `docker` & `docker compose` are installed on your server/system. Both are typically included with Docker utilities, like Docker Desktop and Rancher Desktop.
**Note**: `docker compose` without the hyphen is now the primary method of using docker-compose, according to the Docker documentation.
1. Clone the repository:
```bash
@@ -164,4 +202,16 @@ docker compose logs -f
You can close the logs again with `CTRL + C`.
## Troubleshooting for the Shell Script Setup
If you encounter any issues, consider the following steps:
- **Inbound Rules**: Make sure you have added inbound rules for Port 80 and 443 in your VM's Security Group.
- **A Record**: Verify that you have set up an A record for your domain, pointing to your VM's IP address.
- **Check Docker Instances**: Run `docker ps` to check the status of the Docker instances.
- **Check Formbricks Logs**: Run `cd formbricks && docker compose logs` to check the logs of the Formbricks stack.
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

View File

@@ -0,0 +1,69 @@
import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
export const meta = {
title: "Webhook Payload",
description: "Learn how to use the Formbricks Webhook API.",
};
This documentation helps understand the payload structure that will be received when the webhook is triggered in Formbricks.
## An example webhook payload
```
{
"webhookId": "cljwxvjos0003qhnvj2jg4k5i",
"event": "responseCreated",
"data": {
"id": "cljwy2m8r0001qhclco1godnu",
"createdAt": "2023-07-10T14:14:17.115Z",
"updatedAt": "2023-07-10T14:14:17.115Z",
"surveyId": "cljsf3d7a000019cv9apt2t27",
"finished": false,
"data": {
"qumbk3fkr6cky8850bvvq5z1": "Executive"
},
"meta": {
"userAgent": {
"os": "Mac OS",
"browser": "Chrome"
}
},
"personAttributes": {
"email": "test@web.com",
"userId": "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"
},
"person": {
"id": "cljold01t0000qh8ewzigzmjk",
"attributes": {
"email": "test@web.com",
"userId": "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"
},
"createdAt": "2023-07-04T17:56:17.154Z",
"updatedAt": "2023-07-04T17:56:17.154Z"
},
"notes": [],
"tags": []
}
}
```
| Variable | Type | Description |
| --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| webhookId | String | Webhook's Id |
| event | String | The name of the trigger event [responseCreated, responseUpdated, responseFinished] |
| data | Object | Contains the details of the newly created response. |
| data.id | String | Formbricks Response ID. |
| data.createdAt | String | The timestamp when the response was created. |
| data.updatedAt | String | The timestamp when the response was last updated. |
| data.surveyId | String | The identifier of the survey associated with this response. |
| data.finished | Boolean | A boolean value indicating whether the survey response is marked as finished. |
| data.data | Object | An object containing the response data, where keys are question identifiers, and values are the corresponding answers given by the respondent. |
| data.meta | Object | Additional metadata related to the response, such as the user's operating system and browser information. |
| data.personAttributes | Object | An object with attributes related to the respondent, such as their email and a user ID (if available). |
| data.person | Object | Information about the respondent, including their unique id, attributes, and creation/update timestamps. |
| data.notes | Array | An array of notes associated with the response (if any). |
| data.tags | Array | An array of tags assigned to the response (if any). |
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

View File

@@ -2,7 +2,7 @@ import Layout from "@/components/shared/Layout";
import HeroTitle from "@/components/shared/HeroTitle";
import { Button } from "@formbricks/ui";
const OSSFriends = [
export const OSSFriends = [
{
name: "BoxyHQ",
description:

3
apps/web/.gitignore vendored
View File

@@ -34,3 +34,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Sentry Auth Token
.sentryclirc

View File

@@ -24,12 +24,7 @@ export default function AttributeDetailModal({
},
{
title: "Settings",
children: (
<AttributeSettingsTab
attributeClass={attributeClass}
setOpen={setOpen}
/>
),
children: <AttributeSettingsTab attributeClass={attributeClass} setOpen={setOpen} />,
},
];

View File

@@ -103,6 +103,7 @@ export default function PreviewSurvey({
cancelAnimationFrame(frameRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoClose]);
useEffect(() => {
@@ -299,6 +300,7 @@ export default function PreviewSurvey({
savedAnswer={savedAnswer}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
)
@@ -329,6 +331,7 @@ export default function PreviewSurvey({
savedAnswer={savedAnswer}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
)

View File

@@ -1,198 +1,198 @@
"use client";
import {
copyToOtherEnvironmentAction,
deleteSurveyAction,
duplicateSurveyAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import {
ArrowUpOnSquareStackIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
EyeIcon,
LinkIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/solid";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurveyWithAnalytics;
environment: TEnvironment;
otherEnvironment: TEnvironment;
}
export default function SurveyDropDownMenu({
environmentId,
survey,
environment,
otherEnvironment,
}: SurveyDropDownMenuProps) {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleDeleteSurvey = async (survey) => {
setLoading(true);
try {
await deleteSurveyAction(survey.id);
router.refresh();
setDeleteDialogOpen(false);
toast.success("Survey deleted successfully.");
} catch (error) {
toast.error("An error occured while deleting survey");
}
setLoading(false);
};
const duplicateSurveyAndRefresh = async (surveyId) => {
setLoading(true);
try {
await duplicateSurveyAction(environmentId, surveyId);
router.refresh();
toast.success("Survey duplicated successfully.");
} catch (error) {
toast.error("Failed to duplicate the survey.");
}
setLoading(false);
};
const copyToOtherEnvironment = async (surveyId) => {
setLoading(true);
try {
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
if (otherEnvironment.type === "production") {
toast.success("Survey copied to production env.");
} else if (otherEnvironment.type === "development") {
toast.success("Survey copied to development env.");
}
router.replace(`/environments/${otherEnvironment.id}`);
} catch (error) {
toast.error(`Failed to copy to ${otherEnvironment.type}`);
}
setLoading(false);
};
if (loading) {
return (
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-gray-100">
<LoadingSpinner />
</div>
);
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div>
<span className="sr-only">Open options</span>
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
<PencilSquareIcon className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={async () => {
duplicateSurveyAndRefresh(survey.id);
}}>
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
Duplicate
</button>
</DropdownMenuItem>
{environment.type === "development" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Prod
</button>
</DropdownMenuItem>
) : environment.type === "production" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Dev
</button>
</DropdownMenuItem>
) : null}
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/s/${survey.id}?preview=true`}
target="_blank">
<EyeIcon className="mr-2 h-4 w-4" />
Preview Survey
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
);
toast.success("Copied link to clipboard");
}}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy Link
</button>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Survey"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey)}
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
/>
</>
);
}
"use client";
import {
copyToOtherEnvironmentAction,
deleteSurveyAction,
duplicateSurveyAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import {
ArrowUpOnSquareStackIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
EyeIcon,
LinkIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/solid";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurveyWithAnalytics;
environment: TEnvironment;
otherEnvironment: TEnvironment;
}
export default function SurveyDropDownMenu({
environmentId,
survey,
environment,
otherEnvironment,
}: SurveyDropDownMenuProps) {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleDeleteSurvey = async (survey) => {
setLoading(true);
try {
await deleteSurveyAction(survey.id);
router.refresh();
setDeleteDialogOpen(false);
toast.success("Survey deleted successfully.");
} catch (error) {
toast.error("An error occured while deleting survey");
}
setLoading(false);
};
const duplicateSurveyAndRefresh = async (surveyId) => {
setLoading(true);
try {
await duplicateSurveyAction(environmentId, surveyId);
router.refresh();
toast.success("Survey duplicated successfully.");
} catch (error) {
toast.error("Failed to duplicate the survey.");
}
setLoading(false);
};
const copyToOtherEnvironment = async (surveyId) => {
setLoading(true);
try {
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
if (otherEnvironment.type === "production") {
toast.success("Survey copied to production env.");
} else if (otherEnvironment.type === "development") {
toast.success("Survey copied to development env.");
}
router.replace(`/environments/${otherEnvironment.id}`);
} catch (error) {
toast.error(`Failed to copy to ${otherEnvironment.type}`);
}
setLoading(false);
};
if (loading) {
return (
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-gray-100">
<LoadingSpinner />
</div>
);
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div>
<span className="sr-only">Open options</span>
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
<PencilSquareIcon className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={async () => {
duplicateSurveyAndRefresh(survey.id);
}}>
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
Duplicate
</button>
</DropdownMenuItem>
{environment.type === "development" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Prod
</button>
</DropdownMenuItem>
) : environment.type === "production" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Dev
</button>
</DropdownMenuItem>
) : null}
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/s/${survey.id}?preview=true`}
target="_blank">
<EyeIcon className="mr-2 h-4 w-4" />
Preview Survey
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
);
toast.success("Copied link to clipboard");
}}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy Link
</button>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Survey"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey)}
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
/>
</>
);
}

View File

@@ -1,60 +1,60 @@
"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 { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
export default function SurveyStarter({
environmentId,
environment,
product,
}: {
environmentId: string;
environment: TEnvironment;
product: TProduct;
}) {
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
const router = useRouter();
const newSurveyFromTemplate = async (template: Template) => {
setIsCreateSurveyLoading(true);
const augmentedTemplate = {
...template.preset,
type: environment?.widgetSetupCompleted ? "web" : "link",
};
try {
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
} catch (e) {
toast.error("An error occured creating a new survey");
setIsCreateSurveyLoading(false);
}
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
{isCreateSurveyLoading ? (
<LoadingSpinner />
) : (
<>
<div className="px-7 pb-4">
<h1 className="text-3xl font-extrabold text-slate-700">
You&apos;re all set! Time to create your first survey.
</h1>
</div>
<TemplateList
environmentId={environmentId}
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}
</div>
);
}
"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 { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
export default function SurveyStarter({
environmentId,
environment,
product,
}: {
environmentId: string;
environment: TEnvironment;
product: TProduct;
}) {
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
const router = useRouter();
const newSurveyFromTemplate = async (template: Template) => {
setIsCreateSurveyLoading(true);
const augmentedTemplate = {
...template.preset,
type: environment?.widgetSetupCompleted ? "web" : "link",
};
try {
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
} catch (e) {
toast.error("An error occured creating a new survey");
setIsCreateSurveyLoading(false);
}
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
{isCreateSurveyLoading ? (
<LoadingSpinner />
) : (
<>
<div className="px-7 pb-4">
<h1 className="text-3xl font-extrabold text-slate-700">
You&apos;re all set! Time to create your first survey.
</h1>
</div>
<TemplateList
environmentId={environmentId}
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}
</div>
);
}

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

@@ -126,7 +126,7 @@ const ResponseFilter = () => {
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-slate-100 p-3 text-sm text-slate-600 sm:min-w-[11rem] sm:px-6 sm:py-3">
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-slate-100 p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
Filter {selectedFilter.filter.length > 0 && `(${selectedFilter.filter.length})`}
<div className="ml-3">
{isOpen ? (

View File

@@ -12,6 +12,7 @@ interface CTAQuestionFormProps {
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function CTAQuestionForm({
@@ -19,6 +20,7 @@ export default function CTAQuestionForm({
questionIdx,
updateQuestion,
lastQuestion,
isInValid,
}: CTAQuestionFormProps): JSX.Element {
const [firstRender, setFirstRender] = useState(true);
@@ -33,6 +35,7 @@ export default function CTAQuestionForm({
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>

View File

@@ -11,12 +11,14 @@ interface ConsentQuestionFormProps {
question: ConsentQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
isInValid: boolean;
}
export default function ConsentQuestionForm({
question,
questionIdx,
updateQuestion,
isInValid,
}: ConsentQuestionFormProps): JSX.Element {
const [firstRender, setFirstRender] = useState(true);
return (
@@ -29,6 +31,7 @@ export default function ConsentQuestionForm({
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>
@@ -62,6 +65,7 @@ export default function ConsentQuestionForm({
value={question.label}
placeholder="I agree to the terms and conditions"
onChange={(e) => updateQuestion(questionIdx, { label: e.target.value })}
isInvalid={isInValid && question.label.trim() === ""}
/>
</div>
{/* <div className="mt-3">

View File

@@ -21,12 +21,14 @@ interface OpenQuestionFormProps {
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function MultipleChoiceMultiForm({
question,
questionIdx,
updateQuestion,
isInValid,
}: OpenQuestionFormProps): JSX.Element {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
@@ -51,16 +53,28 @@ export default function MultipleChoiceMultiForm({
},
};
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
: question.choices.map((choice, idx) => {
if (idx === choiceIdx) {
return { ...choice, ...updatedAttributes };
}
return choice;
});
updateQuestion(questionIdx, { choices: newChoices });
const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => {
const newLabel = updatedAttributes.label;
const oldLabel = question.choices[choiceIdx].label;
let newChoices: any[] = [];
if (question.choices) {
newChoices = question.choices.map((choice, idx) => {
if (idx !== choiceIdx) return choice;
return { ...choice, ...updatedAttributes };
});
}
let newLogic: any[] = [];
question.logic?.forEach((logic) => {
let newL: string | string[] | undefined = logic.value;
if (Array.isArray(logic.value)) {
newL = logic.value.map((value) => (value === oldLabel ? newLabel : value));
} else {
newL = logic.value === oldLabel ? newLabel : logic.value;
}
newLogic.push({ ...logic, value: newL });
});
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
const addChoice = (choiceIdx?: number) => {
@@ -137,6 +151,7 @@ export default function MultipleChoiceMultiForm({
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>
@@ -184,6 +199,7 @@ export default function MultipleChoiceMultiForm({
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
isInvalid={isInValid && choice.label.trim() === ""}
/>
{question.choices && question.choices.length > 2 && (
<TrashIcon

View File

@@ -21,12 +21,14 @@ interface OpenQuestionFormProps {
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function MultipleChoiceSingleForm({
question,
questionIdx,
updateQuestion,
isInValid,
}: OpenQuestionFormProps): JSX.Element {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
@@ -51,16 +53,28 @@ export default function MultipleChoiceSingleForm({
},
};
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
: question.choices.map((choice, idx) => {
if (idx === choiceIdx) {
return { ...choice, ...updatedAttributes };
}
return choice;
});
updateQuestion(questionIdx, { choices: newChoices });
const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => {
const newLabel = updatedAttributes.label;
const oldLabel = question.choices[choiceIdx].label;
let newChoices: any[] = [];
if (question.choices) {
newChoices = question.choices.map((choice, idx) => {
if (idx !== choiceIdx) return choice;
return { ...choice, ...updatedAttributes };
});
}
let newLogic: any[] = [];
question.logic?.forEach((logic) => {
let newL: string | string[] | undefined = logic.value;
if (Array.isArray(logic.value)) {
newL = logic.value.map((value) => (value === oldLabel ? newLabel : value));
} else {
newL = logic.value === oldLabel ? newLabel : logic.value;
}
newLogic.push({ ...logic, value: newL });
});
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
const addChoice = (choiceIdx?: number) => {
@@ -137,6 +151,7 @@ export default function MultipleChoiceSingleForm({
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>
@@ -184,6 +199,7 @@ export default function MultipleChoiceSingleForm({
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
isInvalid={isInValid && choice.label.trim() === ""}
/>
{question.choices && question.choices.length > 2 && (
<TrashIcon

View File

@@ -10,6 +10,7 @@ interface NPSQuestionFormProps {
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function NPSQuestionForm({
@@ -17,6 +18,7 @@ export default function NPSQuestionForm({
questionIdx,
updateQuestion,
lastQuestion,
isInValid,
}: NPSQuestionFormProps): JSX.Element {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
@@ -31,6 +33,7 @@ export default function NPSQuestionForm({
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>

View File

@@ -10,12 +10,14 @@ interface OpenQuestionFormProps {
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function OpenQuestionForm({
question,
questionIdx,
updateQuestion,
isInValid,
}: OpenQuestionFormProps): JSX.Element {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
@@ -30,6 +32,7 @@ export default function OpenQuestionForm({
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>

View File

@@ -39,6 +39,7 @@ interface QuestionCardProps {
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function QuestionCard({
@@ -51,6 +52,7 @@ export default function QuestionCard({
activeQuestionId,
setActiveQuestionId,
lastQuestion,
isInValid,
}: QuestionCardProps) {
const question = localSurvey.questions[questionIdx];
const open = activeQuestionId === question.id;
@@ -69,7 +71,8 @@ export default function QuestionCard({
<div
className={cn(
open ? "bg-slate-700" : "bg-slate-400",
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:bg-slate-600"
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:bg-slate-600",
isInValid && "bg-red-400 hover:bg-red-600"
)}>
{questionIdx + 1}
</div>
@@ -136,6 +139,7 @@ export default function QuestionCard({
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === QuestionType.MultipleChoiceSingle ? (
<MultipleChoiceSingleForm
@@ -144,6 +148,7 @@ export default function QuestionCard({
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === QuestionType.MultipleChoiceMulti ? (
<MultipleChoiceMultiForm
@@ -152,6 +157,7 @@ export default function QuestionCard({
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === QuestionType.NPS ? (
<NPSQuestionForm
@@ -160,6 +166,7 @@ export default function QuestionCard({
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === QuestionType.CTA ? (
<CTAQuestionForm
@@ -168,6 +175,7 @@ export default function QuestionCard({
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === QuestionType.Rating ? (
<RatingQuestionForm
@@ -176,6 +184,7 @@ export default function QuestionCard({
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === "consent" ? (
<ConsentQuestionForm
@@ -183,6 +192,7 @@ export default function QuestionCard({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
isInValid={isInValid}
/>
) : null}
<div className="mt-4">

View File

@@ -9,6 +9,8 @@ import AddQuestionButton from "./AddQuestionButton";
import EditThankYouCard from "./EditThankYouCard";
import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import { Question } from "@formbricks/types/questions";
import { validateQuestion } from "./Validation";
interface QuestionsViewProps {
localSurvey: Survey;
@@ -16,6 +18,8 @@ interface QuestionsViewProps {
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
environmentId: string;
invalidQuestions: String[] | null;
setInvalidQuestions: (invalidQuestions: String[] | null) => void;
}
export default function QuestionsView({
@@ -24,6 +28,8 @@ export default function QuestionsView({
localSurvey,
setLocalSurvey,
environmentId,
invalidQuestions,
setInvalidQuestions,
}: QuestionsViewProps) {
const internalQuestionIdMap = useMemo(() => {
return localSurvey.questions.reduce((acc, question) => {
@@ -44,12 +50,33 @@ export default function QuestionsView({
return survey;
};
// function to validate individual questions
const validateSurvey = (question: Question) => {
// prevent this function to execute further if user hasnt still tried to save the survey
if (invalidQuestions === null) {
return;
}
let temp = JSON.parse(JSON.stringify(invalidQuestions));
if (validateQuestion(question)) {
temp = invalidQuestions.filter((id) => id !== question.id);
setInvalidQuestions(temp);
} else if (!invalidQuestions.includes(question.id)) {
temp.push(question.id);
setInvalidQuestions(temp);
}
};
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;
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
if (invalidQuestions?.includes(initialQuestionId)) {
setInvalidQuestions(
invalidQuestions.map((id) => (id === initialQuestionId ? updatedAttributes.id : id))
);
}
// relink the question to internal Id
internalQuestionIdMap[updatedAttributes.id] =
@@ -63,6 +90,7 @@ export default function QuestionsView({
...updatedAttributes,
};
setLocalSurvey(updatedSurvey);
validateSurvey(updatedSurvey.questions[questionIdx]);
};
const deleteQuestion = (questionIdx: number) => {
@@ -120,7 +148,6 @@ export default function QuestionsView({
if (!result.destination) {
return;
}
const newQuestions = Array.from(localSurvey.questions);
const [reorderedQuestion] = newQuestions.splice(result.source.index, 1);
newQuestions.splice(result.destination.index, 0, reorderedQuestion);
@@ -134,7 +161,6 @@ export default function QuestionsView({
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
const destinationIndex = up ? questionIndex - 1 : questionIndex + 1;
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
const updatedSurvey = { ...localSurvey, questions: newQuestions };
setLocalSurvey(updatedSurvey);
};
@@ -159,6 +185,7 @@ export default function QuestionsView({
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
lastQuestion={questionIdx === localSurvey.questions.length - 1}
isInValid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
/>
))}
{provided.placeholder}

View File

@@ -12,6 +12,7 @@ interface RatingQuestionFormProps {
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function RatingQuestionForm({
@@ -19,6 +20,7 @@ export default function RatingQuestionForm({
questionIdx,
updateQuestion,
lastQuestion,
isInValid,
}: RatingQuestionFormProps) {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
@@ -33,6 +35,7 @@ export default function RatingQuestionForm({
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
</div>
</div>

View File

@@ -22,7 +22,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<Survey | null>();
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
@@ -56,6 +56,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
environmentId={environmentId}
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">
@@ -67,6 +68,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
/>
) : (
<SettingsView

View File

@@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { isEqual } from "lodash";
import { validateQuestion } from "./Validation";
interface SurveyMenuBarProps {
localSurvey: Survey;
@@ -21,6 +22,7 @@ interface SurveyMenuBarProps {
environmentId: string;
activeId: "questions" | "settings";
setActiveId: (id: "questions" | "settings") => void;
setInvalidQuestions: (invalidQuestions: String[]) => void;
}
export default function SurveyMenuBar({
@@ -30,6 +32,7 @@ export default function SurveyMenuBar({
setLocalSurvey,
activeId,
setActiveId,
setInvalidQuestions,
}: SurveyMenuBarProps) {
const router = useRouter();
const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id);
@@ -37,6 +40,7 @@ export default function SurveyMenuBar({
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const { product } = useProduct(environmentId);
let faultyQuestions: String[] = [];
useEffect(() => {
if (audiencePrompt && activeId === "settings") {
@@ -85,6 +89,26 @@ export default function SurveyMenuBar({
}
};
const validateSurvey = (survey) => {
faultyQuestions = [];
for (let index = 0; index < survey.questions.length; index++) {
const question = survey.questions[index];
const isValid = validateQuestion(question);
if (!isValid) {
faultyQuestions.push(question.id);
}
}
// if there are any faulty questions, the user won't be allowed to save the survey
if (faultyQuestions.length > 0) {
setInvalidQuestions(faultyQuestions);
toast.error("Please fill required fields");
return false;
}
return true;
};
const saveSurveyAction = (shouldNavigateBack = false) => {
// variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question
const strippedSurvey = {
@@ -94,6 +118,11 @@ export default function SurveyMenuBar({
return rest;
}),
};
if (!validateSurvey(localSurvey)) {
return;
}
triggerSurveyMutate({ ...strippedSurvey })
.then(async (response) => {
if (!response?.ok) {
@@ -180,6 +209,9 @@ export default function SurveyMenuBar({
variant="darkCTA"
loading={isMutatingSurvey}
onClick={async () => {
if (!validateSurvey(localSurvey)) {
return;
}
await triggerSurveyMutate({ ...localSurvey, status: "inProgress" });
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
}}>

View File

@@ -0,0 +1,32 @@
// extend this object in order to add more validation rules
import {
MultipleChoiceMultiQuestion,
MultipleChoiceSingleQuestion,
Question,
} from "@formbricks/types/questions";
const validationRules = {
multipleChoiceMulti: (question: MultipleChoiceMultiQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
multipleChoiceSingle: (question: MultipleChoiceSingleQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
defaultValidation: (question: Question) => {
return question.headline.trim() !== "";
},
};
const validateQuestion = (question) => {
const specificValidation = validationRules[question.type];
const defaultValidation = validationRules.defaultValidation;
const specificValidationResult = specificValidation ? specificValidation(question) : true;
const defaultValidationResult = defaultValidation(question);
// Return true only if both specific and default validation pass
return specificValidationResult && defaultValidationResult;
};
export { validateQuestion };

View File

@@ -1,10 +1,15 @@
export const revalidate = REVALIDATION_INTERVAL;
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";
export const metadata: Metadata = {
title: "Your Surveys",
};
export default async function SurveysPage({ params }) {
return (
<ContentWrapper className="flex h-full flex-col justify-between">

View File

@@ -240,7 +240,7 @@ export const authOptions: NextAuthOptions = {
});
return true;
}
return "/auth/error?error=email-conflict";
return "/auth/login?error=Looks%20like%20you%20updated%20your%20email%20somewhere%20else.%0AA%20user%20with%20this%20new%20email%20exists%20already.";
}
// There is no existing account for this identity provider / account id
@@ -251,7 +251,7 @@ export const authOptions: NextAuthOptions = {
});
if (existingUserWithEmail) {
return "/auth/error?error=use-email-login";
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
}
await prisma.user.create({

View File

@@ -63,7 +63,7 @@ const notificationInsight = (insights: Insights) =>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Completion %</p>
<h1>${insights.completionRate.toFixed(2)}%</h1>
<h1>${insights.totalDisplays === 0 ? "N/A" : `${Math.round(insights.completionRate)}%`}</h1>
</td>
</tr>
</table>
@@ -91,30 +91,34 @@ const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
if (!surveys.length) return ` `;
return surveys
.filter((survey) => survey.responses.length > 0)
.map((survey) => {
const displayStatus = convertSurveyStatus(survey.status);
const isLive = displayStatus === "Live";
const noResponseLastWeek = isLive && survey.responses.length === 0;
return `
<div style="display: block; margin-top:3em;">
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
survey.id
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color:#1e293b;">
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
</a>
<span style="display: inline; margin-left: 10px; background-color: ${
isLive ? "#34D399" : "#a7f3d0"
isLive ? "#34D399" : "#cbd5e1"
}; color: ${isLive ? "#F3F4F6" : "#15803d"}; border-radius:99px; padding: 2px 8px; font-size:0.9em">
${displayStatus}
</span>
${createSurveyFields(survey.responses)}
${
survey.responsesCount >= 1
noResponseLastWeek
? "<p>No new response received this week 🕵️</p>"
: createSurveyFields(survey.responses)
}
${
survey.responsesCount >= 0
? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA">
${getButtonLabel(survey.responsesCount)}
${noResponseLastWeek ? "View previous responses" : getButtonLabel(survey.responsesCount)}
</a>`
: ""
}
@@ -154,8 +158,7 @@ const notificationFooter = () => {
return `
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
<p style="margin-top:0.8em; text-align:center; font-size:0.8em; line-height:1em;">The Formbricks Team 🤍</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
};

View File

@@ -101,7 +101,7 @@ const getNotificationResponse = (environment: EnvironmentData, productName: stri
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
insights.totalDisplays += survey.displays.length;
insights.totalResponses += survey.responses.length;
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalDisplays) * 100);
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100);
}
// build the notification response needed for the emails
const lastWeekDate = new Date();
@@ -160,6 +160,11 @@ const getProducts = async (): Promise<ProductData[]> => {
},
},
displays: {
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
select: {
status: true,
},

View File

@@ -45,6 +45,7 @@ export async function POST(request: Request): Promise<NextResponse> {
let response: TResponse;
try {
const meta = {
url: responseInput?.meta?.url ?? "",
userAgent: {
browser: agent?.browser.name,
device: agent?.device.type,

View File

@@ -8,10 +8,10 @@ import ContentWrapper from "@/components/shared/ContentWrapper";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useLinkSurveyUtils } from "@/lib/linkSurvey/linkSurvey";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { Confetti } from "@formbricks/ui";
import { ArrowPathIcon } from "@heroicons/react/24/solid";
import { useEffect, useRef } from "react";
import type { Survey } from "@formbricks/types/surveys";
import { useEffect, useRef, useState } from "react";
type EnhancedSurvey = Survey & {
brandColor: string;
@@ -42,6 +42,14 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
const showBackButton = progress !== 0 && !finished;
// Create a reference to the top element
const topRef = useRef<HTMLDivElement>(null);
const [autoFocus, setAutofocus] = useState(false);
// Not in an iframe, enable autofocus on input fields.
useEffect(() => {
if (window.self === window.top) {
setAutofocus(true);
}
}, []);
// Scroll to top when the currentQuestion changes
useEffect(() => {
@@ -97,6 +105,7 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
savedAnswer={savedAnswer}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={autoFocus}
/>
)}
</ContentWrapper>

View File

@@ -1,6 +1,6 @@
export const TiredFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -61,7 +61,7 @@ export const TiredFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) =>
export const WearyFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -122,7 +122,7 @@ export const WearyFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) =>
export const PerseveringFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -192,7 +192,7 @@ export const PerseveringFace: React.FC<React.SVGProps<SVGCircleElement>> = (prop
export const FrowningFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -222,7 +222,7 @@ export const FrowningFace: React.FC<React.SVGProps<SVGCircleElement>> = (props)
export const ConfusedFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -251,7 +251,7 @@ export const ConfusedFace: React.FC<React.SVGProps<SVGCircleElement>> = (props)
export const NeutralFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -284,7 +284,7 @@ export const NeutralFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) =
export const SlightlySmilingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -314,7 +314,7 @@ export const SlightlySmilingFace: React.FC<React.SVGProps<SVGCircleElement>> = (
export const SmilingFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -358,7 +358,7 @@ export const SmilingFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElemen
export const GrinningFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -410,7 +410,7 @@ export const GrinningFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleEleme
export const GrinningSquintingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"

View File

@@ -77,7 +77,7 @@ export default function NPSQuestion({
value={number}
checked={selectedChoice === number}
className="absolute h-full w-full cursor-pointer opacity-0"
onChange={() => handleSelect(number)}
onClick={() => handleSelect(number)}
required={question.required}
/>
{number}

View File

@@ -14,6 +14,7 @@ interface OpenTextQuestionProps {
savedAnswer: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer: Response["data"]) => void;
autoFocus?: boolean;
}
export default function OpenTextQuestion({
@@ -24,6 +25,7 @@ export default function OpenTextQuestion({
savedAnswer,
goToNextQuestion,
goToPreviousQuestion,
autoFocus = false,
}: OpenTextQuestionProps) {
const [value, setValue] = useState<string>("");
@@ -54,7 +56,7 @@ export default function OpenTextQuestion({
<div className="mt-4">
{question.longAnswer === false ? (
<input
autoFocus={!savedAnswer}
autoFocus={autoFocus}
name={question.id}
id={question.id}
value={value}
@@ -65,7 +67,7 @@ export default function OpenTextQuestion({
/>
) : (
<textarea
autoFocus={!savedAnswer}
autoFocus={autoFocus}
rows={3}
name={question.id}
id={question.id}

View File

@@ -15,6 +15,7 @@ interface QuestionConditionalProps {
savedAnswer: any;
goToNextQuestion: (answer: any) => void;
goToPreviousQuestion?: (answer: any) => void;
autoFocus: boolean;
}
export default function QuestionConditional({
@@ -25,6 +26,7 @@ export default function QuestionConditional({
savedAnswer,
goToNextQuestion,
goToPreviousQuestion,
autoFocus,
}: QuestionConditionalProps) {
return question.type === QuestionType.OpenText ? (
<OpenTextQuestion
@@ -35,6 +37,7 @@ export default function QuestionConditional({
savedAnswer={savedAnswer}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
autoFocus={autoFocus}
/>
) : question.type === QuestionType.MultipleChoiceSingle ? (
<MultipleChoiceSingleQuestion

View File

@@ -64,6 +64,7 @@ export const env = createEnv({
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,

View File

@@ -132,6 +132,9 @@ export const useLinkSurveyUtils = (survey: Survey) => {
personId: personId,
finished,
data,
meta: {
url: window.location.href,
},
};
if (!responseId && !isPreview) {

View File

@@ -1,3 +1,4 @@
import { withSentryConfig } from "@sentry/nextjs";
import "./env.mjs";
import { createId } from "@paralleldrive/cuid2";
@@ -65,4 +66,39 @@ const nextConfig = {
},
};
export default nextConfig;
const sentryOptions = {
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
// Suppresses source map uploading logs during build
silent: true,
org: "formbricks",
project: "formbricks-cloud",
};
const sentryConfig = {
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Transpiles SDK to be compatible with IE11 (increases bundle size)
transpileClientSDK: true,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
};
const exportConfig = process.env.NEXT_PUBLIC_SENTRY_DSN
? withSentryConfig(nextConfig, sentryOptions, sentryConfig)
: nextConfig;
export default exportConfig;

View File

@@ -19,22 +19,23 @@
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^1.7.15",
"@headlessui/react": "^1.7.16",
"@heroicons/react": "^2.0.18",
"@json2csv/node": "^7.0.1",
"@paralleldrive/cuid2": "^2.2.1",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@sentry/nextjs": "^7.60.1",
"@t3-oss/env-nextjs": "^0.6.0",
"bcryptjs": "^2.4.3",
"eslint-config-next": "^13.4.10",
"eslint-config-next": "^13.4.12",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
"lucide-react": "^0.260.0",
"lucide-react": "^0.263.1",
"next": "13.4.10",
"next-auth": "^4.22.1",
"nodemailer": "^6.9.3",
"posthog-js": "^1.71.0",
"next-auth": "^4.22.3",
"nodemailer": "^6.9.4",
"posthog-js": "^1.75.2",
"prismjs": "^1.29.0",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
@@ -48,7 +49,7 @@
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@types/bcryptjs": "^2.4.2",
"@types/lodash": "^4.14.195",
"@types/lodash": "^4.14.196",
"@types/markdown-it": "^12.2.3",
"eslint-config-formbricks": "workspace:*"
}

View File

@@ -22,12 +22,16 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Missing personId" });
}
const session = await createSession(personId);
const settings = await getSettings(environmentId, personId);
try {
const session = await createSession(personId);
const settings = await getSettings(environmentId, personId);
captureTelemetry("session created");
captureTelemetry("session created");
return res.json({ session, settings });
return res.json({ session, settings });
} catch (error) {
res.status(500).json({ message: error.message });
}
}
// Unknown HTTP Method

View File

@@ -0,0 +1,30 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
new Sentry.Replay({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
});

View File

@@ -0,0 +1,16 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@@ -0,0 +1,15 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@@ -1,45 +1,29 @@
# Formbricks Quickstart Using Docker
# Self Host Formbricks Production Instance
Follow the instructions below to quickly get Formbricks running on your system with Docker. This guide is designed for most users who want a straightforward setup process.
Follow this guide to get your Formbricks instance up and running with a Postgres DB and SSL certificate using a single script:
1. **Create a New Directory for Formbricks**
## Requirements
Open a terminal and create a new directory for Formbricks, then navigate into this new directory:
Before you proceed, make sure you have the following:
\```bash
mkdir formbricks-quickstart && cd formbricks-quickstart
\```
- A Linux Ubuntu Virtual Machine deployed with SSH access.
2. **Download the Docker-Compose File**
- An A record set up to connect a custom domain to your instance. Formbricks will automatically create an SSL certificate for your domain using Let's Encrypt.
Download the docker-compose file directly from the Formbricks repository:
## Single Command Setup
\```bash
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/docker/main/docker-compose.yml
\```
Copy and paste the following command into your terminal:
3. **Generate NextAuth Secret**
```bash
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/production.sh)"
```
Next, you need to generate a NextAuth secret. This will be used for session signing and encryption. The `sed` command below generates a random string using `openssl`, then replaces the `NEXTAUTH_SECRET:` placeholder in the `docker-compose.yml` file with this generated secret:
The script will prompt you for the following information:
\```bash
sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.\*/NEXTAUTH_SECRET: $(openssl rand -base64 32)/" docker-compose.yml
\```
1. **Overwriting Docker GPG Keys**: If Docker GPG keys already exist, the script will ask if you want to overwrite them.
4. **Start the Docker Setup**
2. **Email Address**: Provide your email address for SSL certificate registration with Let's Encrypt.
You're now ready to start the Formbricks Docker setup. The following command will start Formbricks together with a postgreSQL database using Docker Compose:
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
\```bash
docker compose up -d
\```
The `-d` flag will run the containers in detached mode, meaning they'll run in the background.
5. **Visit Formbricks in Your Browser**
After starting the Docker setup, visit http://localhost:3000 in your browser to interact with the Formbricks application. The first time you access this page, you'll be greeted by a setup wizard. Follow the prompts to define your first user and get started.
Enjoy using Formbricks!
Note: For detailed documentation of local setup, take a look at our [self hosting docs](https://formbricks.com/docs/self-hosting/deployment)
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!

196
docker/production.sh Normal file
View File

@@ -0,0 +1,196 @@
#!/bin/env bash
set -e
ubuntu_version=$(lsb_release -a 2>/dev/null | grep -v "No LSB modules are available." | grep "Description:" | awk -F "Description:\t" '{print $2}')
# Friendly welcome
echo "🧱 Welcome to the Formbricks single instance installer"
echo ""
echo "🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your $ubuntu_version server."
echo ""
# Remove any old Docker installations, without stopping the script if they're not found
echo "🧹 Time to sweep away any old Docker installations."
sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true
# Update package list
echo "🔄 Updating your package list."
sudo apt-get update >/dev/null 2>&1
# Install dependencies
echo "📦 Installing the necessary dependencies."
sudo apt-get install -y \
ca-certificates \
curl \
gnupg \
lsb-release >/dev/null 2>&1
# Set up Docker's official GPG key & stable repository
echo "🔑 Adding Docker's official GPG key and setting up the stable repository."
sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null 2>&1
# Update package list again
echo "🔄 Updating your package list again."
sudo apt-get update >/dev/null 2>&1
# Install Docker
echo "🐳 Installing Docker."
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1
# Test Docker installation
echo "🚀 Testing your Docker installation."
if docker --version >/dev/null 2>&1; then
echo "🎉 Docker is installed!"
else
echo "❌ Docker is not installed. Please install Docker before proceeding."
exit 1
fi
# Adding your user to the Docker group
echo "🐳 Adding your user to the Docker group to avoid using sudo with docker commands."
sudo groupadd docker >/dev/null 2>&1 || true
sudo usermod -aG docker $USER >/dev/null 2>&1
echo "🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!"
# Installing Traefik
echo "🚗 Installing Traefik..."
mkdir -p formbricks && cd formbricks
echo "📁 Created Formbricks Quickstart directory at ./formbricks."
# Ask the user for their email address
echo "💡 Please enter your email address for the SSL certificate:"
read email_address
cat <<EOT >traefik.yaml
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
http:
tls:
certResolver: default
providers:
docker:
watch: true
exposedByDefault: false
certificatesResolvers:
default:
acme:
email: $email_address
storage: acme.json
caServer: "https://acme-v01.api.letsencrypt.org/directory"
tlsChallenge: {}
EOT
echo "💡 Created traefik.yaml file with your provided email address."
touch acme.json
chmod 600 acme.json
echo "💡 Created acme.json file with correct permissions."
# Ask the user for their email address
echo "🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):"
read domain_name
cat <<EOT >docker-compose.yml
version: "3.3"
x-environment: &environment
environment:
########################################################################
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
########################################################################
# PostgreSQL DB for Formbricks to connect to
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
# PRISMA_GENERATE_DATAPROXY=true
PRISMA_GENERATE_DATAPROXY:
# NextJS Auth
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
# You can use: $(openssl rand -base64 32) to generate one
NEXTAUTH_SECRET:
# Set this to your public-facing URL, e.g., https://example.com
# You do not need the NEXTAUTH_URL environment variable in Vercel.
NEXTAUTH_URL: "https://$domain_name"
services:
postgres:
restart: always
image: postgres:15-alpine
volumes:
- postgres:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
formbricks:
restart: always
image: formbricks/formbricks:latest
depends_on:
- postgres
labels:
- "traefik.enable=true" # Enable Traefik for this service
- "traefik.http.routers.formbricks.rule=Host(\`$domain_name\`)" # Replace your_domain_name with your actual domain or IP
- "traefik.http.routers.formbricks.entrypoints=websecure" # Use the websecure entrypoint (port 443 with TLS)
- "traefik.http.services.formbricks.loadbalancer.server.port=3000" # Forward traffic to Formbricks on port 3000
ports:
- 3000:3000
<<: *environment
traefik:
image: "traefik:v2.7"
restart: always
container_name: "traefik"
depends_on:
- formbricks
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- ./traefik.yaml:/traefik.yaml
- ./acme.json:/acme.json
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
postgres:
driver: local
EOT
update_nextauth_secret() {
nextauth_secret=$(openssl rand -base64 32)
sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.\*/NEXTAUTH_SECRET: $nextauth_secret/" docker-compose.yml
}
echo "🚙 Updating NEXTAUTH_SECRET in the Formbricks container..."
while true; do
if update_nextauth_secret; then
echo "🚗 NEXTAUTH_SECRET updated successfully!"
break
else
echo "🚧 Failed to update NEXTAUTH_SECRET. Retrying..."
fi
done
newgrp docker << END
docker compose up -d
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
echo ""
echo "🎉 All done! Check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'"

View File

@@ -17,7 +17,7 @@
"db:migrate:deploy": "turbo run db:migrate:deploy",
"db:migrate:vercel": "turbo run db:migrate:vercel",
"db:push": "turbo run db:push",
"go": "turbo run go",
"go": "turbo run go --concurrency 16",
"dev": "turbo run dev --parallel",
"start": "turbo run start --parallel",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",

View File

@@ -17,7 +17,6 @@
"db:up": "docker-compose up -d",
"db:setup": "pnpm db:up && pnpm db:migrate:dev",
"db:start": "pnpm db:setup",
"go": "pnpm db:setup",
"format": "prisma format",
"generate": "prisma generate",
"lint": "eslint ./src --fix",

View File

@@ -16,7 +16,7 @@
},
"dependencies": {
"@formbricks/database": "workspace:*",
"next": "13.4.10",
"stripe": "^12.13.0"
"next": "13.4.12",
"stripe": "^12.16.0"
}
}

View File

@@ -8,10 +8,10 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"eslint": "^8.45.0",
"eslint-config-next": "^13.4.10",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "7.32.2",
"eslint": "^8.46.0",
"eslint-config-next": "^13.4.12",
"eslint-config-prettier": "^8.9.0",
"eslint-plugin-react": "7.33.1",
"eslint-config-turbo": "latest"
}
}

View File

@@ -50,21 +50,21 @@
"@formbricks/api": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"autoprefixer": "^10.4.14",
"babel-jest": "^29.6.1",
"babel-jest": "^29.6.2",
"cross-env": "^7.0.3",
"eslint-config-formbricks": "workspace:*",
"eslint-config-preact": "^1.3.0",
"isomorphic-fetch": "^3.0.0",
"jest": "^29.6.1",
"jest-environment-jsdom": "^29.6.1",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"jest-fetch-mock": "^3.0.3",
"jest-preset-preact": "^4.1.0",
"microbundle": "^0.15.1",
"preact": "10.16.0",
"preact-cli": "^3.4.6",
"preact-cli": "^3.5.0",
"preact-render-to-string": "^6.2.0",
"regenerator-runtime": "^0.13.11"
},

View File

@@ -48,6 +48,7 @@ export default function NPSQuestion({
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
setSelectedChoice(null);
onSubmit({
[question.id]: number,
});
@@ -79,7 +80,7 @@ export default function NPSQuestion({
value={number}
checked={selectedChoice === number}
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onChange={() => handleSelect(number)}
onClick={() => handleSelect(number)}
required={question.required}
/>
{number}

View File

@@ -3,7 +3,7 @@ import type { JSX } from "preact";
export const TiredFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -64,7 +64,7 @@ export const TiredFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>>
export const WearyFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -125,7 +125,7 @@ export const WearyFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>>
export const PerseveringFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -195,7 +195,7 @@ export const PerseveringFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElem
export const FrowningFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -225,7 +225,7 @@ export const FrowningFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement
export const ConfusedFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -254,7 +254,7 @@ export const ConfusedFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement
export const NeutralFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -287,7 +287,7 @@ export const NeutralFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>
export const SlightlySmilingFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -319,7 +319,7 @@ export const SmilingFaceWithSmilingEyes: FunctionComponent<JSX.HTMLAttributes<SV
props
) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -365,7 +365,7 @@ export const GrinningFaceWithSmilingEyes: FunctionComponent<JSX.HTMLAttributes<S
props
) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -417,7 +417,7 @@ export const GrinningFaceWithSmilingEyes: FunctionComponent<JSX.HTMLAttributes<S
export const GrinningSquintingFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
@@ -468,4 +468,6 @@ export const GrinningSquintingFace: FunctionComponent<JSX.HTMLAttributes<SVGCirc
);
};
export let icons = [<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg"></svg>];
export let icons = [
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}></svg>,
];

View File

@@ -232,6 +232,9 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
personId: config.state.person.id,
finished,
data,
meta: {
url: window.location.href,
},
};
if (!responseId) {
const [response, _] = await Promise.all([

View File

@@ -38,42 +38,40 @@ export const getEnvironment = cache(async (environmentId: string): Promise<TEnvi
}
});
export const getEnvironments = cache(
async (productId: string): Promise<TEnvironment[]> => {
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
id: productId,
},
include:{
environments:true
}
});
export const getEnvironments = cache(async (productId: string): Promise<TEnvironment[]> => {
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
id: productId,
},
include: {
environments: true,
},
});
if (!productPrisma) {
throw new ResourceNotFoundError("Product", productId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
if (!productPrisma) {
throw new ResourceNotFoundError("Product", productId);
}
const environments:TEnvironment[]=[];
for(let environment of productPrisma.environments){
let targetEnvironment:TEnvironment=ZEnvironment.parse(environment);
environments.push(targetEnvironment);
}
try {
return environments;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environments array failed");
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
);
const environments: TEnvironment[] = [];
for (let environment of productPrisma.environments) {
let targetEnvironment: TEnvironment = ZEnvironment.parse(environment);
environments.push(targetEnvironment);
}
try {
return environments;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environments array failed");
}
});

View File

@@ -33,13 +33,10 @@ type TransformPersonInput = {
};
export const transformPrismaPerson = (person: TransformPersonInput): TPerson => {
const attributes = person.attributes.reduce(
(acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
},
{} as Record<string, string | number>
);
const attributes = person.attributes.reduce((acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
}, {} as Record<string, string | number>);
return {
id: person.id,

View File

@@ -41,4 +41,3 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
throw new ValidationError("Data validation of product failed");
}
});

View File

@@ -10,7 +10,7 @@
"@tailwindcss/forms": "^0.5.4",
"@tailwindcss/typography": "^0.5.9",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.26",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3"
}
}

View File

@@ -6,8 +6,8 @@
"clean": "rimraf node_modules"
},
"devDependencies": {
"@types/node": "20.4.2",
"@types/react": "18.2.15",
"@types/node": "20.4.5",
"@types/react": "18.2.17",
"@types/react-dom": "18.2.7",
"typescript": "5.1.6"
}

View File

@@ -28,6 +28,7 @@ const ZResponseNote = z.object({
export type TResponseNote = z.infer<typeof ZResponseNote>;
export const ZResponseMeta = z.object({
url: z.string(),
userAgent: z.object({
browser: z.string().optional(),
os: z.string().optional(),
@@ -67,6 +68,7 @@ export const ZResponseInput = z.object({
data: ZResponseData,
meta: z
.object({
url: z.string().optional(),
userAgent: z
.object({
browser: z.string().optional(),

View File

@@ -12,7 +12,7 @@ export const ColorPicker = ({ color, onChange }: { color: string; onChange: (v:
<div>
#
<HexColorInput
className="ml-2 mr-2 h-10 w-16 bg-transparent text-slate-500 outline-none focus:border-none"
className="ml-2 mr-2 h-10 w-32 border-0 bg-transparent text-slate-500 outline-none focus:border-none"
color={color}
onChange={onChange}
/>

View File

@@ -7,6 +7,7 @@ export interface InputProps
dangerouslySetInnerHTML?: {
__html: string;
};
isInvalid?: boolean;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
@@ -14,7 +15,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...pr
<input
className={cn(
"focus:border-brand flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
className
className,
props.isInvalid && "border border-red-600 focus:border-red-600"
)}
ref={ref}
{...props}

View File

@@ -16,19 +16,19 @@
"@formbricks/tsconfig": "workspace:*",
"concurrently": "^8.2.0",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.26",
"postcss": "^8.4.27",
"react": "18.2.0"
},
"dependencies": {
"@formbricks/lib": "workspace:*",
"@heroicons/react": "^2.0.18",
"@lexical/code": "^0.11.2",
"@lexical/link": "^0.11.2",
"@lexical/list": "^0.11.2",
"@lexical/markdown": "^0.11.2",
"@lexical/react": "^0.11.2",
"@lexical/rich-text": "^0.11.2",
"@lexical/table": "^0.11.2",
"@lexical/code": "^0.11.3",
"@lexical/link": "^0.11.3",
"@lexical/list": "^0.11.3",
"@lexical/markdown": "^0.11.3",
"@lexical/react": "^0.11.3",
"@lexical/rich-text": "^0.11.3",
"@lexical/table": "^0.11.3",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
@@ -42,8 +42,8 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"lucide-react": "^0.260.0",
"next": "13.4.10",
"lucide-react": "^0.263.1",
"next": "13.4.12",
"react-colorful": "^5.6.1",
"react-confetti": "^6.1.0",
"react-day-picker": "^8.8.0",

2234
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,11 @@
"@formbricks/web#go": {
"cache": false,
"persistent": true,
"dependsOn": ["@formbricks/database#go", "@formbricks/js#build"]
"dependsOn": ["@formbricks/database#db:setup", "@formbricks/js#build"]
},
"@formbricks/api#build": {
"outputs": ["dist/**"],
"dependsOn": ["^build"]
},
"@formbricks/js#build": {
"outputs": ["dist/**"],
@@ -55,6 +59,7 @@
"NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID",
"NEXT_PUBLIC_FORMBRICKS_URL",
"NEXT_PUBLIC_IMPRINT_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_VERCEL_URL",
"NODE_ENV",
"NEXT_PUBLIC_POSTHOG_API_HOST",
@@ -72,7 +77,12 @@
"VERCEL_URL"
]
},
"db:setup": {
"cache": false,
"outputs": []
},
"go": {
"persistent": true,
"cache": false
},
"prebuild": {