merge latest changes
@@ -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=
|
||||
@@ -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=
|
||||
|
||||
7
.github/workflows/checks.yml
vendored
@@ -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
@@ -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
|
||||
2
.github/workflows/cron-weeklySummary.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
BIN
apps/formbricks-com/pages/docs/api/api-key-setup/add-api-key.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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 won’t 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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
102
apps/formbricks-com/pages/docs/integrations/zapier/index.mdx
Normal 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 you’ll 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 you’ll 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 don’t 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>;
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 23 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
});
|
||||
} */
|
||||
|
||||
|
||||
43
apps/web/app/api/cron/close_surveys/route.ts
Normal 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)`,
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
75
apps/web/app/api/v1/js/actions/route.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
128
apps/web/app/api/v1/js/people/[personId]/set-attribute/route.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
120
apps/web/app/api/v1/js/people/[personId]/set-user-id/route.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
153
apps/web/app/api/v1/js/surveys.ts
Normal 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;
|
||||
};
|
||||
139
apps/web/app/api/v1/js/sync/route.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
32
apps/web/app/api/v1/surveys/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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">
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
|
||||
export default function IntegrationsLayout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 "Other"
|
||||
</Button>
|
||||
</>
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
|
||||
Add "Other"
|
||||
</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>
|
||||
|
||||
@@ -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 "Other"
|
||||
</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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||