merge latest changes

This commit is contained in:
Matthias Nannt
2023-07-13 15:22:10 +02:00
207 changed files with 3357 additions and 2025 deletions

View File

@@ -97,4 +97,7 @@ GITHUB_SECRET=
# Configure Google Login
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=0
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_SECRET=
# Cron Secret
CRON_SECRET=

View File

@@ -46,7 +46,8 @@ NEXTAUTH_URL=http://localhost:3000
MAIL_FROM=noreply@example.com
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
SMTP_SECURE_ENABLED=0
SMTP_USER=smtpUser
SMTP_PASSWORD=smtpPassword
@@ -104,4 +105,4 @@ NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Cron Secret
CRON_SECRET=
CRON_SECRET=

View File

@@ -27,8 +27,11 @@ jobs:
- name: Build formbricks-js dependencies
run: pnpm build --filter=js
- name: create .env
run: cp .env.example .env
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
#- name: Test
# run: pnpm test

23
.github/workflows/cron-closeOnDate.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Cron - weeklySummary
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At 00:00.” (see https://crontab.guru)
- cron: "0 0 * * *"
jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/close_surveys \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
--fail

View File

@@ -19,5 +19,5 @@ jobs:
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
-H 'x-api-key: ${{ secrets.CRON_SECRET }}' \
--fail

View File

@@ -1,38 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -5,6 +5,7 @@
"scripts": {
"clean": "rimraf .turbo node_modules .next",
"dev": "next dev -p 3002 --turbo",
"go": "next dev -p 3002 --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"

View File

@@ -12,10 +12,9 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
logLevel: "debug",
debug: true,
});
window.formbricks = formbricks;
formbricks.refresh();
}
}

View File

@@ -1,38 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -9,10 +9,9 @@ import { Button } from "@formbricks/ui";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import MetaInformation from "../shared/MetaInformation";
import DocsFeedback from "./DocsFeedback";
import { useRef } from "react";
function GitHubIcon(props: any) {
return (
@@ -23,7 +22,6 @@ function GitHubIcon(props: any) {
}
function Header({ navigation }: any) {
const router = useRouter();
let [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
@@ -63,13 +61,15 @@ function Header({ navigation }: any) {
variant="secondary"
EndIcon={GitHubIcon}
endIconClassName="fill-slate-800 dark:fill-slate-200 ml-2"
onClick={() => router.push("https://github.com/formbricks/formbricks")}>
View on Github
href="https://github.com/formbricks/formbricks"
target="_blank">
Star us on Github
</Button>
<Button
variant="highlight"
className="ml-2"
onClick={() => router.push("https://app.formbricks.com/auth/signup")}>
href="https://app.formbricks.com/auth/signup"
target="_blank">
Get started
</Button>
</div>
@@ -101,6 +101,26 @@ export const Layout: React.FC<LayoutProps> = ({ children, meta }) => {
sessionStorage.setItem("scrollPosition", (scroll + 89).toString());
};
const useExternalLinks = (selector: string) => {
useEffect(() => {
const links = document.querySelectorAll(selector);
links.forEach((link) => {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
});
return () => {
links.forEach((link) => {
link.removeAttribute("target");
link.removeAttribute("rel");
});
};
}, [selector]);
};
useExternalLinks(".prose a");
useEffect(() => {
if (parentRef.current) {
const scrollPosition = Number.parseInt(sessionStorage.getItem("scrollPosition"), 10);

View File

@@ -69,6 +69,7 @@ export const templates: Template[] = [
headline: "How disappointed would you be if you could no longer use Formbricks?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -90,6 +91,7 @@ export const templates: Template[] = [
headline: "What is your role?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -154,6 +156,7 @@ export const templates: Template[] = [
headline: "What is your role?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -183,6 +186,7 @@ export const templates: Template[] = [
headline: "What's your company size?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -212,6 +216,7 @@ export const templates: Template[] = [
headline: "How did you hear about us first?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -253,6 +258,7 @@ export const templates: Template[] = [
headline: "What do you value most about our service?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -278,6 +284,7 @@ export const templates: Template[] = [
headline: "What should we improve on?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -323,6 +330,7 @@ export const templates: Template[] = [
headline: "How did you hear about us first?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -364,6 +372,7 @@ export const templates: Template[] = [
headline: "Why did you cancel your subscription?",
subheader: "We're sorry to see you leave. Please help us do better:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -413,6 +422,7 @@ export const templates: Template[] = [
headline: "Why did you stop your trial?",
subheader: "Help us understand you better:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -468,6 +478,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "How easy was it to change your plan?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -496,6 +507,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "Is the pricing information easy to understand?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -528,6 +540,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "Were you able to accomplish what you came here to do today?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -578,6 +591,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "What's your primary goal for using Formbricks?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -668,6 +682,7 @@ export const templates: Template[] = [
headline: "How disappointed would you be if you could no longer use Formbricks?",
subheader: "Please select one of the following options:",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -709,6 +724,7 @@ export const templates: Template[] = [
headline: "What's on your mind, boss?",
subheader: "Thanks for sharing. We'll get back to you asap.",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -744,6 +760,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "How easy was it to set this integration up?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -792,6 +809,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "Which other tools are you using?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -839,6 +857,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "Was this page helpful?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -945,6 +964,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "How many hours does your team save per week by using Formbricks?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
@@ -1039,6 +1059,7 @@ export const templates: Template[] = [
type: QuestionType.MultipleChoiceSingle,
headline: "Do you have all the info you need to give Formbricks a try?",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),

View File

@@ -5,7 +5,7 @@ import CrowdLogoDark from "@/images/clients/crowd-logo-dark.svg";
import CrowdLogoLight from "@/images/clients/crowd-logo-light.svg";
import NILogoDark from "@/images/clients/niLogoDark.svg";
import NILogoLight from "@/images/clients/niLogoWhite.svg";
import AnimationFallback from "@/public/animations/fallback-image-open-source-feedback-software.jpg";
import AnimationFallback from "@/public/animations/opensource-xm-platform-formbricks-fallback.png";
import { Button } from "@formbricks/ui";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { usePlausible } from "next-plausible";
@@ -27,14 +27,14 @@ export const Hero: React.FC = ({}) => {
<ChevronRightIcon className="inline h-5 w-5 text-slate-300" />
</a>
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<span className="xl:inline">Create Products People Remember</span>
<span className="xl:inline">Open-source Experience Management</span>
</h1>
<p className="xs:max-w-none mx-auto mt-3 max-w-xs text-base text-slate-500 dark:text-slate-400 sm:text-lg md:mt-5 md:text-xl">
Understand what customers think & feel about your product.
<br />
<span className="hidden md:block">
Continuously gather deep user insights,{" "}
Natively integrate user research with minimal dev attention,{" "}
<span className="decoration-brand-dark underline underline-offset-4">privacy-first.</span>
</span>
</p>

View File

@@ -19,7 +19,7 @@ export const HeroAnimation: React.FC<any> = ({ fallbackImage, ...props }) => {
loop: true,
autoplay: true,
// path to your animation file, place it inside public folder
path: "/animations/formbricks-open-source-survey-software-hero-animation-v1.json",
path: "/animations/opensource-xm-platform-formbricks.json",
});
animation.addEventListener("DOMLoaded", () => {

View File

@@ -44,6 +44,10 @@ const navigation = [
{ title: "Docs Feedback", href: "/docs/best-practices/docs-feedback" },
],
},
{
title: "Integrations",
links: [{ title: "Zapier", href: "/docs/integrations/zapier" }],
},
{
title: "Link Surveys",
links: [

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -2,6 +2,10 @@ import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { APILayout } from "@/components/shared/APILayout.tsx";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import AddApiKey from "./add-api-key.png";
import ApiKeySecret from "./api-key-secret.png";
export const meta = {
title: "API Key Setup",
@@ -16,9 +20,11 @@ The API requests are authorized with a personal API key. This API key gives you
### How to generate an API key
1. Go to your settings on [app.formbricks.com](https://app.formbricks.com).
2. Go to page “API keys”.
2. Go to page “API keys”
<Image src={AddApiKey} alt="Add API Key" quality="100" className="rounded-lg" />
3. Create a key for the development or production environment.
4. Copy the key immediately. You wont be able to see it again.
<Image src={ApiKeySecret} alt="API Key Secret" quality="100" className="rounded-lg" />
<Callout title="Store API key safely" type="warning">
Anyone who has your API key has full control over your account. For security reasons, you cannot view the

View File

@@ -10,7 +10,7 @@ export const meta = {
description: "To test in-app surveys, trigger actions and set attributes, you can use the Demo App.",
};
To play around with the user actions, you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set attributes.
To play around with the in-app [User Actions](/docs/actions/why), you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set [Attributes](/docs/attributes/why).
<Image src={DemoApp} alt="Demo App Preview" quality="100" className="rounded-lg" />

View File

@@ -31,36 +31,24 @@ To get the project running locally on your machine you need to have the followin
pnpm install
```
1. To make the process of installing a dev dependencies easier, we offer a [`docker-compose.yml`](https://docs.docker.com/compose/) with the following servers:
- a `postgres` container and environment variables preset to reach it,
- a `mailhog` container that acts as a mock SMTP server and shows received mails in a web UI (forwarded to your host's `localhost:8025`)
```bash
docker-compose -f docker-compose.dev.yml up -d
```
1. Create a `.env` file based on `.env.example`. It's already preset to work with the docker-compose setup but you can also change values if needed.
```bash
cp .env.example .env
```
1. Make sure your Docker containers are running. Then let prisma set up the database for you:
1. Make sure you have [`Docker`](https://docs.docker.com/compose/) & [`docker-compose`](https://docs.docker.com/compose/) installed and running on your machine. Then run the following command to start the formbricks dev setup:
```bash
pnpm prisma migrate dev
pnpm go
```
1. Start the development server of the app:
This starts the Formbricks main app (plus all its dependencies) as well as the following services using Docker:
```bash
pnpm dev --filter=web...
```
- a `postgres` container for hosting your database,
- a `mailhog` container that acts as a mock SMTP server and shows received mails in a web UI (forwarded to your host's `localhost:8025`)
This only starts the Formbricks main app plus all its dependencies to save memory. If you want to start all apps at once, run `pnpm dev` instead.
**You can now access the app on [http://localhost:3000](http://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of formbricks, create a new account.
**You can now access the Formbricks app on [http://localhost:3000](http://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of formbricks, create a new account.
For viewing the confirmation email and other emails the system sends you, you can access mailhog at [http://localhost:8025](http://localhost:8025)
@@ -72,4 +60,24 @@ To build all apps and packages and check for build errors, run the following com
pnpm build
```
### Access Demo app
To run the [Demo app](/docs/contributing/demo), run the following command in a separate terminal window:
```bash
pnpm dev --filter=demo
```
You can now access the Demo app on [http://localhost:3002](http://localhost:3002).
### Access Formbricks website
If you want to make changes to the Formbricks website, e.g. to update the documentation, run the following command in a separate terminal window:
```bash
pnpm dev --filter=formbricks-com
```
You can now access the Formbricks website on [http://localhost:3001](http://localhost:3001).
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

View File

@@ -47,7 +47,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
logLevel: "debug", // remove when in production
debug: true, // remove when in production
});
}

View File

@@ -49,7 +49,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
logLevel: "debug", // remove when in production
debug: true, // remove when in production
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,102 @@
import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import AddNewZap from "./add-new-zap.png";
import ChooseEvent from "./choose-event.png";
import ConnectWithFB1 from "./connect-with-formbricks-1.png";
import ConnectWithFB2 from "./connect-with-formbricks-2.png";
import DuplicateSurvey from "./duplicate-survey.png";
import SelectSurvey from "./select-survey.png";
import SlackChannelMsg from "./slack-channel-msg.png";
import SlackMsg from "./slack-message.png";
import SubmitTestResponse from "./submit-test-response.png";
import SuccessConnection from "./success-connected.png";
import TestSubmission from "./test-submission.png";
import UpdateQuestionId from "./update-question-id.png";
import ZapierMessage from "./zapier-message.png";
export const meta = {
title: "Zapier Setup",
description: "Wire up Formbricks with Zapier and 5000+ other apps",
};
Zapier is a powerful ally. Hook up Formbricks with Zapier and you can send your data to 5000+ other apps. Here is how to do it.
<Callout title="Nail down your survey first" type="note">
Any changes in the survey cause additional work in the Zap. It makes sense to first settle on the survey you
want to run and then get to setting up Zapier.
</Callout>
## Step 1: Setup your survey incl. `questionId` for every question
When setting up the Zap your life will be easier when you change the `questionId`s of your survey questions. You can only do so **before** you publish your survey.
<Image src={UpdateQuestionId} alt="Update Question ID" quality="100" className="rounded-lg" />
_In every question card in the Advanced Settings you find the Question ID field. Update it so that youll recognize the response tied to this question._
<Callout title="Already published? Duplicate survey" type="note">
You can only update the questionId when the survey was not yet published. Already published it? Just
**duplicate it** to update the questionIds.
<Image src={DuplicateSurvey} alt="Duplicate Survey" quality="100" className="rounded-lg" />
</Callout>
## Step 3: Send a test response
In order to set up Zapier youll need a test response. This allows you to select the individual values of each response in your Zap. If you have Formbricks running locally and you want to set up an in-app survey, you can use our [Demo App](/docs/contributing/demo) to trigger a survey and submit a response.
<Image src={SubmitTestResponse} alt="Submit Test Response" quality="100" className="rounded-lg" />
## Step 4: Setup your Zap
Go to [zapier.com](https://zapier.com) and create a new Zap. Search for “Formbricks” to get started:
<Image src={AddNewZap} alt="Add New Zap" quality="100" className="rounded-lg" />
Then, choose the event you want to trigger the Zap on:
<Image src={ChooseEvent} alt="Choose Event" quality="100" className="rounded-lg" />
## Step 5: Connect Formbricks with Zapier
Now, you have to connect Zapier with Formbricks via an API Key:
<Image src={ConnectWithFB1} alt="Connect with Formbricks - 1" quality="100" className="rounded-lg" />
<Image src={ConnectWithFB2} alt="Connect with Formbricks - 2" quality="100" className="rounded-lg" />
Now you need an API key. Please refer to the [API Key Setup](/docs/api/api-key-setup) page to learn how to create one.
Once you copied it in the newly opened Zapier window, you will be connected:
<Image src={SuccessConnection} alt="Successful Connection" quality="100" className="rounded-lg" />
## Step 6: Select Survey
Next, you can choose from all the surveys you have created in this environment:
<Image src={SelectSurvey} alt="Select Survey" quality="100" className="rounded-lg" />
## Step 7: Test your trigger
Once you hit “Test” you will see the three most recent submissions for this survey. If you dont have any submissions in the survey, submit one to continue setting up your Zap:
<Image src={TestSubmission} alt="Test Submission" quality="100" className="rounded-lg" />
_Now you're happy that you updated the questionId's_
## Step 8: Set up your Zap
Now you have all the data you need at hand. The next steps depend on what you want to do with it. In this tutorial, we will send submissions to a Slack channel:
<Image src={SlackChannelMsg} alt="Slack Channel Message" quality="100" className="rounded-lg" />
In the action itself we can determine the data and layout of the message. Here, we only choose the submission data. You can also refer to the meta data of the submission and the [attributes](/docs/attributes/why) of the person who submitted the survey.
<Image src={SlackMsg} alt="Slack Message" quality="100" className="rounded-lg" />
We now receive a notifcation in our Slack channel whenever a Churn survey is completed:
<Image src={ZapierMessage} alt="Zapier Message" quality="100" className="rounded-lg" />
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -78,7 +78,7 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id
### Multi Select Question (Checkbox)
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id=Sun%2CPalms%2CBeach
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?multi_select_question_id=Sun%2CPalms%2CBeach
// -> Selects three options "Sun, Palms and Beach" in the multi select question. The strings have to be identical to the options in your question.
```

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Add a new webhook."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -27,11 +27,17 @@ export const meta = {
required: true,
},
{
label: "trigger",
type: "string",
description: "The event that will trigger the webhook.",
label: "triggers",
type: "string[]",
description: "List of events that will trigger the webhook",
required: true,
},
{
label: "surveyIds",
type: "string[]",
description:
"List of survey IDs that will trigger the webhook. If not provided, the webhook will be triggered for all surveys.",
},
]}
example={`{
"url": "https://mysystem.com/myendpoint",
@@ -51,7 +57,8 @@ export const meta = {
"environmentId": "clisypjy4000319t4imm289uo",
"triggers": [
"responseFinished"
]
],
"surveyIds": ["clisypjy4000319t4imm289uo"]
}
}`,
},
@@ -75,16 +82,17 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},
]}
/>
| field name | required | default | description |
| ---------- | -------- | ------- | ------------------------------------------------------------------------------------------------------ |
| url | yes | - | The endpoint that the webhook will send data to |
| trigger | yes | - | The event that will trigger the webhook ("responseCreated" or "responseUpdated" or "responseFinished") |
| field name | required | default | description |
| ---------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| url | yes | - | The endpoint that the webhook will send data to |
| trigger | yes | - | The event that will trigger the webhook ("responseCreated" or "responseUpdated" or "responseFinished") |
| surveyIds | no | - | List of survey IDs that will trigger the webhook. If not provided, the webhook will be triggered for all surveys. |
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Delete a specific webhook by its ID."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -45,7 +45,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Retrieve a specific webhook by its ID."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -45,7 +45,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Retrieve a list of all webhooks."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -47,7 +47,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -10,7 +10,7 @@ import BestPractices from "@/components/shared/BestPractices";
const IndexPage = () => (
<Layout
title="Formbricks | Privacy-first user research"
title="Formbricks | Privacy-first Experience Management"
description="Build qualitative user research into your product. Leverage Best practices to increase Product-Market Fit.">
<Hero />
<div className="hidden lg:block">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

View File

@@ -23,7 +23,7 @@ USER nextjs
WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.js .
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Automatically leverage output traces to reduce image size

View File

@@ -1,80 +0,0 @@
# Formbricks
> still in development
Everything you always wanted (from a form tool)...
The days of scattered response data are counted. Manage all form data in one place. Analyze right here or pipe your data where you need it.
### How to run locally (for development)
To get the project running locally on your machine you need to have the following development tools installed:
- Node.JS (we recommend v18)
- [pnpm](https://pnpm.io/)
- [Docker](https://www.docker.com/) (to run PostgreSQL / MailHog)
1. Clone the project:
```sh
git clone https://github.com/formbricks/formbricks
```
and move into the directory
```sh
cd formbricks
```
1. Install Node.JS packages via pnpm. Don't have pnpm? Get it [here](https://pnpm.io/installation)
```sh
pnpm install
```
1. To make the process of installing a dev dependencies easier, we offer a [`docker-compose.yml`](https://docs.docker.com/compose/) with the following servers:
- a `postgres` container and environment variables preset to reach it,
- a `mailhog` container that acts as a mock SMTP server and shows received mails in a web UI (forwarded to your host's `localhost:8025`)
```sh
docker-compose -f docker-compose.dev.yml up -d
```
1. Create a `.env` file based on `.env.example` and change it according to your setup. If you are using a cloud based database or another mail server, you will need to update the `DATABASE_URL` and SMTP settings in your `.env` accordingly.
```sh
cp .env.example .env
```
1. Make sure your PostgreSQL Database Server is running. Then let prisma set up the database for you:
```sh
pnpm prisma migrate dev
```
1. Start the development server:
```sh
pnpm dev
```
**You can now access the app on [http://localhost:3000](http://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of formbricks, create a new account.
For viewing the confirmation email and other emails the system sends you, you can access mailhog at [http://localhost:8025](http://localhost:8025)
### Build
To build all apps and packages, run the following command:
```sh
pnpm build
```
### Develop
To develop all apps and packages, run the following command:
```sh
pnpm dev
```

View File

@@ -9,7 +9,7 @@ import { useEffect } from "react";
formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
logLevel: "debug",
debug: true,
});
} */

View File

@@ -0,0 +1,43 @@
import { responses } from "@/lib/api/response";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
export async function POST() {
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey || apiKey !== process.env.CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const surveys = await prisma.survey.findMany({
where: {
status: "inProgress",
closeOnDate: {
lte: new Date(),
},
},
select: {
id: true,
},
});
if (!surveys.length) {
return responses.successResponse({ message: "No surveys to close" });
}
const mutationResp = await prisma.survey.updateMany({
where: {
id: {
in: surveys.map((survey) => survey.id),
},
},
data: {
status: "completed",
},
});
return responses.successResponse({
message: `Closed ${mutationResp.count} survey(s)`,
});
}

View File

@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
import { AttributeClass } from "@prisma/client";
import { sendResponseFinishedEmail } from "@/lib/email";
import { Question } from "@formbricks/types/questions";
import { NotificationSettings } from "@formbricks/types/users";
export async function POST(request: Request) {
const { internalSecret, environmentId, surveyId, event, data } = await request.json();
@@ -51,6 +52,18 @@ export async function POST(request: Request) {
triggers: {
hasSome: event,
},
OR: [
{
surveyIds: {
has: surveyId,
},
},
{
surveyIds: {
isEmpty: true,
},
},
],
},
});
@@ -60,6 +73,7 @@ export async function POST(request: Request) {
await fetch(webhook.url, {
method: "POST",
body: JSON.stringify({
webhookId: webhook.id,
event,
data,
}),
@@ -91,14 +105,11 @@ export async function POST(request: Request) {
});
// filter all users that have email notifications enabled for this survey
const usersWithNotifications = users.filter((user) => {
if (!user.notificationSettings) {
return false;
const notificationSettings: NotificationSettings | null = user.notificationSettings;
if (notificationSettings?.alert && notificationSettings.alert[surveyId]) {
return true;
}
const notificationSettings = user.notificationSettings[surveyId];
if (!notificationSettings || !notificationSettings.responseFinished) {
return false;
}
return true;
return false;
});
if (usersWithNotifications.length > 0) {

View File

@@ -0,0 +1,75 @@
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { ZJsActionInput } from "@formbricks/types/v1/js";
import { EventType } from "@prisma/client";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request): Promise<NextResponse> {
try {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsActionInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, sessionId, name, properties } = inputValidation.data;
let eventType: EventType = EventType.code;
if (name === "Exit Intent (Desktop)" || name === "50% Scroll") {
eventType = EventType.automatic;
}
await prisma.event.create({
data: {
properties,
session: {
connect: {
id: sessionId,
},
},
eventClass: {
connectOrCreate: {
where: {
name_environmentId: {
name,
environmentId,
},
},
create: {
name,
type: eventType,
environment: {
connect: {
id: environmentId,
},
},
},
},
},
},
select: {
id: true,
},
});
return responses.successResponse({}, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -0,0 +1,128 @@
import { getSurveys } from "@/app/api/v1/js/surveys";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getPerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/v1/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, { params }): Promise<NextResponse> {
try {
const { personId } = params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, sessionId, key, value } = inputValidation.data;
const existingPerson = await getPerson(personId);
if (!existingPerson) {
return responses.notFoundResponse("Person", personId, true);
}
// find attribute class
let attributeClass = await prisma.attributeClass.findUnique({
where: {
name_environmentId: {
name: key,
environmentId,
},
},
select: {
id: true,
},
});
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await prisma.attributeClass.create({
data: {
name: key,
type: "code",
environment: {
connect: {
id: environmentId,
},
},
},
select: {
id: true,
},
});
}
// upsert attribute (update or create)
const attribute = await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
attributeClassId: attributeClass.id,
personId,
},
},
update: {
value,
},
create: {
attributeClass: {
connect: {
id: attributeClass.id,
},
},
person: {
connect: {
id: personId,
},
},
value,
},
select: {
person: {
select,
},
},
});
const person = transformPrismaPerson(attribute.person);
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
extendSession(sessionId),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -0,0 +1,120 @@
import { getSurveys } from "@/app/api/v1/js/surveys";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { deletePerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/v1/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, { params }): Promise<NextResponse> {
try {
const { personId } = params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId, sessionId } = inputValidation.data;
let returnedPerson;
// check if person with this userId exists
const existingPerson = await prisma.person.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeClass: {
name: "userId",
},
value: userId,
},
},
},
select,
});
// if person exists, reconnect session and delete old user
if (existingPerson) {
// reconnect session to new person
await prisma.session.update({
where: {
id: sessionId,
},
data: {
person: {
connect: {
id: existingPerson.id,
},
},
},
});
// delete old person
await deletePerson(personId);
returnedPerson = existingPerson;
} else {
// update person with userId
returnedPerson = await prisma.person.update({
where: {
id: personId,
},
data: {
attributes: {
create: {
value: userId,
attributeClass: {
connect: {
name_environmentId: {
name: "userId",
environmentId,
},
},
},
},
},
},
select,
});
}
const person = transformPrismaPerson(returnedPerson);
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
extendSession(sessionId),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -0,0 +1,153 @@
import { prisma } from "@formbricks/database";
import { select } from "@formbricks/lib/services/survey";
import { TPerson } from "@formbricks/types/v1/people";
import { TSurvey } from "@formbricks/types/v1/surveys";
export const getSurveys = async (environmentId: string, person: TPerson): Promise<TSurvey[]> => {
// get recontactDays from product
const product = await prisma.product.findFirst({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: {
recontactDays: true,
},
});
if (!product) {
throw new Error("Product not found");
}
// get all surveys that meet the displayOption criteria
const potentialSurveys = await prisma.survey.findMany({
where: {
OR: [
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "respondMultiple",
},
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "displayOnce",
displays: { none: { personId: person.id } },
},
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "displayMultiple",
displays: { none: { personId: person.id, status: "responded" } },
},
],
},
select: {
...select,
attributeFilters: {
select: {
id: true,
condition: true,
value: true,
attributeClass: {
select: {
id: true,
name: true,
},
},
},
},
displays: {
where: {
personId: person.id,
},
orderBy: {
createdAt: "desc",
},
take: 1,
select: {
createdAt: true,
},
},
},
});
// get last display for this person
const lastDisplayPerson = await prisma.display.findFirst({
where: {
personId: person.id,
},
orderBy: {
createdAt: "desc",
},
select: {
createdAt: true,
},
});
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = potentialSurveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const personAttributeValue = person.attributes[attributeFilter.attributeClass.name];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
// filter surveys that meet the recontactDays criteria
const surveys: TSurvey[] = potentialSurveysWithAttributes
.filter((survey) => {
if (!lastDisplayPerson) {
// no display yet - always display
return true;
} else if (survey.recontactDays !== null) {
// if recontactDays is set on survey, use that
const lastDisplaySurvey = survey.displays[0];
if (!lastDisplaySurvey) {
// no display yet - always display
return true;
}
const lastDisplayDate = new Date(lastDisplaySurvey.createdAt);
const currentDate = new Date();
const diffTime = Math.abs(currentDate.getTime() - lastDisplayDate.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= survey.recontactDays;
} else if (product.recontactDays !== null) {
// if recontactDays is not set in survey, use product recontactDays
const lastDisplayDate = new Date(lastDisplayPerson.createdAt);
const currentDate = new Date();
const diffTime = Math.abs(currentDate.getTime() - lastDisplayDate.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= product.recontactDays;
} else {
// if recontactDays is not set in survey or product, always display
return true;
}
})
.map((survey) => ({
...survey,
triggers: survey.triggers.map((trigger) => trigger.eventClass),
attributeFilters: survey.attributeFilters.map((af) => ({
...af,
attributeClassId: af.attributeClass.id,
attributeClass: undefined,
})),
}));
return surveys;
};

View File

@@ -0,0 +1,139 @@
import { getSurveys } from "@/app/api/v1/js/surveys";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { createPerson, getPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { createSession, extendSession, getSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsSyncInput } from "@formbricks/types/v1/js";
import { TPerson } from "@formbricks/types/v1/people";
import { TSession } from "@formbricks/types/v1/sessions";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request): Promise<NextResponse> {
try {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsSyncInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, personId, sessionId } = inputValidation.data;
if (!personId) {
// create a new person
const person = await createPerson(environmentId);
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
createSession(person.id),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
}
if (!sessionId) {
let person: TPerson | null;
// check if person exists
person = await getPerson(personId);
if (!person) {
// create a new person
person = await createPerson(environmentId);
}
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
createSession(person.id),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
}
// person & session exists
// check if session exists
let person: TPerson | null;
let session: TSession | null;
session = await getSession(sessionId);
if (!session) {
// check if person exits
person = await getPerson(personId);
if (!person) {
// create a new person
person = await createPerson(environmentId);
}
// create a new session
session = await createSession(person.id);
} else {
// session exists
// check if person exists (should always exist, but just in case)
person = await getPerson(personId);
if (!person) {
// create a new person & session
person = await createPerson(environmentId);
session = await createSession(person.id);
} else {
// check if session is expired
if (session.expiresAt < new Date()) {
// create a new session
session = await createSession(person.id);
} else {
// extend session
session = await extendSession(sessionId);
}
}
}
// get/create rest of the state
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -1,10 +1,11 @@
import { responses } from "@/lib/api/response";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getEnvironmentResponses } from "@formbricks/lib/services/response";
import { getEnvironmentResponses, getSurveyResponses } from "@formbricks/lib/services/response";
import { headers } from "next/headers";
import { DatabaseError } from "@formbricks/errors";
import { getSurvey } from "@formbricks/lib/services/survey";
export async function GET() {
export async function GET(request: Request) {
const apiKey = headers().get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
@@ -19,10 +20,27 @@ export async function GET() {
return responses.notAuthenticatedResponse();
}
// get webhooks from database
// get surveyId from searchParams
const { searchParams } = new URL(request.url);
const surveyId = searchParams.get("surveyId");
// get responses from database
try {
const environmentResponses = await getEnvironmentResponses(apiKeyData.environmentId);
return responses.successResponse(environmentResponses);
if (!surveyId) {
const environmentResponses = await getEnvironmentResponses(apiKeyData.environmentId);
return responses.successResponse(environmentResponses);
}
// check if survey is part of environment
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse(surveyId, "survey");
}
if (survey.environmentId !== apiKeyData.environmentId) {
return responses.notFoundResponse(surveyId, "survey");
}
// get responses for survey
const surveyResponses = await getSurveyResponses(surveyId);
return responses.successResponse(surveyResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);

View File

@@ -0,0 +1,32 @@
import { responses } from "@/lib/api/response";
import { DatabaseError } from "@formbricks/errors";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getSurveys } from "@formbricks/lib/services/survey";
import { headers } from "next/headers";
export async function GET() {
const apiKey = headers().get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
let apiKeyData;
try {
apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
} catch (error) {
return responses.notAuthenticatedResponse();
}
// get surveys from database
try {
const surveys = await getSurveys(apiKeyData.environmentId);
return responses.successResponse(surveys);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
}

View File

@@ -1,4 +1,4 @@
import { PasswordResetForm } from "../../../components/auth/PasswordResetForm";
import { PasswordResetForm } from "../../../components/auth/RequestPasswordResetForm";
import FormWrapper from "@/components/auth/FormWrapper";
const ForgotPasswordPage: React.FC = () => {

View File

@@ -5,7 +5,7 @@ export default function ResetPasswordSuccessPage() {
return (
<FormWrapper>
<div>
<h1 className="leading-2 mb-4 text-center font-bold">Password successfully reset</h1>
<h1 className="leading-2 mb-4 text-center font-bold">Password successfully reset.</h1>
<p className="text-center">You can now log in with your new password</p>
<div className="mt-3 text-center">
<BackToLoginButton />

View File

@@ -29,6 +29,7 @@ import { useTeam } from "@/lib/teams/teams";
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
import {
CustomersIcon,
DashboardIcon,
ErrorComponent,
FilterIcon,
FormIcon,
@@ -120,12 +121,12 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
icon: FilterIcon,
current: pathname?.includes("/events") || pathname?.includes("/attributes"),
},
/* {
{
name: "Integrations",
href: `/environments/${environmentId}/integrations/installation`,
href: `/environments/${environmentId}/integrations`,
icon: DashboardIcon,
current: pathname?.includes("/integrations"),
}, */
},
{
name: "Settings",
href: `/environments/${environmentId}/settings/profile`,

View File

@@ -2,7 +2,7 @@ import { Button } from "@formbricks/ui";
export default function DocsSidebar() {
return (
<div className="w-fit rounded-lg border border-slate-300 bg-slate-200 p-8 pr-16">
<div className="w-20 min-w-max rounded-lg border border-slate-200 bg-slate-100 p-8">
<p className="font-bold text-slate-700">Documentation</p>
<p className="text-xs text-slate-500">Get detailed instructions</p>
<Button className="my-2" href="https://formbricks.com/docs" target="_blank">

View File

@@ -0,0 +1,31 @@
import { BackIcon } from "@formbricks/ui";
import Link from "next/link";
interface IntegrationPageTitleProps {
title: string;
icon?: React.ReactNode;
environmentId: string;
}
const IntegrationPageTitle: React.FC<IntegrationPageTitleProps> = ({ title, icon, environmentId }) => {
return (
<div className="flex justify-between">
<div className="mb-8">
<Link className="inline-block" href={`/environments/${environmentId}/integrations/`}>
<BackIcon className="mb-2 h-6 w-6" />
</Link>
<div className="my-4 flex items-baseline">
{icon && <div className="h-6 w-6">{icon}</div>}
<h1 className="ml-3 text-2xl font-bold text-slate-800">{title}</h1>
</div>
</div>
{/* <div className="flex items-center space-x-2">
<Switch id="integration-enabled" />
<Label htmlFor="integration-enabled">Enabled</Label>
</div> */}
</div>
);
};
export default IntegrationPageTitle;

View File

@@ -1,11 +0,0 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import IntegrationsTabs from "@/components/integrations/IntegrationsTabs";
export default function SettingsLayout({ children, params }) {
return (
<>
<IntegrationsTabs activeId="alerts" environmentId={params.environmentId} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View File

@@ -1,35 +0,0 @@
import SlackLogo from "@/images/slacklogo.png";
import { Card, EmailIcon, PageTitle } from "@formbricks/ui";
import Image from "next/image";
import Link from "next/link";
export default function EventsAttributesPage({ params }) {
return (
<div>
<PageTitle>Team Alerts</PageTitle>
<div className="grid grid-cols-3 gap-6">
{/* <Card
href={`/environments/${params.environmentId}/integrations/alerts/email`}
title="Email Notifications"
description="Keep your team in the loop with email notifications."
icon={<EmailIcon />}
/> */}
<Card
href={`/environments/${params.environmentId}/integrations/alerts/slack`}
title="Slack"
description="Surface insights in dedicated Slack channels."
icon={<Image src={SlackLogo} alt="Slack Logo" />}
/>
<Link
href={`/environments/${params.environmentId}/settings/notifications`}
className="hover:ring-brand-dark cursor-pointer rounded-lg bg-slate-100 p-8 text-left shadow-sm transition-all duration-150 ease-in-out hover:ring-1">
<div className="mb-6 h-8 w-8">
<EmailIcon />
</div>
<h3 className="text-lg font-bold text-slate-800">Looking for email?</h3>
<p className="text-xs text-slate-500">Change your notification settings.</p>
</Link>
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Button, Checkbox, Input, Label } from "@formbricks/ui";
type AddEmailAlertModalProps = {
open: boolean;
setOpen: (v: boolean) => void;
};
const AddEmailAlertModal: React.FC<AddEmailAlertModalProps> = ({ open, setOpen }) => {
const surveys = [
{ label: "Survey 1", id: "1" },
{ label: "Survey 2", id: "2" },
{ label: "Survey 3", id: "3" },
];
const onTest = () => {
throw Error("not implemented");
};
const onSave = () => {
throw Error("not implemented");
};
return (
<>
<Modal open={open} setOpen={setOpen} title="Add Slack Alert">
<form className="space-y-6">
<div>
<Label>Alert name</Label>
<Input type="text" placeholder="e.g. Product Team Info" />
</div>
<div>
<Label>End Point URL</Label>
<Input type="URL" placeholder="https://hooks.slack.com/service/ABC123/ASD213ADS" />
</div>
<div>
<Label className="block">Trigger Event</Label>
<Label className="font-normal text-slate-400">
Send message every time one of the surveys receives a response:
</Label>
<div className="mt-2 rounded bg-slate-50 p-6 ">
<div className="flex items-center space-x-2">
<Checkbox id="all" />
<label
htmlFor="all"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
All surveys
</label>
</div>
<hr className="my-2" />
{surveys.map((survey) => (
<div key={survey.id} className="flex items-center space-x-2">
<Checkbox className="my-1" id={survey.id} />
<label
htmlFor="all"
className="text-sm font-medium leading-none text-slate-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{survey.label}
</label>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-2">
<Button variant="minimal" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="secondary" onClick={onTest}>
Send Test
</Button>
<Button variant="darkCTA" onClick={onSave}>
Save
</Button>
</div>
</form>
</Modal>
</>
);
};
export default AddEmailAlertModal;

View File

@@ -1,56 +0,0 @@
"use client";
import { AddAlertButton } from "@/components/integrations/AddAlertButton";
import AlertCard from "@/components/integrations/AlertCard";
import IntegrationPageTitle from "@/components/integrations/IntegrationsPageTitle";
import SlackLogo from "@/images/slacklogo.png";
import Image from "next/image";
import AddSlackAlertModal from "./AddSlackAlertModal";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { useState } from "react";
export default function SlackAlertPage({ params }) {
const exampleAlert = {
href: "/",
title: "Example Alert",
description: "This is an example alert",
};
const [isAlertModalOpen, setAlertModalOpen] = useState(false);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleAddAlertClick = async () => {
setAlertModalOpen(true);
};
const handleDeleteAlertClick = async () => {
setDeleteDialogOpen(true);
};
const deleteEmailAlert = async () => {
setDeleteDialogOpen(false);
};
return (
<div>
<IntegrationPageTitle environmentId={params.environmentId} title="Slack Alerts" goBackTo="alerts" />
<div className="grid grid-cols-3 gap-6">
<AlertCard
onDelete={handleDeleteAlertClick}
onEdit={handleAddAlertClick}
title={exampleAlert.title}
description={exampleAlert.description}
icon={<Image src={SlackLogo} alt="Slack Logo" />}
/>
<AddAlertButton channel="Slack" onClick={() => handleAddAlertClick()} />
</div>
<AddSlackAlertModal open={isAlertModalOpen} setOpen={setAlertModalOpen} />
<DeleteDialog
deleteWhat="Email Alert"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={deleteEmailAlert}
/>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import IntegrationsTabs from "@/components/integrations/IntegrationsTabs";
import ContentWrapper from "@/components/shared/ContentWrapper";
export default function EventsAttributesPage({ params }) {
return (
<div className="">
<IntegrationsTabs activeId="data" environmentId={params.environmentId} />
<ContentWrapper>Data</ContentWrapper>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import DocsSidebar from "@/components/integrations/DocsSidebar";
import IntegrationPageTitle from "@/components/integrations/IntegrationsPageTitle";
import JSLogo from "@/images/jslogo.png";
import { Input } from "@formbricks/ui";
import Image from "next/image";
export default function JavaScriptPage({ params }) {
/* useEffect(() => {
Prism.highlightAll();
}, []); */
return (
<div>
<IntegrationPageTitle
environmentId={params.environmentId}
title="JavaScript Snippet"
icon={<Image src={JSLogo} alt="JavaScript Logo" />}
goBackTo="installation"
/>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
<div>
<h3 className="text-xl font-bold text-slate-800">Quick Start</h3>
<ol className="my-4 ml-2 list-decimal text-slate-900">
<li>Copy the Javascript snippet below into the HEAD of your HTML file.</li>
<li>Set up a button with the onClick handler below to let your users open the widget.</li>
<li>PLACEHOLDER</li>
</ol>
<div className="flex">
<div className="mr-6">
<p className="font-bold text-slate-600">Production ID</p>
<Input type="text" className="rounded border border-slate-200 bg-slate-100" />
</div>
<div>
<p className="font-bold text-slate-600">Development ID</p>
<Input type="text" className="rounded border border-slate-200 bg-slate-100" />
</div>
</div>
</div>
<div>
<h3 className="mt-12 text-xl font-bold text-slate-800">JavaScript Snippet</h3>
<div className="col-span-3 rounded-md bg-black p-4 text-sm font-light text-slate-200">
<pre>
<code className="language-html whitespace-pre-wrap">
{`<!--HTML header script -->
<script src="https://cdn.jsdelivr.net/npm/@formbricks/feedback@0.2" defer></script>
<script>
window.formbricks = {
...window.formbricks,
config: {
hqUrl: "https://app.formbricks.com",
formId: "YOUR FEEDBACK BOX ID HERE", // copy from Formbricks dashboard
contact: {
name: "Matti",
position: "Co-Founder",
imgUrl: "https://avatars.githubusercontent.com/u/675065?s=128&v=4",
},
},
}
</script>
`}
</code>
</pre>
</div>
</div>
</div>
<div className="col-span-1">
<DocsSidebar />
</div>
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import IntegrationsTabs from "@/components/integrations/IntegrationsTabs";
export default function InstallationsLayout({ children, params }) {
return (
<>
<IntegrationsTabs activeId="installation" environmentId={params.environmentId} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View File

@@ -1,75 +0,0 @@
import DocsSidebar from "@/components/integrations/DocsSidebar";
import IntegrationPageTitle from "@/components/integrations/IntegrationsPageTitle";
import NPMLogo from "@/images/npmlogo.png";
import { Input } from "@formbricks/ui";
import Image from "next/image";
export default function NPMPage({ params }) {
/* useEffect(() => {
Prism.highlightAll();
}, []); */
return (
<div>
<IntegrationPageTitle
environmentId={params.environmentId}
title="NPM Install"
icon={<Image src={NPMLogo} alt="NPM Logo" />}
goBackTo="installation"
/>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
<div>
<h3 className="text-xl font-bold text-slate-800">Quick Start</h3>
<ol className="my-4 ml-2 list-decimal text-slate-900">
<li>Copy the Javascript snippet below into the HEAD of your HTML file.</li>
<li>Set up a button with the onClick handler below to let your users open the widget.</li>
<li>PLACEHOLDER</li>
</ol>
<div className="flex">
<div className="mr-6">
<p className="font-bold text-slate-600">Production ID</p>
<Input type="text" className="rounded border border-slate-200 bg-slate-100" />
</div>
<div>
<p className="font-bold text-slate-600">Development ID</p>
<Input type="text" className="rounded border border-slate-200 bg-slate-100" />
</div>
</div>
</div>
<div>
<h3 className="mt-12 text-xl font-bold text-slate-800">JavaScript Snippet</h3>
<div className="col-span-3 rounded-md bg-black p-4 text-sm font-light text-slate-200">
<pre>
<code className="language-html whitespace-pre-wrap">
{`<!--HTML header script -->
<script src="https://cdn.jsdelivr.net/npm/@formbricks/feedback@0.2" defer></script>
<script>
window.formbricks = {
...window.formbricks,
config: {
hqUrl: "https://app.formbricks.com",
formId: "YOUR FEEDBACK BOX ID HERE", // copy from Formbricks dashboard
contact: {
name: "Matti",
position: "Co-Founder",
imgUrl: "https://avatars.githubusercontent.com/u/675065?s=128&v=4",
},
},
}
</script>
`}
</code>
</pre>
</div>
</div>
</div>
<div className="col-span-1">
<DocsSidebar />
</div>
</div>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import JSLogo from "@/images/jslogo.png";
import NPMLogo from "@/images/npmlogo.png";
import { Card, PageTitle } from "@formbricks/ui";
import Image from "next/image";
export default function InstallationsPage({ params }) {
return (
<div>
<PageTitle>Installation</PageTitle>
<div className="grid grid-cols-3 gap-6">
<Card
href={`/environments/${params.environmentId}/integrations/installation/javascript`}
title="JavaScript"
description="Copy the Formbricks snippet into your HTML <head>."
icon={<Image src={JSLogo} alt="JavaScript Logo" />}
/>
<Card
href={`/environments/${params.environmentId}/integrations/installation/npm`}
title="NPM"
description="Use NPM or yarn to install the Formbricks SDK."
icon={<Image src={NPMLogo} alt="NPM Logo" />}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
export default function IntegrationsLayout({ children }) {
return (
<>
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View File

@@ -1,3 +1,28 @@
import { Card } from "@formbricks/ui";
import Image from "next/image";
import JsLogo from "@/images/jslogo.png";
import ZapierLogo from "@/images/zapier-small.png";
export default function IntegrationsPage() {
return <div>Integrations</div>;
return (
<div>
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
<p className="mb-6 text-slate-500">Connect Formbricks with your favorite tools.</p>
<div className="grid grid-cols-3 gap-6">
<Card
docsHref="https://formbricks.com/docs/getting-started/nextjs-app"
label="Javascript Widget"
description="Integrate Formbricks into your Webapp"
icon={<Image src={JsLogo} alt="Javascript Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/zapier"
connectHref="https://zapier.com/apps/formbricks/integrations"
label="Zapier"
description="Integrate Formbricks with 5000+ apps via Zapier"
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
/>
</div>
</div>
);
}

View File

@@ -2,11 +2,12 @@ export const revalidate = 0;
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { truncateMiddle } from "@/lib/utils";
import { TransformPersonOutput, getPeople } from "@formbricks/lib/services/person";
import { getPeople } from "@formbricks/lib/services/person";
import { TPerson } from "@formbricks/types/v1/people";
import { PersonAvatar } from "@formbricks/ui";
import Link from "next/link";
const getAttributeValue = (person: TransformPersonOutput, attributeName: string) =>
const getAttributeValue = (person: TPerson, attributeName: string) =>
person.attributes[attributeName]?.toString();
export default async function PeoplePage({ params }) {

View File

@@ -22,10 +22,8 @@ import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
ProfileAvatar,
Tooltip,
@@ -105,8 +103,6 @@ function RoleElement({
</DropdownMenuTrigger>
{!disableRole && (
<DropdownMenuContent>
<DropdownMenuLabel className="text-center">Select Role</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={capitalizeFirstLetter(memberRole)}
onValueChange={(value) => handleMemberRoleUpdate(value.toLowerCase())}>
@@ -156,10 +152,16 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
}
const handleDeleteMember = async () => {
let result = false;
if (activeMember.accepted) {
await removeMember(team.teamId, activeMember.userId);
result = await removeMember(team.teamId, activeMember.userId);
} else {
await deleteInvite(team.teamId, activeMember.inviteId);
result = await deleteInvite(team.teamId, activeMember.inviteId);
}
if (result) {
toast.success("Member removed successfully");
} else {
toast.error("Something went wrong");
}
setDeleteMemberModalOpen(false);
mutateTeam();
@@ -177,10 +179,21 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
const handleAddMember = async (data) => {
// TODO: handle http 409 user is already part of the team
await addMember(team.teamId, data);
const add = await addMember(team.teamId, data);
if (add) {
toast.success("Member invited successfully");
} else {
toast.error("Something went wrong");
}
mutateTeam();
};
const isExpired = (invite) => {
const now = new Date();
const expiresAt = new Date(invite.expiresAt);
return now > expiresAt;
};
return (
<>
<div className="mb-6 text-right">
@@ -237,12 +250,18 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
/>
</div>
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
{!member.accepted && <Badge className="mr-2" type="warning" text="Pending" size="tiny" />}
{member.role !== "owner" && (
{!member.accepted &&
(isExpired(member) ? (
<Badge className="mr-2" type="gray" text="Expired" size="tiny" />
) : (
<Badge className="mr-2" type="warning" text="Pending" size="tiny" />
))}
{isAdminOrOwner && member.role !== "owner" && member.userId !== profile?.id && (
<button onClick={(e) => handleOpenDeleteMemberModal(e, member)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
)}
{!member.accepted && (
<TooltipProvider delayDuration={50}>
<Tooltip>

View File

@@ -36,7 +36,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${window.location.protocol}//${window.location.host}",
logLevel: "debug", // remove when in production
debug: true, // remove when in production
});
}`}</CodeBlock>

View File

@@ -1,14 +1,15 @@
"use client";
import FormbricksSignature from "@/components/preview/FormbricksSignature";
import Modal from "@/components/preview/Modal";
import Progress from "@/components/preview/Progress";
import QuestionConditional from "@/components/preview/QuestionConditional";
import ThankYouCard from "@/components/preview/ThankYouCard";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import type { Logic, Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { useEffect, useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/v1/product";
import type { TEnvironment } from "@formbricks/types/v1/environment";
interface PreviewSurveyProps {
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
@@ -19,6 +20,8 @@ interface PreviewSurveyProps {
thankYouCard: Survey["thankYouCard"];
autoClose: Survey["autoClose"];
previewType?: "modal" | "fullwidth" | "email";
product: TProduct;
environment: TEnvironment;
}
export default function PreviewSurvey({
@@ -26,15 +29,13 @@ export default function PreviewSurvey({
activeQuestionId,
questions,
brandColor,
environmentId,
surveyType,
thankYouCard,
autoClose,
previewType,
product,
environment,
}: PreviewSurveyProps) {
const { environment } = useEnvironment(environmentId);
const { product } = useProduct(environmentId);
const [isModalOpen, setIsModalOpen] = useState(true);
const [progress, setProgress] = useState(0); // [0, 1]
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
@@ -257,7 +258,7 @@ export default function PreviewSurvey({
}
return (
<div className="my-4 flex h-full w-5/6 flex-col rounded-lg border border-slate-300 bg-slate-200 ">
<div className="flex h-full w-5/6 flex-1 flex-col rounded-lg border border-slate-300 bg-slate-200 ">
<div className="flex h-8 items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
@@ -307,8 +308,8 @@ export default function PreviewSurvey({
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
<div className="flex flex-grow flex-col">
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="flex flex-grow flex-col overflow-y-auto">
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md">
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard

View File

@@ -32,12 +32,17 @@ import toast from "react-hot-toast";
import TemplateList from "./templates/TemplateList";
import { useEffect } from "react";
import { changeEnvironment } from "@/lib/environments/changeEnvironments";
import { TProduct } from "@formbricks/types/v1/product";
export default function SurveysList({ environmentId }) {
interface SurveyListProps {
environmentId: string;
product: TProduct;
}
export default function SurveysList({ environmentId, product }: SurveyListProps) {
const router = useRouter();
const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId);
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
@@ -133,6 +138,8 @@ export default function SurveysList({ environmentId }) {
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}

View File

@@ -14,7 +14,7 @@ export default async function ResponsesLimitReachedBanner({
environmentId,
session,
}: ResponsesLimitReachedBannerProps) {
const { responsesCount, limitReached } = await getAnalysisData(session, surveyId);
const { responsesCount, limitReached } = await getAnalysisData(session, surveyId, environmentId);
return (
<>
{limitReached && (

View File

@@ -82,6 +82,7 @@ export default function LogicEditor({
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
};
const logicConditions: LogicConditions = {
submitted: {
label: "is submitted",
@@ -201,10 +202,9 @@ export default function LogicEditor({
};
const deleteLogic = (logicIdx: number) => {
const newLogic = !question.logic
? []
: (question.logic as Logic[]).filter((_: any, idx: number) => idx !== logicIdx);
updateQuestion(questionIdx, { logic: newLogic });
const updatedLogic = !question.logic ? [] : JSON.parse(JSON.stringify(question.logic));
updatedLogic.splice(logicIdx, 1);
updateQuestion(questionIdx, { logic: updatedLogic });
};
const truncate = (str: string, n: number) =>
@@ -225,9 +225,7 @@ export default function LogicEditor({
<BsArrowReturnRight className="h-4 w-4" />
<p className="text-slate-700">If this answer</p>
<Select
defaultValue={logic.condition}
onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
<SelectTrigger className="min-w-fit flex-1">
<SelectValue placeholder="Select condition" />
</SelectTrigger>
@@ -246,9 +244,7 @@ export default function LogicEditor({
{logic.condition && logicConditions[logic.condition].values != null && (
<div className="flex-1 basis-1/5">
{!logicConditions[logic.condition].multiSelect ? (
<Select
defaultValue={logic.value}
onValueChange={(e) => updateLogic(logicIdx, { value: e })}>
<Select value={logic.value} onValueChange={(e) => updateLogic(logicIdx, { value: e })}>
<SelectTrigger>
<SelectValue placeholder="Select match type" />
</SelectTrigger>
@@ -294,7 +290,7 @@ export default function LogicEditor({
<p className="text-slate-700">skip to</p>
<Select
defaultValue={logic.destination}
value={logic.destination}
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
<SelectTrigger className="w-fit overflow-hidden ">
<SelectValue placeholder="Select question" />

View File

@@ -1,6 +1,15 @@
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@formbricks/ui";
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
@@ -24,6 +33,24 @@ export default function MultipleChoiceMultiForm({
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const questionRef = useRef<HTMLInputElement>(null);
const shuffleOptionsTypes = {
none: {
id: "none",
label: "None (Keep choices in current order)",
show: true,
},
all: {
id: "all",
label: "All (Randomize all choices)",
show: question.choices.filter((c) => c.id === "other").length === 0,
},
exceptLast: {
id: "exceptLast",
label: "Except Last (Keep last choice and randomize other choices)",
show: true,
},
};
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
@@ -59,7 +86,12 @@ export default function MultipleChoiceMultiForm({
if (question.choices.filter((c) => c.id === "other").length === 0) {
const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other");
newChoices.push({ id: "other", label: "Other" });
updateQuestion(questionIdx, { choices: newChoices });
updateQuestion(questionIdx, {
choices: newChoices,
...(question.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id,
}),
});
}
};
@@ -167,14 +199,40 @@ export default function MultipleChoiceMultiForm({
)}
</div>
))}
<div className="flex items-center space-x-2">
<div className="flex items-center justify-between space-x-2">
{question.choices.filter((c) => c.id === "other").length === 0 && (
<>
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot;
</Button>
</>
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot;
</Button>
)}
<div className="flex flex-1 items-center justify-end gap-2">
<p className="text-sm text-slate-700">Ordering</p>
<Select
defaultValue={question.shuffleOption}
value={question.shuffleOption}
onValueChange={(e) => {
updateQuestion(questionIdx, { shuffleOption: e });
}}>
<SelectTrigger className="w-fit overflow-hidden ">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>
{Object.values(shuffleOptionsTypes).map(
(shuffleOptionsType) =>
shuffleOptionsType.show && (
<SelectItem
key={shuffleOptionsType.id}
value={shuffleOptionsType.id}
title={shuffleOptionsType.label}>
{shuffleOptionsType.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,15 @@
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@formbricks/ui";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
@@ -24,6 +33,24 @@ export default function MultipleChoiceSingleForm({
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const questionRef = useRef<HTMLInputElement>(null);
const shuffleOptionsTypes = {
none: {
id: "none",
label: "None (Keep choices in current order)",
show: true,
},
all: {
id: "all",
label: "All (Randomize all choices)",
show: question.choices.filter((c) => c.id === "other").length === 0,
},
exceptLast: {
id: "exceptLast",
label: "Except Last (Keep last choice and randomize other choices)",
show: true,
},
};
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
@@ -59,7 +86,12 @@ export default function MultipleChoiceSingleForm({
if (question.choices.filter((c) => c.id === "other").length === 0) {
const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other");
newChoices.push({ id: "other", label: "Other" });
updateQuestion(questionIdx, { choices: newChoices });
updateQuestion(questionIdx, {
choices: newChoices,
...(question.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id,
}),
});
}
};
@@ -167,12 +199,40 @@ export default function MultipleChoiceSingleForm({
)}
</div>
))}
<div className="flex items-center space-x-2">
<div className="flex items-center justify-between space-x-2">
{question.choices.filter((c) => c.id === "other").length === 0 && (
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot;
</Button>
)}
<div className="flex flex-1 items-center justify-end gap-2">
<p className="text-sm text-slate-700">Ordering</p>
<Select
defaultValue={question.shuffleOption}
value={question.shuffleOption}
onValueChange={(e) => {
updateQuestion(questionIdx, { shuffleOption: e });
}}>
<SelectTrigger className="w-fit overflow-hidden ">
<SelectValue placeholder="Select ordering" />
</SelectTrigger>
<SelectContent>
{Object.values(shuffleOptionsTypes).map(
(shuffleOptionsType) =>
shuffleOptionsType.show && (
<SelectItem
key={shuffleOptionsType.id}
value={shuffleOptionsType.id}
title={shuffleOptionsType.label}>
{shuffleOptionsType.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>

View File

@@ -1,8 +1,9 @@
"use client";
import AdvancedSettings from "@/app/environments/[environmentId]/surveys/[surveyId]/edit/AdvancedSettings";
import { getQuestionTypeName } from "@/lib/questions";
import { cn } from "@formbricks/lib/cn";
import { QuestionType, type Question } from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import {
@@ -20,18 +21,16 @@ import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { Draggable } from "react-beautiful-dnd";
import CTAQuestionForm from "./CTAQuestionForm";
import ConsentQuestionForm from "./ConsentQuestionForm";
import MultipleChoiceMultiForm from "./MultipleChoiceMultiForm";
import MultipleChoiceSingleForm from "./MultipleChoiceSingleForm";
import NPSQuestionForm from "./NPSQuestionForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionMenu";
import RatingQuestionForm from "./RatingQuestionForm";
import ConsentQuestionForm from "./ConsentQuestionForm";
import AdvancedSettings from "@/app/environments/[environmentId]/surveys/[surveyId]/edit/AdvancedSettings";
interface QuestionCardProps {
localSurvey: Survey;
question: Question;
questionIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
@@ -44,7 +43,6 @@ interface QuestionCardProps {
export default function QuestionCard({
localSurvey,
question,
questionIdx,
moveQuestion,
updateQuestion,
@@ -54,6 +52,7 @@ export default function QuestionCard({
setActiveQuestionId,
lastQuestion,
}: QuestionCardProps) {
const question = localSurvey.questions[questionIdx];
const open = activeQuestionId === question.id;
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
return (

View File

@@ -32,35 +32,45 @@ export default function QuestionsView({
}, {});
}, []);
const handleQuestionLogicChange = (survey: Survey, compareId: string, updatedId: string): Survey => {
survey.questions.forEach((question) => {
if (!question.logic) return;
question.logic.forEach((rule) => {
if (rule.destination === compareId) {
rule.destination = updatedId;
}
});
});
return survey;
};
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
const updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions[questionIdx] = {
...updatedSurvey.questions[questionIdx],
...updatedAttributes,
};
setLocalSurvey(updatedSurvey);
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);
// relink the question to internal Id
internalQuestionIdMap[updatedAttributes.id] =
internalQuestionIdMap[localSurvey.questions[questionIdx].id];
delete internalQuestionIdMap[localSurvey.questions[questionIdx].id];
setActiveQuestionId(updatedAttributes.id);
}
updatedSurvey.questions[questionIdx] = {
...updatedSurvey.questions[questionIdx],
...updatedAttributes,
};
setLocalSurvey(updatedSurvey);
};
const deleteQuestion = (questionIdx: number) => {
const questionId = localSurvey.questions[questionIdx].id;
const updatedSurvey: Survey = JSON.parse(JSON.stringify(localSurvey));
let updatedSurvey: Survey = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey.questions.forEach((question) => {
if (!question.logic) return;
question.logic.forEach((rule) => {
if (rule.destination === questionId) {
rule.destination = "end";
}
});
});
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
setLocalSurvey(updatedSurvey);
delete internalQuestionIdMap[questionId];
@@ -100,7 +110,7 @@ export default function QuestionsView({
const addQuestion = (question: any) => {
const updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.push(question);
updatedSurvey.questions.push({ ...question, isDraft: true });
setLocalSurvey(updatedSurvey);
setActiveQuestionId(question.id);
internalQuestionIdMap[question.id] = createId();
@@ -141,7 +151,6 @@ export default function QuestionsView({
<QuestionCard
key={internalQuestionIdMap[question.id]}
localSurvey={localSurvey}
question={question}
questionIdx={questionIdx}
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}

View File

@@ -1,7 +1,7 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import { DatePicker, Input, Label, Switch } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
@@ -15,20 +15,38 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const [open, setOpen] = useState(false);
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
heading: "Survey Completed",
subheading: "This free & open-source survey has been closed",
});
const [closeOnDate, setCloseOnDate] = useState<Date>();
const handleRedirectCheckMark = () => {
setRedirectToggle((prev) => !prev);
if (redirectToggle && localSurvey.redirectUrl) {
setRedirectToggle(false);
setRedirectUrl(null);
setLocalSurvey({ ...localSurvey, redirectUrl: null });
}
};
const handleSurveyCloseOnDateToggle = () => {
if (surveyCloseOnDateToggle && localSurvey.closeOnDate) {
setSurveyCloseOnDateToggle(false);
setCloseOnDate(undefined);
setLocalSurvey({ ...localSurvey, closeOnDate: null });
return;
}
if (redirectToggle) {
setRedirectToggle(false);
if (surveyCloseOnDateToggle) {
setSurveyCloseOnDateToggle(false);
return;
}
setRedirectToggle(true);
setSurveyCloseOnDateToggle(true);
};
const handleRedirectUrlChange = (link: string) => {
@@ -36,12 +54,59 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setLocalSurvey({ ...localSurvey, redirectUrl: link });
};
const handleCloseSurveyMessageToggle = () => {
setSurveyClosedMessageToggle((prev) => !prev);
if (surveyClosedMessageToggle && localSurvey.surveyClosedMessage) {
setLocalSurvey({ ...localSurvey, surveyClosedMessage: null });
}
};
const handleCloseOnDateChange = (date: Date) => {
const equivalentDate = date?.getDate();
date?.setUTCHours(0, 0, 0, 0);
date?.setDate(equivalentDate);
setCloseOnDate(date);
setLocalSurvey({ ...localSurvey, closeOnDate: date ?? null });
};
const handleClosedSurveyMessageChange = ({
heading,
subheading,
}: {
heading?: string;
subheading?: string;
}) => {
const message = {
heading: heading ?? surveyClosedMessage.heading,
subheading: subheading ?? surveyClosedMessage.subheading,
};
setSurveyClosedMessage(message);
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
};
useEffect(() => {
if (localSurvey.redirectUrl) {
setRedirectUrl(localSurvey.redirectUrl);
setRedirectToggle(true);
}
if (!!localSurvey.surveyClosedMessage) {
setSurveyClosedMessage({
heading: localSurvey.surveyClosedMessage.heading ?? surveyClosedMessage.heading,
subheading: localSurvey.surveyClosedMessage.subheading ?? surveyClosedMessage.subheading,
});
setSurveyClosedMessageToggle(true);
}
if (localSurvey.closeOnDate) {
setCloseOnDate(localSurvey.closeOnDate);
setSurveyCloseOnDateToggle(true);
}
}, []);
const handleCheckMark = () => {
if (autoComplete) {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: null };
@@ -80,62 +145,159 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="p-3">
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch id="autoComplete" checked={autoComplete} onCheckedChange={handleCheckMark} />
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
Auto complete survey on response limit
</h3>
</div>
</Label>
</div>
{autoComplete && (
<div className="ml-2 flex items-center space-x-1 px-4 pb-4">
<label
htmlFor="autoCompleteResponses"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Automatically mark the survey as complete after
<Input
type="number"
min="1"
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={(e) => handleInputResponse(e)}
className="ml-2 mr-2 inline w-16 text-center text-sm"
/>
completed responses.
{/* Close Survey on Limit */}
<div className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch id="autoComplete" checked={autoComplete} onCheckedChange={handleCheckMark} />
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Close survey on response limit</h3>
<p className="text-xs font-normal text-slate-500">
Automatically close the survey after a certain number of responses.
</p>
</div>
</label>
</Label>
</div>
)}
{localSurvey.type === "link" && (
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
{autoComplete && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<label
htmlFor="autoCompleteResponses"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Automatically mark the survey as complete after
<Input
autoFocus
type="number"
min="1"
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={(e) => handleInputResponse(e)}
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
completed responses.
</p>
</div>
</Label>
</label>
</div>
<div className="mt-4">
{redirectToggle && (
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
)}
</div>
{/* Close Survey on Date */}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="surveyDeadline"
checked={surveyCloseOnDateToggle}
onCheckedChange={handleSurveyCloseOnDateToggle}
/>
<Label htmlFor="surveyDeadline" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Close survey on date</h3>
<p className="text-xs font-normal text-slate-500">
Automatically closes the survey at the beginning of the day (UTC).
</p>
{localSurvey.status === "completed" && (
<p className="text-xs font-normal text-slate-500">This form is already completed.</p>
)}
</div>
</Label>
</div>
{surveyCloseOnDateToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<p className="mr-2 text-sm font-semibold text-slate-700">
Automatically mark survey as complete on:
</p>
<DatePicker date={closeOnDate} handleDateChange={handleCloseOnDateChange} />
</div>
</div>
)}
</div>
{/* Redirect on completion */}
{localSurvey.type === "link" && (
<>
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="redirectUrl"
checked={redirectToggle}
onCheckedChange={handleRedirectCheckMark}
/>
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
{redirectToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<p className="mr-2 whitespace-nowrap text-sm font-semibold text-slate-700">
Redirect respondents here:
</p>
<Input
autoFocus
className="bg-white"
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
</div>
)}
</div>
</div>
{/* Adjust Survey Closed Message */}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="adjustSurveyClosedMessage"
checked={surveyClosedMessageToggle}
onCheckedChange={handleCloseSurveyMessageToggle}
/>
<Label htmlFor="adjustSurveyClosedMessage" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
{" "}
{"Adjust 'Survey Closed' message"}
</h3>
<p className="text-xs font-normal text-slate-500">
Change the message visitors see when the survey is closed.
</p>
</div>
</Label>
</div>
{surveyClosedMessageToggle && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<Label htmlFor="headline">Heading</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
/>
<Label htmlFor="headline">Subheading</Label>
<Input
className="mt-2 bg-white"
id="subheading"
name="subheading"
defaultValue={surveyClosedMessage.subheading}
onChange={(e) => handleClosedSurveyMessageChange({ subheading: e.target.value })}
/>
</div>
</div>
)}
</div>
</>
)}
</div>
</Collapsible.CollapsibleContent>

View File

@@ -11,6 +11,7 @@ import SettingsView from "./SettingsView";
import QuestionsAudienceTabs from "./QuestionsAudienceTabs";
import QuestionsView from "./QuestionsView";
import SurveyMenuBar from "./SurveyMenuBar";
import { useEnvironment } from "@/lib/environments/environments";
interface SurveyEditorProps {
environmentId: string;
@@ -24,6 +25,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
useEffect(() => {
if (survey) {
@@ -37,11 +39,11 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
}
}, [survey]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
if (isLoadingSurvey || isLoadingProduct || isLoadingEnvironment || !localSurvey) {
return <LoadingSpinner />;
}
if (isErrorSurvey || isErrorProduct) {
if (isErrorSurvey || isErrorProduct || isErrorEnvironment) {
return <ErrorComponent />;
}
@@ -74,13 +76,15 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-2 md:flex md:flex-col">
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
questions={localSurvey.questions}
brandColor={product.brandColor}
environmentId={environmentId}
product={product}
environment={environment}
surveyType={localSurvey.type}
thankYouCard={localSurvey.thankYouCard}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}

View File

@@ -71,7 +71,7 @@ export default function SurveyMenuBar({
setDeleteDialogOpen(false);
router.back();
} catch (error) {
console.log("An error occured deleting the survey");
console.log("An error occurred deleting the survey");
}
};
@@ -86,7 +86,15 @@ export default function SurveyMenuBar({
};
const saveSurveyAction = (shouldNavigateBack = false) => {
triggerSurveyMutate({ ...localSurvey })
// variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question
const strippedSurvey = {
...localSurvey,
questions: localSurvey.questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
}),
};
triggerSurveyMutate({ ...strippedSurvey })
.then(async (response) => {
if (!response?.ok) {
throw new Error(await response?.text());
@@ -108,6 +116,7 @@ export default function SurveyMenuBar({
toast.error(`Error saving changes`);
});
};
return (
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2 whitespace-nowrap">

View File

@@ -1,25 +1,41 @@
"use client";
import { Button, Input, Label } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/solid";
import { Input, Label } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
export default function UpdateQuestionId({ localSurvey, question, questionIdx, updateQuestion }) {
const [currentValue, setCurrentValue] = useState(question.id);
const [prevValue, setPrevValue] = useState(question.id);
const saveAction = () => {
// return early if the input value was not changed
if (currentValue === prevValue) {
return;
}
// check if id is unique
const questionIds = localSurvey.questions.map((q) => q.id);
if (questionIds.includes(currentValue)) {
alert("Question Identifier must be unique within the survey.");
toast.error("IDs have to be unique per survey.");
setCurrentValue(question.id);
return;
}
// check if id contains any spaces
if (currentValue.trim() === "" || currentValue.includes(" ")) {
toast.error("ID should not contain space.");
setCurrentValue(question.id);
return;
}
updateQuestion(questionIdx, { id: currentValue });
toast.success("Question ID updated.");
setPrevValue(currentValue); // after successful update, set current value as previous value
};
const isInputInvalid = currentValue.trim() === "" || currentValue.includes(" ");
return (
<div>
<Label htmlFor="questionId">Question ID</Label>
@@ -29,17 +45,10 @@ export default function UpdateQuestionId({ localSurvey, question, questionIdx, u
name="questionId"
value={currentValue}
onChange={(e) => setCurrentValue(e.target.value)}
disabled={localSurvey.status !== "draft"}
onBlur={saveAction}
disabled={!(localSurvey.status === "draft" || question.isDraft)}
className={isInputInvalid ? "border-red-300 focus:border-red-300" : ""}
/>
{localSurvey.status === "draft" && (
<Button
variant="darkCTA"
className="ml-2 bg-slate-600 text-white hover:bg-slate-700 disabled:bg-slate-400"
onClick={saveAction}
disabled={currentValue === question.id}>
<CheckIcon className="h-4 w-4" />
</Button>
)}
</div>
</div>
);

View File

@@ -172,7 +172,11 @@ export default function ResponseTimeline({
return (
<div className="space-y-4">
{responses.length === 0 ? (
<EmptySpaceFiller type="response" environmentId={environmentId} />
<EmptySpaceFiller
type="response"
environmentId={environmentId}
noWidgetRequired={survey.type === "link"}
/>
) : (
<div>
<Button variant="darkCTA" onClick={() => downloadResponses()} loading={isDownloadCSVLoading}>

View File

@@ -9,14 +9,11 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
export default async function ResponsesPage({ params }) {
const environmentId = params.environmentId;
console.log(environmentId);
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const { responses, survey } = await getAnalysisData(session, params.surveyId);
const { responses, survey } = await getAnalysisData(session, params.surveyId, params.environmentId);
return (
<>
<SurveyResultsTabs

View File

@@ -16,10 +16,11 @@ interface ChoiceResult {
export default function CTASummary({ questionSummary }: CTASummaryProps) {
const ctr: ChoiceResult = useMemo(() => {
const clickedAbs = questionSummary.responses.filter((response) => response.value === "clicked").length;
const count = questionSummary.responses.length;
if (count === 0) return { count: 0, percentage: 0 };
return {
count: questionSummary.responses.length,
percentage: clickedAbs / questionSummary.responses.length,
count: count,
percentage: clickedAbs / count,
};
}, [questionSummary]);

View File

@@ -2,6 +2,7 @@ import { ConsentQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface ConsentSummaryProps {
questionSummary: QuestionSummary<ConsentQuestion>;
@@ -16,15 +17,20 @@ interface ChoiceResult {
}
export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) {
const total = questionSummary.responses.length;
const clickedAbs = questionSummary.responses.filter((response) => response.value !== "dismissed").length;
const ctr: ChoiceResult = {
count: total,
acceptedCount: clickedAbs,
acceptedPercentage: clickedAbs / total,
dismissedCount: total - clickedAbs,
dismissedPercentage: 1 - clickedAbs / total,
};
const ctr: ChoiceResult = useMemo(() => {
const total = questionSummary.responses.length;
const clickedAbs = questionSummary.responses.filter((response) => response.value !== "dismissed").length;
if (total === 0) {
return { count: 0, acceptedCount: 0, acceptedPercentage: 0, dismissedCount: 0, dismissedPercentage: 0 };
}
return {
count: total,
acceptedCount: clickedAbs,
acceptedPercentage: clickedAbs / total,
dismissedCount: total - clickedAbs,
dismissedPercentage: 1 - clickedAbs / total,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">

View File

@@ -27,7 +27,7 @@ interface SummaryListProps {
}
export default async function SummaryList({ environmentId, surveyId, session }: SummaryListProps) {
const { survey, responses } = await getAnalysisData(session, surveyId);
const { survey, responses } = await getAnalysisData(session, surveyId, environmentId);
const getSummaryData = (): QuestionSummary<TSurveyQuestion>[] =>
survey.questions.map((question) => {

View File

@@ -3,9 +3,10 @@ import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { Session } from "next-auth";
export const getAnalysisData = async (session: Session, surveyId: string) => {
export const getAnalysisData = async (session: Session, surveyId: string, environmentId: string) => {
const survey = await getSurvey(surveyId);
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
if (survey.environmentId !== environmentId) throw new Error(`Survey not found: ${surveyId}`);
const allResponses = await getSurveyResponses(surveyId);
const limitReached =
IS_FORMBRICKS_CLOUD && session?.user.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;

View File

@@ -1,11 +1,15 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import SurveysList from "./SurveyList";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
export default async function SurveysPage({ params }) {
const environmentId = params.environmentId;
const product = await getProductByEnvironmentId(environmentId);
return (
<ContentWrapper className="flex h-full flex-col justify-between">
<SurveysList environmentId={params.environmentId} />
<SurveysList environmentId={params.environmentId} product={product} />
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
</ContentWrapper>
);

View File

@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import type { Template } from "@formbricks/types/templates";
import { useEffect } from "react";
import { replacePresetPlaceholders } from "@/lib/templates";
import { templates } from "./templates";
import PreviewSurvey from "../PreviewSurvey";
import TemplateList from "./TemplateList";
import type { TProduct } from "@formbricks/types/v1/product";
import type { TEnvironment } from "@formbricks/types/v1/environment";
type TemplateContainerWithPreviewProps = {
environmentId: string;
product: TProduct;
environment: TEnvironment;
};
export default function TemplateContainerWithPreview({
environmentId,
product,
environment,
}: TemplateContainerWithPreviewProps) {
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
useEffect(() => {
if (product && templates?.length) {
const newTemplate = replacePresetPlaceholders(templates[0], product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}
}, [product]);
return (
<div className="flex h-full flex-col ">
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
<TemplateList
environmentId={environmentId}
environment={environment}
product={product}
onTemplateClick={(template) => {
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
{activeTemplate && (
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
<PreviewSurvey
activeQuestionId={activeQuestionId}
questions={activeTemplate.preset.questions}
brandColor={product.brandColor}
product={product}
environment={environment}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
thankYouCard={{ enabled: true }}
autoClose={null}
/>
</div>
)}
</aside>
</div>
</div>
);
}

View File

@@ -1,9 +1,5 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { useProfile } from "@/lib/profile";
import { createSurvey } from "@/lib/surveys/surveys";
import { replacePresetPlaceholders } from "@/lib/templates";
import { cn } from "@formbricks/lib/cn";
@@ -16,24 +12,26 @@ import { useEffect, useState } from "react";
import { customSurvey, templates } from "./templates";
import { SplitIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { useProfile } from "@/lib/profile";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
type TemplateList = {
environmentId: string;
onTemplateClick: (template: Template) => void;
environment: TEnvironment;
product: TProduct;
};
const ALL_CATEGORY_NAME = "All";
const RECOMMENDED_CATEGORY_NAME = "For you";
export default function TemplateList({ environmentId, onTemplateClick }: TemplateList) {
export default function TemplateList({ environmentId, onTemplateClick, product, environment }: TemplateList) {
const router = useRouter();
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(false);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
const { environment } = useEnvironment(environmentId);
const [selectedFilter, setSelectedFilter] = useState(RECOMMENDED_CATEGORY_NAME);
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
const [categories, setCategories] = useState<Array<string>>([]);
@@ -65,8 +63,8 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
};
if (isLoadingProduct || isLoadingProfile) return <LoadingSpinner />;
if (isErrorProduct || isErrorProfile) return <ErrorComponent />;
if (isLoadingProfile) return <LoadingSpinner />;
if (isErrorProfile) return <ErrorComponent />;
return (
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-3 focus:outline-none">

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