Merge branch 'main' of github.com:formbricks/formbricks into feature/FOR-1081
8
.github/workflows/cron-closeOnDate.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Cron - weeklySummary
|
||||
name: Cron - closeOnDate
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
@@ -10,14 +10,14 @@ jobs:
|
||||
cron-weeklySummary:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/close_surveys \
|
||||
curl ${{ env.APP_URL }}/api/cron/close_surveys \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
|
||||
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
|
||||
--fail
|
||||
|
||||
@@ -10,6 +10,7 @@ interface APICallProps {
|
||||
label: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
}[];
|
||||
bodies: {
|
||||
label: string;
|
||||
@@ -69,7 +70,13 @@ export function APILayout({ method, url, description, headers, bodies, responses
|
||||
<p className="not-prose -mb-1 pt-2 font-bold">Headers</p>
|
||||
<div>
|
||||
{headers.map((q) => (
|
||||
<Parameter key={q.label} label={q.label} type={q.type} description={q.description} />
|
||||
<Parameter
|
||||
key={q.label}
|
||||
label={q.label}
|
||||
type={q.type}
|
||||
description={q.description}
|
||||
required={q.required}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,10 @@ interface Props {
|
||||
meta: {
|
||||
title: string;
|
||||
description: string;
|
||||
publishedTime: string;
|
||||
authors: string[];
|
||||
section: string;
|
||||
tags: string[];
|
||||
};
|
||||
children: JSX.Element;
|
||||
}
|
||||
@@ -34,7 +38,14 @@ export default function LayoutMdx({ meta, children }: Props) {
|
||||
useExternalLinks(".prose a");
|
||||
return (
|
||||
<div className="flex h-screen flex-col justify-between">
|
||||
<MetaInformation title={meta.title} description={meta.description} />
|
||||
<MetaInformation
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
publishedTime={meta.publishedTime}
|
||||
authors={meta.authors}
|
||||
section={meta.section}
|
||||
tags={meta.tags}
|
||||
/>
|
||||
<Header />
|
||||
<main className="min-w-0 max-w-2xl flex-auto px-4 lg:max-w-none lg:pl-8 lg:pr-0 xl:px-16">
|
||||
<article className="mx-auto my-16 max-w-3xl px-2">
|
||||
|
||||
@@ -3,10 +3,21 @@ import Head from "next/head";
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
publishedTime?: string;
|
||||
authors?: string[];
|
||||
section?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export default function MetaInformation({ title, description }: Props) {
|
||||
const pageTitle = `${title} | Open-Source Survey Software`;
|
||||
export default function MetaInformation({
|
||||
title,
|
||||
description,
|
||||
publishedTime,
|
||||
authors,
|
||||
section,
|
||||
tags,
|
||||
}: Props) {
|
||||
const pageTitle = `${title} | Open-Source Experience Management, Privacy-first`;
|
||||
return (
|
||||
<Head>
|
||||
<title>{pageTitle}</title>
|
||||
@@ -14,13 +25,22 @@ export default function MetaInformation({ title, description }: Props) {
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={`https://${process.env.VERCEL_URL}/social-image.png`} />
|
||||
<meta property="og:image:alt" content="Formbricks - Open Source Form and Survey Infrastructure" />
|
||||
<meta property="og:image:alt" content="Formbricks - Open Source Experience Management, Privacy-first" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Open Source Forms and Surveys by Formbricks" />
|
||||
<meta property="og:site_name" content="Open Source Experience Management, Privacy-first" />
|
||||
<meta property="article:publisher" content="Formbricks" />
|
||||
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
|
||||
{authors && <meta property="article:author" content={authors.join(", ")} />}
|
||||
{section && <meta property="article:section" content={section} />}
|
||||
{tags && <meta property="article:tag" content={tags.join(", ")} />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@formbricks" />
|
||||
<meta name="twitter:creator" content="@formbricks" />
|
||||
<meta name="theme-color" content="#00C4B8" />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,12 +91,16 @@ const nextConfig = {
|
||||
destination: "/docs/actions/code",
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
{
|
||||
source: "/pmf",
|
||||
destination: "/",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/blog/v1-and-how-we-got-here",
|
||||
destination: "/blog/experience-management-open-source",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
"Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
|
||||
href: "https://typebot.io",
|
||||
},
|
||||
{
|
||||
{
|
||||
name: "Twenty",
|
||||
description:
|
||||
"A modern CRM offering the flexibility of open-source, advanced features and sleek design.",
|
||||
|
||||
@@ -9,35 +9,39 @@ import SurveyJS from "./surveyjs-free-opensource-form-survey-tool-software-to-ma
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Best Open-source Form & Survey Tools (still maintained in 2023)",
|
||||
title: "5 Open Source Survey and Form Tools maintained in 2023",
|
||||
description:
|
||||
"Most open-source projects get abandoned after a while. But these 5 open-source form and survey tools are still alive and kicking in 2023.",
|
||||
"Most open source projects get abandoned after a while. But these 5 open source survey tools are still alive and kicking in 2023.",
|
||||
date: "2023-04-12",
|
||||
publishedTime: "2023-04-12T12:00:00",
|
||||
authors: ["Johannes"],
|
||||
section: "Open Source Surveys",
|
||||
tags: ["Open Source Surveys", "Formbricks", "Typeform", "SurveyJS", "Typebot", "OpnForm", "LimeSurvey"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
_Most open-source projects get abandoned after a while. But these 5 open-source form and survey tools are still alive and kicking in 2023._
|
||||
_Most open source projects get abandoned after a while. But these 5 open source survey tools are still alive and kicking in 2023._
|
||||
|
||||
<Image
|
||||
src={HeaderImage}
|
||||
alt="Free and self-hostable: Find the 5 best (and maintained) open-source survey tools 2023."
|
||||
alt="Open source survey tool self-hostable: Find the 5 best (and maintained) open source survey tool 2023."
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
Looking for the perfect open-source form and survey tool to help you gather valuable insights and improve your business? Look no further!
|
||||
Looking for the perfect open source survey tool to help you gather valuable insights and improve your business? Look no further!
|
||||
|
||||
We've compiled a list of the top 5 open-source form and survey tools that are still maintained in 2023. In-product surveys, conversational bots, AI-generated surveys: These tools offer various features that cater to different needs.
|
||||
We've compiled a list of the top 5 open source form and survey tools that are still maintained in 2023. In app surveys, conversational bots, AI-generated surveys: These open source tools offer various features that cater to different needs.
|
||||
|
||||
## 1. Formbricks - In-product micro-surveys
|
||||
## 1. Formbricks - In app micro surveys
|
||||
|
||||
<Image
|
||||
src={Formbricks}
|
||||
alt="Formbricks is a free and open-source survey software for in-product micro-surveys. Ask any segment at any point in time."
|
||||
alt="Formbricks is a free and open source survey software for in app micro surveys. Ask any user segment at any point in the user journey."
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
Formbricks is a powerful open-source form and survey solution designed to help you get better experience data for your business. This tool allows you to survey specific customer segments at any point in the user journey, providing you with invaluable insights into what your customers think and feel.
|
||||
Formbricks is a powerful open source survey tool designed to help you get better experience data for your business. This tool allows you to survey specific customer segments at any point in the user journey, providing you with invaluable insights into what your customers think and feel about your product.
|
||||
|
||||
- 👍 **Pre-segment users:** Don't ask everyone, all the time. Granularly segment your user base to get deep insights
|
||||
- 👍 **Event-based surveys:** Trigger surveys based on user behavior, such as page views, clicks, and more
|
||||
@@ -45,11 +49,15 @@ Formbricks is a powerful open-source form and survey solution designed to help y
|
||||
- 👍 **Easy self-hosting:** Docker makes it possible to self-host Formbricks in minutes.
|
||||
- ⚠️ **It's early for Formbricks.** Product developes rapidly but might encounter a bug here and there.
|
||||
|
||||
[Try it out](https://app.formbricks.com), [read more](https://formbricks.com) or dive into the [code base.](https://formbricks.com/github)
|
||||
[Try it out](https://app.formbricks.com), [read more](https://formbricks.com) or dive into the [code base](https://formbricks.com/github) or comprehensive [docs](https://formbricks.com/docs).
|
||||
|
||||
## 2. SurveyJS - Build-it-yourself libraries
|
||||
## 2. SurveyJS - Build-it-yourself library
|
||||
|
||||
<Image src={SurveyJS} alt="SurveyJS is a comprehensive" className="rounded-lg" />
|
||||
<Image
|
||||
src={SurveyJS}
|
||||
alt="SurveyJS is a comprehensive JS library to build your own form or survey application."
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
SurveyJS is a collection of JavaScript Librarys to build forms. Building your own form management system has never been easier than with SurveyJS. It packs:
|
||||
|
||||
@@ -61,9 +69,13 @@ SurveyJS is a collection of JavaScript Librarys to build forms. Building your ow
|
||||
|
||||
[Dive into the code on GitHub](https://github.com/surveyjs)
|
||||
|
||||
## 3. Typebot - Truly conversational Forms
|
||||
## 3. Typebot - Truly conversational forms
|
||||
|
||||
<Image src={Typebot} alt="SurveyJS is a comprehensive" className="rounded-lg" />
|
||||
<Image
|
||||
src={Typebot}
|
||||
alt="Open source survey and form builder SurveyJS lets you build surveys fast"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
Coming in at number three on our list is Typebot, that makes it really easy to create conversational forms and surveys. Typebot helps you engage with your audience in a more interactive way, leading to higher response rates and better data. It comes with:
|
||||
|
||||
@@ -77,9 +89,13 @@ Coming in at number three on our list is Typebot, that makes it really easy to c
|
||||
|
||||
## 4. OpnForm - Straight-forward survey builder
|
||||
|
||||
<Image src={OpnForm} alt="SurveyJS is a comprehensive" className="rounded-lg" />
|
||||
<Image
|
||||
src={OpnForm}
|
||||
alt="OpnForm is an open source form builder for experience management"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
OpnForms is a flexible and powerful open-source form and survey tool designed to make data collection easy and efficient. OpnForm packs lots of features, especially for a Beta:
|
||||
OpnForms is a flexible and powerful open source form and survey tool designed to make data collection easy and efficient. OpnForm packs lots of features, especially for a Beta:
|
||||
|
||||
- 👍 **Multiple Question Types:** Choose from a wide variety of question types to create highly customizable forms and surveys.
|
||||
- 👍 **Conditional Logic:** Show or hide questions based on previous responses to create personalized surveys.
|
||||
@@ -90,7 +106,11 @@ OpnForms is a flexible and powerful open-source form and survey tool designed to
|
||||
|
||||
## 5. LimeSurvey - Old (but gold?)
|
||||
|
||||
<Image src={LimeSurvey} alt="SurveyJS is a comprehensive" className="rounded-lg" />
|
||||
<Image
|
||||
src={LimeSurvey}
|
||||
alt="LimeSurvey is open source survey builder to manage experiences with forms"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
LimeSurvey has been around for at least a decade. It's a powerful survey tool made for more classical, scientific surveying. It packs:
|
||||
|
||||
@@ -104,9 +124,9 @@ LimeSurvey has been around for at least a decade. It's a powerful survey tool ma
|
||||
|
||||
## Summary ☟
|
||||
|
||||
In this article, we've rounded up the top 5 open-source form and survey tools that are still rocking it in 2023. Perfect for devs who are always on the lookout for the latest and greatest!
|
||||
In this article, we've rounded up the top 5 open source form and survey tools that are still rocking it in 2023. Perfect for devs who are always on the lookout for the latest and greatest!
|
||||
|
||||
1. Formbricks: A game-changer for in-product micro-surveys, letting you target specific customer segments at any point in their journey. It's still early days, but this bad boy is worth keeping an eye on.
|
||||
1. Formbricks: A game-changer for in app micro surveys, letting you target specific customer segments at any point in their journey. It's still early days, but this bad boy is worth keeping an eye on.
|
||||
|
||||
2. SurveyJS: A must-have for DIY enthusiasts, this collection of JavaScript libraries makes building your own form management system a breeze. Just remember, the starting price is $499/year.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -16,6 +16,10 @@ export const meta = {
|
||||
description:
|
||||
"A lot has happened since Matti and I had a chat about open-source surveys in May last year. The release of Formbricks v1.0 is a perfect opportunity to look back on how it all started.",
|
||||
date: "2023-07-14",
|
||||
publishedTime: "2023-07-14T08:10:33",
|
||||
authors: ["Johannes"],
|
||||
section: "Open Source Experience Management",
|
||||
tags: ["Open Source", "Experience Management", "Formbricks"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -11,7 +11,11 @@ export const meta = {
|
||||
title: "Our GitHub Accelerator Experience 👀",
|
||||
description:
|
||||
"What we learned during the first GitHub Open-Source Accelerator Programm - our experience and if we would do it again.",
|
||||
date: "2023-04-13",
|
||||
date: "2023-06-06",
|
||||
publishedTime: "2023-06-06T05:12:25",
|
||||
authors: ["Johannes"],
|
||||
section: "GitHub Accelerator",
|
||||
tags: ["GitHub Accelerator", "Open-Source", "Startup"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
@@ -40,7 +44,7 @@ January and February came and went. On the 22nd of March, we received an email f
|
||||
|
||||
Needless to say, we were thrilled! We were selected from over 1000 open-source projects, alongside renowned and popular projects like [Nuxt](https://github.com/nuxt/nuxt), [TRPC](https://github.com/trpc/trpc), and [Responsively App](https://github.com/responsively-org/responsively-app). Here is a summary of what we got:
|
||||
|
||||
### What we got on paper
|
||||
### What the GitHub Accelerator offers on paper
|
||||
|
||||
✅ Ten sessions with **well-known** figures from the open-source community (Wednesdays)
|
||||
|
||||
@@ -108,7 +112,7 @@ Ericas talk was really impressive and so is she: Founder of Bitnami, COO of GitH
|
||||
|
||||
**GitHub Demo Day:** All teams presented what they achieved during the 10 week programm. It was great fun to present Formbricks, [you can watch it on Youtube.](https://www.youtube.com/live/Gj6Bez2182k?feature=share&t=1448)
|
||||
|
||||
## Would we do it again? And should you?
|
||||
## Would we do the GitHub Accelerator again? And should you?
|
||||
|
||||
Yes, absolutely. The sessions were excellent, we met a handful of inspiring builders, and the 20k USD was a helpful financial boost. The application process might evolve, but since all of your code is open-source anyway, you might as well throw your hat in the ring.
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ export const meta = {
|
||||
description:
|
||||
"We're getting ready to take our open-source experience management platform to new heights, thanks to being part of the first-ever GitHub Accelerator program!",
|
||||
date: "2023-04-13",
|
||||
publishedTime: "2023-04-13T05:12:25",
|
||||
authors: ["Johannes"],
|
||||
section: "GitHub Accelerator",
|
||||
tags: ["GitHub Accelerator", "Open-Source"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
@@ -10,6 +10,10 @@ export const meta = {
|
||||
title: "Open source forms will save the world.",
|
||||
description: "What motivates us to build open source tech in such a crowded space?",
|
||||
date: "2022-08-26",
|
||||
publishedTime: "2022-08-26T12:12:25",
|
||||
authors: ["Johannes"],
|
||||
section: "Open Source Survey Tool",
|
||||
tags: ["Open Source", "Survey Tool", "Forms"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
@@ -16,6 +16,10 @@ export const meta = {
|
||||
description:
|
||||
"We kicked it off with a Typeform open-source alternative. As we build and learn, our focus is shifting. Read why:",
|
||||
date: "2023-03-24",
|
||||
publishedTime: "2023-03-23T08:20:15",
|
||||
authors: ["Johannes"],
|
||||
section: "Open Source Experience Management",
|
||||
tags: ["Open Source", "Experience Management", "Typeform", "Qualtrics"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
@@ -8,6 +8,10 @@ export const meta = {
|
||||
title: "snoopForms → Formbricks 🎉",
|
||||
description: "A new name, a new look, key learnings and what's next.",
|
||||
date: "2022-11-07",
|
||||
publishedTime: "2023-07-11T12:00:06",
|
||||
authors: ["Johannes"],
|
||||
section: "Formbricks",
|
||||
tags: ["Formbricks", "snoopForms", "Open Source"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
|
Before Width: | Height: | Size: 6.5 KiB |
@@ -13,6 +13,10 @@ export const meta = {
|
||||
description:
|
||||
"Here is why a no-code interface is cheatcode for OSS and why particularly large corporations and governments are to benefit the most",
|
||||
date: "2022-06-03",
|
||||
publishedTime: "2022-06-03T06:04:08",
|
||||
authors: ["Johannes"],
|
||||
section: "Open-Source",
|
||||
tags: ["Open-Source", "No-Code", "Enterprise", "Government"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
|
||||
|
||||
@@ -41,7 +41,7 @@ export const meta = {
|
||||
]}
|
||||
example={`{
|
||||
"url": "https://mysystem.com/myendpoint",
|
||||
"trigger": "responseFinished"
|
||||
"triggers": ["responseFinished"]
|
||||
}`}
|
||||
responses={[
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY .env.docker /app/apps/web/.env
|
||||
RUN pnpm install
|
||||
|
||||
# Build the project
|
||||
RUN pnpm prebuild --filter=web...
|
||||
RUN pnpm post-install --filter=web...
|
||||
RUN pnpm turbo run build --filter=web...
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import {
|
||||
@@ -65,6 +66,8 @@ import AddProductModal from "./AddProductModal";
|
||||
import { formbricksLogout } from "@/lib/formbricks";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
interface EnvironmentsNavbarProps {
|
||||
environmentId: string;
|
||||
@@ -86,6 +89,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
|
||||
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && environment.widgetSetupCompleted) {
|
||||
setWidgetSetupCompleted(true);
|
||||
@@ -227,13 +232,14 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="flex h-14 justify-between">
|
||||
<div className="flex space-x-4 py-2">
|
||||
<div className="flex space-x-4 py-2">
|
||||
<Link
|
||||
href={`/environments/${environmentId}/surveys/`}
|
||||
className=" flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
|
||||
{/* <PlusIcon className="h-6 w-6" /> */}
|
||||
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
|
||||
</Link>
|
||||
|
||||
{navigation.map((item) => {
|
||||
const IconComponent: React.ElementType = item.icon;
|
||||
|
||||
@@ -245,7 +251,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
item.current
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
|
||||
"inline-flex items-center rounded-md px-2 py-1 text-sm font-medium"
|
||||
"hidden items-center rounded-md px-2 py-1 text-sm font-medium sm:inline-flex"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<IconComponent className="mr-3 h-5 w-5" />
|
||||
@@ -254,6 +260,34 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center sm:hidden">
|
||||
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
|
||||
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
|
||||
<span>
|
||||
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="mr-4 bg-slate-200">
|
||||
<div className="flex flex-col">
|
||||
{navigation.map((navItem) => (
|
||||
<Link key={navItem.name} href={navItem.href}>
|
||||
<div
|
||||
onClick={() => setMobileNavMenuOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 rounded-md p-2",
|
||||
navItem.current && "bg-slate-300"
|
||||
)}>
|
||||
<navItem.icon className="h-5 w-5" />
|
||||
<span className="font-medium text-slate-600">{navItem.name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:ml-6 sm:flex sm:items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import JsLogo from "@/images/jslogo.png";
|
||||
import WebhookLogo from "@/images/webhook.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
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() {
|
||||
export default function IntegrationsPage({ params }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
|
||||
@@ -11,17 +12,34 @@ export default function IntegrationsPage() {
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/getting-started/nextjs-app"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
label="Javascript Widget"
|
||||
description="Integrate Formbricks into your Webapp"
|
||||
icon={<Image src={JsLogo} alt="Javascript Logo" />}
|
||||
/>
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/integrations/zapier"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
connectHref="https://zapier.com/apps/formbricks/integrations"
|
||||
connectText="Connect"
|
||||
connectNewTab={true}
|
||||
label="Zapier"
|
||||
description="Integrate Formbricks with 5000+ apps via Zapier"
|
||||
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
|
||||
/>
|
||||
<Card
|
||||
connectHref={`/environments/${params.environmentId}/integrations/webhooks`}
|
||||
connectText="Manage Webhooks"
|
||||
connectNewTab={false}
|
||||
docsHref="https://formbricks.com/docs/webhook-api/overview"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
label="Webhooks"
|
||||
description="Trigger Webhooks based on actions in your surveys"
|
||||
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup";
|
||||
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
|
||||
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
|
||||
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { createWebhook } from "@formbricks/lib/services/webhook";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import clsx from "clsx";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface AddWebhookModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AddWebhookModal({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { isSubmitting },
|
||||
} = useForm();
|
||||
|
||||
const [testEndpointInput, setTestEndpointInput] = useState("");
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>([]);
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
setHittingEndpoint(true);
|
||||
await testEndpoint(testEndpointInput);
|
||||
setHittingEndpoint(false);
|
||||
if (sendSuccessToast) toast.success("Yay! We are able to ping the webhook!");
|
||||
setEndpointAccessible(true);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setHittingEndpoint(false);
|
||||
toast.error("Oh no! We are unable to ping the webhook!");
|
||||
setEndpointAccessible(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllSurveys = () => {
|
||||
setSelectedAllSurveys(!selectedAllSurveys);
|
||||
setSelectedSurveys([]);
|
||||
};
|
||||
|
||||
const handleSelectedSurveyChange = (surveyId: string) => {
|
||||
setSelectedSurveys((prevSelectedSurveys: string[]) =>
|
||||
prevSelectedSurveys.includes(surveyId)
|
||||
? prevSelectedSurveys.filter((id) => id !== surveyId)
|
||||
: [...prevSelectedSurveys, surveyId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (selectedValue: TPipelineTrigger) => {
|
||||
setSelectedTriggers((prevValues) =>
|
||||
prevValues.includes(selectedValue)
|
||||
? prevValues.filter((value) => value !== selectedValue)
|
||||
: [...prevValues, selectedValue]
|
||||
);
|
||||
};
|
||||
|
||||
const submitWebhook = async (data: TWebhookInput): Promise<void> => {
|
||||
if (!isSubmitting) {
|
||||
try {
|
||||
setCreatingWebhook(true);
|
||||
if (!testEndpointInput || testEndpointInput === "") {
|
||||
throw new Error("Please enter a URL");
|
||||
}
|
||||
if (selectedTriggers.length === 0) {
|
||||
throw new Error("Please select at least one trigger");
|
||||
}
|
||||
|
||||
if (!selectedAllSurveys && selectedSurveys.length === 0) {
|
||||
throw new Error("Please select at least one survey");
|
||||
}
|
||||
|
||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||
if (!endpointHitSuccessfully) return;
|
||||
|
||||
const updatedData: TWebhookInput = {
|
||||
name: data.name,
|
||||
url: testEndpointInput,
|
||||
triggers: selectedTriggers,
|
||||
surveyIds: selectedSurveys,
|
||||
};
|
||||
|
||||
await createWebhook(environmentId, updatedData);
|
||||
router.refresh();
|
||||
setOpenWithStates(false);
|
||||
toast.success("Webhook added successfully.");
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setCreatingWebhook(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenWithStates = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
reset();
|
||||
setTestEndpointInput("");
|
||||
setEndpointAccessible(undefined);
|
||||
setSelectedSurveys([]);
|
||||
setSelectedTriggers([]);
|
||||
setSelectedAllSurveys(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Webhook />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">Add Webhook</div>
|
||||
<div className="text-sm text-slate-500">Send survey response data to a custom endpoint</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitWebhook)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
{...register("name")}
|
||||
placeholder="Optional: Label your webhook for easy identification"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="URL">URL</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
type="url"
|
||||
id="URL"
|
||||
value={testEndpointInput}
|
||||
onChange={(e) => {
|
||||
setTestEndpointInput(e.target.value);
|
||||
}}
|
||||
className={clsx(
|
||||
endpointAccessible === true
|
||||
? "border-green-500 bg-green-50"
|
||||
: endpointAccessible === false
|
||||
? "border-red-200 bg-red-50"
|
||||
: endpointAccessible === undefined
|
||||
? "border-slate-200 bg-white"
|
||||
: null
|
||||
)}
|
||||
placeholder="Paste the URL you want the event to trigger on"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={hittingEndpoint}
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
handleTestEndpoint(true);
|
||||
}}>
|
||||
Test Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Triggers">Triggers</Label>
|
||||
<TriggerCheckboxGroup
|
||||
triggers={triggers}
|
||||
selectedTriggers={selectedTriggers}
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Surveys</Label>
|
||||
<SurveyCheckboxGroup
|
||||
surveys={surveys}
|
||||
selectedSurveys={selectedSurveys}
|
||||
selectedAllSurveys={selectedAllSurveys}
|
||||
onSelectAllSurveys={handleSelectAllSurveys}
|
||||
onSelectedSurveyChange={handleSelectedSurveyChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
onClick={() => {
|
||||
setOpenWithStates(false);
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={creatingWebhook}>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
|
||||
export const triggers = [
|
||||
{ title: "Response Created", value: "responseCreated" as TPipelineTrigger },
|
||||
{ title: "Response Updated", value: "responseUpdated" as TPipelineTrigger },
|
||||
{ title: "Response Finished", value: "responseFinished" as TPipelineTrigger },
|
||||
];
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@formbricks/ui";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
interface SurveyCheckboxGroupProps {
|
||||
surveys: TSurvey[];
|
||||
selectedSurveys: string[];
|
||||
selectedAllSurveys: boolean;
|
||||
onSelectAllSurveys: () => void;
|
||||
onSelectedSurveyChange: (surveyId: string) => void;
|
||||
}
|
||||
|
||||
export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
|
||||
surveys,
|
||||
selectedSurveys,
|
||||
selectedAllSurveys,
|
||||
onSelectAllSurveys,
|
||||
onSelectedSurveyChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id="allSurveys"
|
||||
className="bg-white"
|
||||
value=""
|
||||
checked={selectedAllSurveys}
|
||||
onCheckedChange={onSelectAllSurveys}
|
||||
/>
|
||||
<label
|
||||
htmlFor="allSurveys"
|
||||
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""}`}>
|
||||
All current and new surveys
|
||||
</label>
|
||||
</div>
|
||||
{surveys.map((survey) => (
|
||||
<div key={survey.id} className="my-1 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={survey.id}
|
||||
value={survey.id}
|
||||
className="bg-white"
|
||||
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
|
||||
disabled={selectedAllSurveys}
|
||||
onCheckedChange={() => onSelectedSurveyChange(survey.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={survey.id}
|
||||
className={`flex cursor-pointer items-center ${
|
||||
selectedAllSurveys ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}>
|
||||
{survey.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyCheckboxGroup;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@formbricks/ui";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
|
||||
interface TriggerCheckboxGroupProps {
|
||||
triggers: { title: string; value: TPipelineTrigger }[];
|
||||
selectedTriggers: TPipelineTrigger[];
|
||||
onCheckboxChange: (selectedValue: TPipelineTrigger) => void;
|
||||
}
|
||||
|
||||
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
|
||||
triggers,
|
||||
selectedTriggers,
|
||||
onCheckboxChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{triggers.map((trigger) => (
|
||||
<div key={trigger.value} className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={trigger.value} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={trigger.value}
|
||||
value={trigger.value}
|
||||
className="bg-white"
|
||||
checked={selectedTriggers.includes(trigger.value)}
|
||||
onCheckedChange={() => {
|
||||
onCheckboxChange(trigger.value);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{trigger.title}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TriggerCheckboxGroup;
|
||||
@@ -0,0 +1,47 @@
|
||||
import ModalWithTabs from "@/components/shared/ModalWithTabs";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import WebhookOverviewTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookOverviewTab";
|
||||
import WebhookSettingsTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookSettingsTab";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Webhook } from "lucide-react";
|
||||
|
||||
interface WebhookModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
}
|
||||
|
||||
export default function WebhookModal({ environmentId, open, setOpen, webhook, surveys }: WebhookModalProps) {
|
||||
const tabs = [
|
||||
{
|
||||
title: "Overview",
|
||||
children: <WebhookOverviewTab webhook={webhook} surveys={surveys} />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
children: (
|
||||
<WebhookSettingsTab
|
||||
environmentId={environmentId}
|
||||
webhook={webhook}
|
||||
surveys={surveys}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={<Webhook />}
|
||||
label={webhook.name ? webhook.name : webhook.url}
|
||||
description={""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Label } from "@formbricks/ui";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
interface ActivityTabProps {
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
}
|
||||
|
||||
const getSurveyNamesForWebhook = (webhook: TWebhook, allSurveys: TSurvey[]): string[] => {
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
return allSurveys.map((survey) => survey.name);
|
||||
} else {
|
||||
return webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const convertTriggerIdToName = (triggerId: string): string => {
|
||||
switch (triggerId) {
|
||||
case "responseCreated":
|
||||
return "Response Created";
|
||||
case "responseUpdated":
|
||||
return "Response Updated";
|
||||
case "responseFinished":
|
||||
return "Response Finished";
|
||||
default:
|
||||
return triggerId;
|
||||
}
|
||||
};
|
||||
|
||||
export default function WebhookOverviewTab({ webhook, surveys }: ActivityTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
<div className="col-span-2 space-y-4 pr-6">
|
||||
<div>
|
||||
<Label className="text-slate-500">Name</Label>
|
||||
<p className="text-sm text-slate-900">{webhook.name ? webhook.name : "-"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">URL</Label>
|
||||
<p className="text-sm text-slate-900">{webhook.url}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">Surveys</Label>
|
||||
|
||||
{getSurveyNamesForWebhook(webhook, surveys).map((surveyName, index) => (
|
||||
<p key={index} className="text-sm text-slate-900">
|
||||
{surveyName}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-500">Triggers</Label>
|
||||
{webhook.triggers.map((triggerId) => (
|
||||
<p key={triggerId} className="text-sm text-slate-900">
|
||||
{convertTriggerIdToName(triggerId)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">Created on</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(webhook.createdAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className=" text-xs font-normal text-slate-500">Last updated</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
|
||||
const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) => {
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
const allSurveyNames = allSurveys.map((survey) => survey.name);
|
||||
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
|
||||
} else {
|
||||
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
const renderSelectedTriggersText = (webhook: TWebhook) => {
|
||||
if (webhook.triggers.length === 0) {
|
||||
return <p className="text-slate-400">No Triggers</p>;
|
||||
} else {
|
||||
let cleanedTriggers = webhook.triggers.map((trigger) => {
|
||||
if (trigger === "responseCreated") {
|
||||
return "Response Created";
|
||||
} else if (trigger === "responseUpdated") {
|
||||
return "Response Updated";
|
||||
} else if (trigger === "responseFinished") {
|
||||
return "Response Finished";
|
||||
} else {
|
||||
return trigger;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<p className="text-slate-400">
|
||||
{cleanedTriggers
|
||||
.sort((a, b) => {
|
||||
const triggerOrder = {
|
||||
"Response Created": 1,
|
||||
"Response Updated": 2,
|
||||
"Response Finished": 3,
|
||||
};
|
||||
|
||||
return triggerOrder[a] - triggerOrder[b];
|
||||
})
|
||||
.join(", ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook; surveys: TSurvey[] }) {
|
||||
return (
|
||||
<div className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="text-left">
|
||||
{webhook.name ? (
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">{webhook.name}</div>
|
||||
<div className="text-xs text-slate-400">{webhook.url}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-medium text-slate-900">{webhook.url}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
|
||||
{renderSelectedSurveysText(webhook, surveys)}
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm text-slate-800">
|
||||
{renderSelectedTriggersText(webhook)}
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSinceConditionally(webhook.createdAt.toString())}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { deleteWebhook, updateWebhook } from "@formbricks/lib/services/webhook";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
|
||||
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
|
||||
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
|
||||
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
environmentId: string;
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function WebhookSettingsTab({
|
||||
environmentId,
|
||||
webhook,
|
||||
surveys,
|
||||
setOpen,
|
||||
}: ActionSettingsTabProps) {
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
triggers: webhook.triggers,
|
||||
surveyIds: webhook.surveyIds,
|
||||
},
|
||||
});
|
||||
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
const [isUpdatingWebhook, setIsUpdatingWebhook] = useState(false);
|
||||
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>(webhook.triggers);
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>(webhook.surveyIds);
|
||||
const [testEndpointInput, setTestEndpointInput] = useState(webhook.url);
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
setHittingEndpoint(true);
|
||||
await testEndpoint(testEndpointInput);
|
||||
setHittingEndpoint(false);
|
||||
if (sendSuccessToast) toast.success("Yay! We are able to ping the webhook!");
|
||||
setEndpointAccessible(true);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setHittingEndpoint(false);
|
||||
toast.error("Oh no! We are unable to ping the webhook!");
|
||||
setEndpointAccessible(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllSurveys = () => {
|
||||
setSelectedAllSurveys(!selectedAllSurveys);
|
||||
setSelectedSurveys([]);
|
||||
};
|
||||
|
||||
const handleSelectedSurveyChange = (surveyId) => {
|
||||
setSelectedSurveys((prevSelectedSurveys) => {
|
||||
if (prevSelectedSurveys.includes(surveyId)) {
|
||||
return prevSelectedSurveys.filter((id) => id !== surveyId);
|
||||
} else {
|
||||
return [...prevSelectedSurveys, surveyId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (selectedValue) => {
|
||||
setSelectedTriggers((prevValues) => {
|
||||
if (prevValues.includes(selectedValue)) {
|
||||
return prevValues.filter((value) => value !== selectedValue);
|
||||
} else {
|
||||
return [...prevValues, selectedValue];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (selectedTriggers.length === 0) {
|
||||
toast.error("Please select at least one trigger");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAllSurveys && selectedSurveys.length === 0) {
|
||||
toast.error("Please select at least one survey");
|
||||
return;
|
||||
}
|
||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||
if (!endpointHitSuccessfully) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedData: TWebhookInput = {
|
||||
name: data.name,
|
||||
url: data.url as string,
|
||||
triggers: selectedTriggers,
|
||||
surveyIds: selectedSurveys,
|
||||
};
|
||||
setIsUpdatingWebhook(true);
|
||||
await updateWebhook(environmentId, webhook.id, updatedData);
|
||||
toast.success("Webhook updated successfully.");
|
||||
router.refresh();
|
||||
setIsUpdatingWebhook(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="Name">Name</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
{...register("name")}
|
||||
defaultValue={webhook.name ?? ""}
|
||||
placeholder="Optional: Label your webhook for easy identification"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="URL">URL</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
{...register("url", {
|
||||
value: testEndpointInput,
|
||||
})}
|
||||
type="text"
|
||||
value={testEndpointInput}
|
||||
onChange={(e) => {
|
||||
setTestEndpointInput(e.target.value);
|
||||
}}
|
||||
className={clsx(
|
||||
endpointAccessible === true
|
||||
? "border-green-500 bg-green-50"
|
||||
: endpointAccessible === false
|
||||
? "border-red-200 bg-red-50"
|
||||
: endpointAccessible === undefined
|
||||
? "border-slate-200 bg-white"
|
||||
: null
|
||||
)}
|
||||
placeholder="Paste the URL you want the event to trigger on"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={hittingEndpoint}
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
handleTestEndpoint(true);
|
||||
}}>
|
||||
Test Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Triggers">Triggers</Label>
|
||||
<TriggerCheckboxGroup
|
||||
triggers={triggers}
|
||||
selectedTriggers={selectedTriggers}
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Surveys</Label>
|
||||
<SurveyCheckboxGroup
|
||||
surveys={surveys}
|
||||
selectedSurveys={selectedSurveys}
|
||||
selectedAllSurveys={selectedAllSurveys}
|
||||
onSelectAllSurveys={handleSelectAllSurveys}
|
||||
onSelectedSurveyChange={handleSelectedSurveyChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
StartIcon={TrashIcon}
|
||||
className="mr-3">
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="https://formbricks.com/docs/webhook-api/overview"
|
||||
target="_blank">
|
||||
Read Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" variant="darkCTA" loading={isUpdatingWebhook}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DeleteDialog
|
||||
open={openDeleteDialog}
|
||||
setOpen={setOpenDeleteDialog}
|
||||
deleteWhat={"Webhook"}
|
||||
text="Are you sure you want to delete this Webhook? This will stop sending you any further notifications."
|
||||
onDelete={async () => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await deleteWebhook(webhook.id);
|
||||
router.refresh();
|
||||
toast.success("Webhook deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import AddWebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/AddWebhookModal";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import WebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal";
|
||||
import { Webhook } from "lucide-react";
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
|
||||
export default function WebhookTable({
|
||||
environmentId,
|
||||
webhooks,
|
||||
surveys,
|
||||
children: [TableHeading, webhookRows],
|
||||
}: {
|
||||
environmentId: string;
|
||||
webhooks: TWebhook[];
|
||||
surveys: TSurvey[];
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
}) {
|
||||
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
|
||||
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
|
||||
|
||||
const [activeWebhook, setActiveWebhook] = useState<TWebhook>({
|
||||
environmentId,
|
||||
id: "",
|
||||
name: "",
|
||||
url: "",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const handleOpenWebhookDetailModalClick = (e, webhook: TWebhook) => {
|
||||
e.preventDefault();
|
||||
setActiveWebhook(webhook);
|
||||
setWebhookDetailModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
setAddWebhookModalOpen(true);
|
||||
}}>
|
||||
<Webhook className="mr-2 h-5 w-5 text-white" />
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{webhooks.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environmentId={environmentId}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage="Your webhooks will appear here as soon as you add them. ⏲️"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
{TableHeading}
|
||||
<div className="grid-cols-7">
|
||||
{webhooks.map((webhook, index) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
handleOpenWebhookDetailModalClick(e, webhook);
|
||||
}}
|
||||
className="w-full"
|
||||
key={webhook.id}>
|
||||
{webhookRows[index]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WebhookModal
|
||||
environmentId={environmentId}
|
||||
open={isWebhookDetailModalOpen}
|
||||
setOpen={setWebhookDetailModalOpen}
|
||||
webhook={activeWebhook}
|
||||
surveys={surveys}
|
||||
/>
|
||||
<AddWebhookModal
|
||||
environmentId={environmentId}
|
||||
surveys={surveys}
|
||||
open={isAddWebhookModalOpen}
|
||||
setOpen={setAddWebhookModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function WebhookTableHeading() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">Edit</span>
|
||||
<div className="col-span-4 pl-6 ">Webhook</div>
|
||||
<div className="col-span-4 text-center">Surveys</div>
|
||||
<div className="col-span-2 text-center ">Triggers</div>
|
||||
<div className="col-span-2 text-center">Updated</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { Webhook } from "lucide-react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-gray-200">
|
||||
<Webhook className="mr-2 h-5 w-5 text-white" />
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">Edit</span>
|
||||
<div className="col-span-4 pl-6 ">Webhook</div>
|
||||
<div className="col-span-4 text-center">Surveys</div>
|
||||
<div className="col-span-2 text-center ">Triggers</div>
|
||||
<div className="col-span-2 text-center">Updated</div>
|
||||
</div>
|
||||
<div className="grid-cols-7">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">
|
||||
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 my-auto flex items-center justify-center text-center text-sm text-slate-500">
|
||||
<div className="font-medium text-slate-500">
|
||||
<div className="mt-0 h-4 w-36 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm text-slate-500">
|
||||
<div className="font-medium text-slate-500">
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import WebhookRowData from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData";
|
||||
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
|
||||
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getWebhooks } from "@formbricks/lib/services/webhook";
|
||||
|
||||
export default async function CustomWebhookPage({ params }) {
|
||||
const webhooks = (await getWebhooks(params.environmentId)).sort((a, b) => {
|
||||
if (a.createdAt > b.createdAt) return -1;
|
||||
if (a.createdAt < b.createdAt) return 1;
|
||||
return 0;
|
||||
});
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<WebhookTable environmentId={params.environmentId} webhooks={webhooks} surveys={surveys}>
|
||||
<WebhookTableHeading />
|
||||
{webhooks.map((webhook) => (
|
||||
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
|
||||
))}
|
||||
</WebhookTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
export const testEndpoint = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
formbricks: "test endpoint",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const statusCode = response.status;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return true;
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
throw new Error(`Request failed with status code ${statusCode}: ${errorMessage}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error while fetching the URL: ${error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
|
||||
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: TActivityFeedItem[];
|
||||
sortByDate: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export default function ActivityFeed({ activities, sortByDate, environmentId }: ActivityFeedProps) {
|
||||
const sortedActivities: TActivityFeedItem[] = activities.sort((a, b) =>
|
||||
sortByDate
|
||||
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{sortedActivities.length === 0 ? (
|
||||
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
|
||||
) : (
|
||||
<div>
|
||||
{sortedActivities.map((activityItem) => (
|
||||
<li key={activityItem.id} className="list-none">
|
||||
<div className="relative pb-12">
|
||||
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
|
||||
<div className="relative">
|
||||
<ActivityItemPopover activityItem={activityItem}>
|
||||
<div className="flex space-x-3 text-left">
|
||||
<ActivityItemIcon activityItem={activityItem} />
|
||||
<ActivityItemContent activityItem={activityItem} />
|
||||
</div>
|
||||
</ActivityItemPopover>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
|
||||
import { Label, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
|
||||
import {
|
||||
CodeBracketIcon,
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
SparklesIcon,
|
||||
TagIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { ActivityFeedItem } from "./ActivityFeed"; // Import the ActivityFeedItem type from the main file
|
||||
import { formatDistance } from "date-fns";
|
||||
|
||||
export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
|
||||
export const ActivityItemIcon = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
|
||||
<div className="h-12 w-12 rounded-full bg-white p-3 text-slate-500 duration-100 ease-in-out group-hover:scale-110 group-hover:text-slate-600">
|
||||
{activityItem.type === "attribute" ? (
|
||||
<TagIcon />
|
||||
@@ -19,9 +19,9 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
|
||||
<EyeIcon />
|
||||
) : activityItem.type === "event" ? (
|
||||
<div>
|
||||
{activityItem.eventType === "code" && <CodeBracketIcon />}
|
||||
{activityItem.eventType === "noCode" && <CursorArrowRaysIcon />}
|
||||
{activityItem.eventType === "automatic" && <SparklesIcon />}
|
||||
{activityItem.actionType === "code" && <CodeBracketIcon />}
|
||||
{activityItem.actionType === "noCode" && <CursorArrowRaysIcon />}
|
||||
{activityItem.actionType === "automatic" && <SparklesIcon />}
|
||||
</div>
|
||||
) : (
|
||||
<QuestionMarkCircleIcon />
|
||||
@@ -29,7 +29,7 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
|
||||
export const ActivityItemContent = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
|
||||
<div>
|
||||
<div className="font-semibold text-slate-700">
|
||||
{activityItem.type === "attribute" ? (
|
||||
@@ -37,40 +37,36 @@ export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFe
|
||||
) : activityItem.type === "display" ? (
|
||||
<p>Seen survey</p>
|
||||
) : activityItem.type === "event" ? (
|
||||
<p>{activityItem.eventLabel} triggered</p>
|
||||
<p>{activityItem.actionLabel} triggered</p>
|
||||
) : (
|
||||
<p>Unknown Activity</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
<time dateTime={timeSince(activityItem.createdAt)}>{timeSince(activityItem.createdAt)}</time>
|
||||
<time
|
||||
dateTime={formatDistance(activityItem.createdAt, new Date(), {
|
||||
addSuffix: true,
|
||||
})}>
|
||||
{formatDistance(activityItem.createdAt, new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ActivityItemPopover = ({
|
||||
activityItem,
|
||||
responses,
|
||||
children,
|
||||
}: {
|
||||
activityItem: ActivityFeedItem;
|
||||
responses: any[];
|
||||
activityItem: TActivityFeedItem;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
function findMatchingSurveyName(responses, surveyId) {
|
||||
for (const response of responses) {
|
||||
if (response.survey.id === surveyId) {
|
||||
return response.survey.name;
|
||||
}
|
||||
return null; // Return null if no match is found
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger className="group">{children}</PopoverTrigger>
|
||||
<PopoverContent className="bg-white">
|
||||
<div className="">
|
||||
<div>
|
||||
{activityItem.type === "attribute" ? (
|
||||
<div>
|
||||
<Label className="font-normal text-slate-400">Attribute Label</Label>
|
||||
@@ -81,26 +77,24 @@ export const ActivityItemPopover = ({
|
||||
) : activityItem.type === "display" ? (
|
||||
<div>
|
||||
<Label className="font-normal text-slate-400">Survey Name</Label>
|
||||
<p className=" mb-2 text-sm font-medium text-slate-900">
|
||||
{findMatchingSurveyName(responses, activityItem.displaySurveyId)}
|
||||
</p>
|
||||
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.displaySurveyName}</p>
|
||||
</div>
|
||||
) : activityItem.type === "event" ? (
|
||||
<div>
|
||||
<div>
|
||||
<Label className="font-normal text-slate-400">Event Display Name</Label>
|
||||
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.eventLabel}</p>{" "}
|
||||
<Label className="font-normal text-slate-400">Event Description</Label>
|
||||
<Label className="font-normal text-slate-400">Action Display Name</Label>
|
||||
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.actionLabel}</p>{" "}
|
||||
<Label className="font-normal text-slate-400">Action Description</Label>
|
||||
<p className=" mb-2 text-sm font-medium text-slate-900">
|
||||
{activityItem.eventDescription ? (
|
||||
<span>{activityItem.eventDescription}</span>
|
||||
{activityItem.actionDescription ? (
|
||||
<span>{activityItem.actionDescription}</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</p>
|
||||
<Label className="font-normal text-slate-400">Event Type</Label>
|
||||
<Label className="font-normal text-slate-400">Action Type</Label>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{capitalizeFirstLetter(activityItem.eventType)}
|
||||
{capitalizeFirstLetter(activityItem.actionType)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityTimeline";
|
||||
import { getActivityTimeline } from "@formbricks/lib/services/activity";
|
||||
|
||||
export default async function ActivitySection({
|
||||
environmentId,
|
||||
personId,
|
||||
}: {
|
||||
environmentId: string;
|
||||
personId: string;
|
||||
}) {
|
||||
const activities = await getActivityTimeline(personId);
|
||||
|
||||
return (
|
||||
<div className="md:col-span-1">
|
||||
<ActivityTimeline environmentId={environmentId} activities={activities} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import ActivityFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityFeed";
|
||||
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
|
||||
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ActivityTimeline({
|
||||
environmentId,
|
||||
activities,
|
||||
}: {
|
||||
environmentId: string;
|
||||
activities: TActivityFeedItem[];
|
||||
}) {
|
||||
const [activityAscending, setActivityAscending] = useState(true);
|
||||
const toggleSortActivity = () => {
|
||||
setActivityAscending(!activityAscending);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={toggleSortActivity}
|
||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityFeed activities={activities} sortByDate={activityAscending} environmentId={environmentId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { getPerson } from "@formbricks/lib/services/person";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
|
||||
import { getSessionCount } from "@formbricks/lib/services/session";
|
||||
|
||||
export default async function AttributesSection({ personId }: { personId: string }) {
|
||||
const person = await getPerson(personId);
|
||||
if (!person) {
|
||||
throw new Error("No such person found");
|
||||
}
|
||||
const numberOfSessions = await getSessionCount(personId);
|
||||
const responses = await getResponsesByPersonId(personId);
|
||||
|
||||
const numberOfResponses = responses?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Email</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{person.attributes.email ? (
|
||||
<span>{person.attributes.email}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">Not provided</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">User Id</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{person.attributes.userId ? (
|
||||
<span>{person.attributes.userId}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">Not provided</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
|
||||
</div>
|
||||
|
||||
{Object.entries(person.attributes)
|
||||
.filter(([key, _]) => key !== "email" && key !== "userId")
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Responses</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{numberOfResponses}</dd>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
export default async function ResponseSection({
|
||||
environmentId,
|
||||
personId,
|
||||
}: {
|
||||
environmentId: string;
|
||||
personId: string;
|
||||
}) {
|
||||
const responses = await getResponsesByPersonId(personId);
|
||||
const surveyIds = responses?.map((response) => response.surveyId) || [];
|
||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environmentId)) ?? [];
|
||||
const responsesWithSurvey: TResponseWithSurvey[] =
|
||||
responses?.reduce((acc: TResponseWithSurvey[], response) => {
|
||||
const thisSurvey = surveys.find((survey) => survey?.id === response.surveyId);
|
||||
if (thisSurvey) {
|
||||
acc.push({
|
||||
...response,
|
||||
survey: thisSurvey,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []) || [];
|
||||
|
||||
return <ResponseTimeline environmentId={environmentId} responses={responsesWithSurvey} />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import ResponseFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ResponseTimeline({
|
||||
environmentId,
|
||||
responses,
|
||||
}: {
|
||||
environmentId: string;
|
||||
responses: TResponseWithSurvey[];
|
||||
}) {
|
||||
const [responsesAscending, setResponsesAscending] = useState(true);
|
||||
const toggleSortResponses = () => {
|
||||
setResponsesAscending(!responsesAscending);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={toggleSortResponses}
|
||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ResponseFeed responses={responses} sortByDate={responsesAscending} environmentId={environmentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,35 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ResponseFeed({ person, sortByDate, environmentId }) {
|
||||
export default function ResponseFeed({
|
||||
responses,
|
||||
sortByDate,
|
||||
environmentId,
|
||||
}: {
|
||||
responses: TResponseWithSurvey[];
|
||||
sortByDate: boolean;
|
||||
environmentId: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{person.responses.length === 0 ? (
|
||||
{responses.length === 0 ? (
|
||||
<EmptySpaceFiller type="response" environmentId={environmentId} />
|
||||
) : (
|
||||
<div>
|
||||
{person.responses
|
||||
{responses
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
sortByDate
|
||||
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
)
|
||||
.map((response, responseIdx) => (
|
||||
<li key={response.createdAt} className="list-none">
|
||||
.map((response: TResponseWithSurvey, responseIdx) => (
|
||||
<li key={response.id} className="list-none">
|
||||
<div className="relative pb-8">
|
||||
{responseIdx !== person.responses.length - 1 ? (
|
||||
{responseIdx !== responses.length - 1 ? (
|
||||
<span
|
||||
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-slate-200"
|
||||
aria-hidden="true"
|
||||
@@ -31,8 +40,14 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="text-sm text-slate-400">
|
||||
<time className="text-slate-700" dateTime={timeSince(response.createdAt)}>
|
||||
{timeSince(response.createdAt)}
|
||||
<time
|
||||
className="text-slate-700"
|
||||
dateTime={formatDistance(response.createdAt, new Date(), {
|
||||
addSuffix: true,
|
||||
})}>
|
||||
{formatDistance(response.createdAt, new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-50 px-3 py-1 text-sm text-slate-600">
|
||||
@@ -52,8 +67,8 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
|
||||
<div key={question.id}>
|
||||
<p className="text-sm text-slate-500">{question.headline}</p>
|
||||
<p className="ph-no-capture my-1 text-lg font-semibold text-slate-700">
|
||||
{response.data[question.id] instanceof Array
|
||||
? response.data[question.id].join(", ")
|
||||
{Array.isArray(response.data[question.id])
|
||||
? (response.data[question.id] as string[]).join(", ")
|
||||
: response.data[question.id]}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,114 +0,0 @@
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import { useMemo } from "react";
|
||||
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
|
||||
|
||||
interface ActivityFeedProps {
|
||||
sessions: any[];
|
||||
attributes: any[];
|
||||
displays: any[];
|
||||
responses: any[];
|
||||
sortByDate: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export type ActivityFeedItem = {
|
||||
id: string;
|
||||
type: "event" | "attribute" | "display";
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
attributeLabel?: string;
|
||||
attributeValue?: string;
|
||||
displaySurveyId?: string;
|
||||
eventLabel?: string;
|
||||
eventDescription?: string;
|
||||
eventType?: string;
|
||||
};
|
||||
|
||||
export default function ActivityFeed({
|
||||
sessions,
|
||||
attributes,
|
||||
displays,
|
||||
responses,
|
||||
sortByDate,
|
||||
environmentId,
|
||||
}: ActivityFeedProps) {
|
||||
// Convert Attributes into unified format
|
||||
const unifiedAttributes = useMemo(() => {
|
||||
if (attributes) {
|
||||
return attributes.map((attribute) => ({
|
||||
id: attribute.id,
|
||||
type: "attribute",
|
||||
createdAt: attribute.createdAt,
|
||||
updatedAt: attribute.updatedAt,
|
||||
attributeLabel: attribute.attributeClass.name,
|
||||
attributeValue: attribute.value,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [attributes]);
|
||||
|
||||
// Convert Displays into unified format
|
||||
const unifiedDisplays = useMemo(() => {
|
||||
if (displays) {
|
||||
return displays.map((display) => ({
|
||||
id: display.id,
|
||||
type: "display",
|
||||
createdAt: display.createdAt,
|
||||
updatedAt: display.updatedAt,
|
||||
displaySurveyId: display.surveyId,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [displays]);
|
||||
|
||||
// Convert Events into unified format
|
||||
const unifiedEvents = useMemo(() => {
|
||||
if (sessions) {
|
||||
return sessions.flatMap((session) =>
|
||||
session.events.map((event) => ({
|
||||
id: event.id,
|
||||
type: "event",
|
||||
eventType: event.eventClass.type,
|
||||
createdAt: event.createdAt,
|
||||
eventLabel: event.eventClass.name,
|
||||
eventDescription: event.eventClass.description,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}, [sessions]);
|
||||
|
||||
const unifiedList = useMemo<ActivityFeedItem[]>(() => {
|
||||
return [...unifiedAttributes, ...unifiedDisplays, ...unifiedEvents].sort((a, b) =>
|
||||
sortByDate
|
||||
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
}, [unifiedAttributes, unifiedDisplays, unifiedEvents, sortByDate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{unifiedList.length === 0 ? (
|
||||
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
|
||||
) : (
|
||||
<div>
|
||||
{unifiedList.map((activityItem) => (
|
||||
<li key={activityItem.id} className="list-none">
|
||||
<div className="relative pb-12">
|
||||
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
|
||||
<div className="relative">
|
||||
<ActivityItemPopover activityItem={activityItem} responses={responses}>
|
||||
<div className="flex space-x-3 text-left">
|
||||
<ActivityItemIcon activityItem={activityItem} />
|
||||
<ActivityItemContent activityItem={activityItem} />
|
||||
</div>
|
||||
</ActivityItemPopover>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { deletePersonAction } from "./actions";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export default function HeadingSection({
|
||||
environmentId,
|
||||
person,
|
||||
}: {
|
||||
environmentId: string;
|
||||
person: TPerson;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const handleDeletePerson = async () => {
|
||||
await deletePersonAction(person.id);
|
||||
router.push(`/environments/${environmentId}/people`);
|
||||
toast.success("Person deleted successfully.");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
|
||||
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
|
||||
<span>{person.attributes.email || person.id}</span>
|
||||
</h1>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat="person"
|
||||
onDelete={handleDeletePerson}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { deletePerson, usePerson } from "@/lib/people/people";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ActivityFeed from "./ActivityFeed";
|
||||
import ResponseFeed from "./ResponsesFeed";
|
||||
|
||||
interface PersonDetailsProps {
|
||||
environmentId: string;
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export default function PersonDetails({ environmentId, personId }: PersonDetailsProps) {
|
||||
const router = useRouter();
|
||||
const { person, isLoadingPerson, isErrorPerson } = usePerson(environmentId, personId);
|
||||
|
||||
const [responsesAscending, setResponsesAscending] = useState(true);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activityAscending, setActivityAscending] = useState(true);
|
||||
|
||||
const personEmail = useMemo(
|
||||
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "email"),
|
||||
[person]
|
||||
);
|
||||
const personUserId = useMemo(
|
||||
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "userId"),
|
||||
[person]
|
||||
);
|
||||
|
||||
const otherAttributes = useMemo(
|
||||
() =>
|
||||
person?.attributes?.filter(
|
||||
(attribute) =>
|
||||
attribute.attributeClass.name !== "email" &&
|
||||
attribute.attributeClass.name !== "userId" &&
|
||||
!attribute.attributeClass.archived
|
||||
) as any[],
|
||||
[person]
|
||||
);
|
||||
|
||||
const toggleSortResponses = () => {
|
||||
setResponsesAscending(!responsesAscending);
|
||||
};
|
||||
|
||||
const handleDeletePerson = async () => {
|
||||
await deletePerson(environmentId, personId);
|
||||
router.push(`/environments/${environmentId}/people`);
|
||||
toast.success("Person deleted successfully.");
|
||||
};
|
||||
|
||||
const toggleSortActivity = () => {
|
||||
setActivityAscending(!activityAscending);
|
||||
};
|
||||
|
||||
if (isLoadingPerson) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorPerson) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
|
||||
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
|
||||
{personEmail ? <span>{personEmail.value}</span> : <span>{person.id}</span>}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Email</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{personEmail ? (
|
||||
<span>{personEmail?.value}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">Not provided</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">User Id</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{personUserId ? (
|
||||
<span>{personUserId?.value}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">Not provided</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{person.sessions.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Responses</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{person.responses.length}</dd>
|
||||
</div>
|
||||
{otherAttributes.map((attribute) => (
|
||||
<div key={attribute.attributeClass.name}>
|
||||
<dt className="text-sm font-medium text-slate-500">
|
||||
{capitalizeFirstLetter(attribute.attributeClass.name)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{attribute.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={toggleSortResponses}
|
||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponseFeed person={person} sortByDate={responsesAscending} environmentId={environmentId} />
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={toggleSortActivity}
|
||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityFeed
|
||||
sessions={person.sessions}
|
||||
attributes={person.attributes}
|
||||
displays={person.displays}
|
||||
responses={person.responses}
|
||||
sortByDate={activityAscending}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat="person"
|
||||
onDelete={handleDeletePerson}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { deletePerson } from "@formbricks/lib/services/person";
|
||||
|
||||
export const deletePersonAction = async (personId: string) => {
|
||||
await deletePerson(personId);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ActivityItemPopover,
|
||||
ActivityItemIcon,
|
||||
} from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityItemComponents";
|
||||
import { BackIcon } from "@formbricks/ui";
|
||||
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
|
||||
|
||||
export default function Loading() {
|
||||
const unifiedList: TActivityFeedItem[] = [
|
||||
{
|
||||
id: "clk9o7gnu000kz8kw4nb26o21",
|
||||
type: "event",
|
||||
actionType: "noCode",
|
||||
createdAt: new Date(),
|
||||
actionLabel: "Loading User Acitivity",
|
||||
updatedAt: null,
|
||||
attributeLabel: null,
|
||||
attributeValue: null,
|
||||
actionDescription: null,
|
||||
displaySurveyName: null,
|
||||
},
|
||||
{
|
||||
id: "clk9o7fwc000iz8kw4s0ha0ql",
|
||||
type: "event",
|
||||
actionType: "automatic",
|
||||
createdAt: new Date(),
|
||||
actionLabel: "Loading User Session Info",
|
||||
updatedAt: null,
|
||||
attributeLabel: null,
|
||||
attributeValue: null,
|
||||
actionDescription: null,
|
||||
displaySurveyName: null,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<main className="mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="pointer-events-none animate-pulse cursor-not-allowed select-none">
|
||||
<button className="inline-flex pt-5 text-sm text-slate-500">
|
||||
<BackIcon className="mr-2 h-5 w-5" />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
|
||||
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
|
||||
<span className="animate-pulse rounded-full">Fetching user</span>
|
||||
</h1>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button className="pointer-events-none animate-pulse cursor-not-allowed select-none">
|
||||
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Email</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
<span className="animate-pulse text-slate-300">Loading</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">User Id</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
<span className="animate-pulse text-slate-300">Loading</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
|
||||
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
|
||||
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">Responses</dt>
|
||||
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
|
||||
<div className="text-right">
|
||||
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
|
||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group space-y-4 rounded-lg bg-white p-6 ">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-12 w-12 flex-shrink-0 rounded-full bg-slate-100"></div>
|
||||
<div className=" h-6 w-full rounded-full bg-slate-100"></div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-12 w-full rounded-full bg-slate-100"></div>
|
||||
<div className=" flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
|
||||
<span className="animate-pulse text-center">Loading user responses</span>
|
||||
</div>
|
||||
<div className="h-12 w-full rounded-full bg-slate-50/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
|
||||
<div className="text-right">
|
||||
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
|
||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{unifiedList.map((activityItem) => (
|
||||
<li key={activityItem.id} className="list-none">
|
||||
<div className="relative pb-12">
|
||||
<span
|
||||
className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative animate-pulse cursor-not-allowed select-none">
|
||||
<ActivityItemPopover activityItem={activityItem}>
|
||||
<div className="flex cursor-not-allowed select-none items-center space-x-3">
|
||||
<ActivityItemIcon activityItem={activityItem} />
|
||||
<div className="font-semibold text-slate-700">Loading</div>
|
||||
</div>
|
||||
</ActivityItemPopover>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,31 @@
|
||||
import PersonDetails from "./PersonDetails";
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getPerson } from "@formbricks/lib/services/person";
|
||||
import AttributesSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(attributeSection)/AttributesSection";
|
||||
import ActivitySection from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivitySection";
|
||||
import HeadingSection from "@/app/(app)/environments/[environmentId]/people/[personId]/HeadingSection";
|
||||
import ResponseSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection";
|
||||
|
||||
export default async function PersonPage({ params }) {
|
||||
const person = await getPerson(params.personId);
|
||||
if (!person) {
|
||||
throw new Error("No such person found");
|
||||
}
|
||||
|
||||
export default function PersonPage({ params }) {
|
||||
return (
|
||||
<div>
|
||||
<main className="mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<PersonDetails personId={params.personId} environmentId={params.environmentId} />
|
||||
<>
|
||||
<HeadingSection environmentId={params.environmentId} person={person} />
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
|
||||
<AttributesSection personId={params.personId} />
|
||||
<ResponseSection environmentId={params.environmentId} personId={params.personId} />
|
||||
<ActivitySection environmentId={params.environmentId} personId={params.personId} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
|
||||
export function EditBrandColor({ environmentId }) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
@@ -180,6 +181,111 @@ export function EditPlacement({ environmentId }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const EditHighlightBorder: React.FC<{ environmentId: string }> = ({ environmentId }) => {
|
||||
const { product, isLoadingProduct, isErrorProduct, mutateProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [showHighlightBorder, setShowHighlightBorder] = useState(false);
|
||||
const [color, setColor] = useState<string | null>(DEFAULT_BRAND_COLOR);
|
||||
|
||||
// Sync product state with local state
|
||||
// not a good pattern, we should find a better way to do this
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setShowHighlightBorder(product.highlightBorderColor ? true : false);
|
||||
setColor(product.highlightBorderColor);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const handleSave = () => {
|
||||
triggerProductMutate(
|
||||
{ highlightBorderColor: color },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Settings updated successfully.");
|
||||
// refetch product to update data
|
||||
mutateProduct();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Something went wrong!");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSwitch = (checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!color) {
|
||||
setColor(DEFAULT_BRAND_COLOR);
|
||||
setShowHighlightBorder(true);
|
||||
} else {
|
||||
setShowHighlightBorder(true);
|
||||
}
|
||||
} else {
|
||||
setShowHighlightBorder(false);
|
||||
setColor(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorProduct) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full">
|
||||
<div className="flex w-1/2 flex-col px-6 py-5">
|
||||
<div className="mb-6 flex items-center space-x-2">
|
||||
<Switch id="highlightBorder" checked={showHighlightBorder} onCheckedChange={handleSwitch} />
|
||||
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
|
||||
</div>
|
||||
|
||||
{showHighlightBorder && color ? (
|
||||
<>
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4 flex max-w-[80px] items-center justify-center"
|
||||
loading={isMutatingProduct}
|
||||
onClick={() => {
|
||||
handleSave();
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
|
||||
<h3 className="text-slate-500">Preview</h3>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}
|
||||
{...(showHighlightBorder &&
|
||||
color && {
|
||||
style: {
|
||||
borderColor: color,
|
||||
},
|
||||
})}>
|
||||
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
|
||||
<div className="flex rounded-2xl border border-slate-400">
|
||||
{[1, 2, 3, 4, 5].map((num) => (
|
||||
<div className="border-r border-slate-400 px-6 py-5 last:border-r-0">
|
||||
<span className="text-sm font-medium">{num}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function EditFormbricksSignature({ environmentId }) {
|
||||
const { isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import { EditBrandColor, EditPlacement, EditFormbricksSignature } from "./editLookAndFeel";
|
||||
import {
|
||||
EditBrandColor,
|
||||
EditPlacement,
|
||||
EditFormbricksSignature,
|
||||
EditHighlightBorder,
|
||||
} from "./editLookAndFeel";
|
||||
|
||||
export default function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
return (
|
||||
@@ -14,6 +19,12 @@ export default function ProfileSettingsPage({ params }: { params: { environmentI
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<EditPlacement environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
noPadding
|
||||
title="Highlight Border"
|
||||
description="Make sure your users notice the survey you display">
|
||||
<EditHighlightBorder environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useState, Dispatch, SetStateAction } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMembers } from "@/lib/members";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { Button, ErrorComponent, Input } from "@formbricks/ui";
|
||||
import { useTeam, deleteTeam } from "@/lib/teams/teams";
|
||||
import { useMemberships } from "@/lib/memberships";
|
||||
|
||||
export default function DeleteTeam({ environmentId }) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { profile } = useProfile();
|
||||
const { memberships } = useMemberships();
|
||||
const { team } = useMembers(environmentId);
|
||||
const { team: teamData, isLoadingTeam, isErrorTeam } = useTeam(environmentId);
|
||||
|
||||
const availableTeams = memberships?.length;
|
||||
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
|
||||
const isUserOwner = role === "owner";
|
||||
const isDeleteDisabled = availableTeams <= 1 || !isUserOwner;
|
||||
|
||||
if (isLoadingTeam) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorTeam) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
const handleDeleteTeam = async () => {
|
||||
setIsDeleting(true);
|
||||
const deleteTeamRes = await deleteTeam(environmentId);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setIsDeleting(false);
|
||||
|
||||
if (deleteTeamRes?.deletedTeam?.id?.length > 0) {
|
||||
toast.success("Team deleted successfully.");
|
||||
router.push("/");
|
||||
} else if (deleteTeamRes?.message?.length > 0) {
|
||||
toast.error(deleteTeamRes.message);
|
||||
} else {
|
||||
toast.error("Error deleting team. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isDeleteDisabled && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-900">
|
||||
This action cannot be undone. If it's gone, it's gone.
|
||||
</p>
|
||||
<Button
|
||||
disabled={isDeleteDisabled}
|
||||
variant="warn"
|
||||
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
|
||||
onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isDeleteDisabled && (
|
||||
<p className="text-sm text-red-700">
|
||||
{!isUserOwner
|
||||
? "Only Owner can delete the team."
|
||||
: "This is your only team, it cannot be deleted. Create a new team first."}
|
||||
</p>
|
||||
)}
|
||||
<DeleteTeamModal
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
teamData={teamData}
|
||||
deleteTeam={handleDeleteTeam}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteTeamModalProps {
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
teamData: { name: string; id: string; plan: string };
|
||||
deleteTeam: () => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
function DeleteTeamModal({ setOpen, open, teamData, deleteTeam, isDeleting }: DeleteTeamModalProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
deleteWhat="team"
|
||||
onDelete={deleteTeam}
|
||||
text="Before you proceed with deleting this team, please be aware of the following consequences:"
|
||||
disabled={inputValue !== teamData?.name}
|
||||
isDeleting={isDeleting}>
|
||||
<div className="py-5">
|
||||
<ul className="list-disc pb-6 pl-6">
|
||||
<li>
|
||||
Permanent removal of all <b>products linked to this team</b>. This includes all surveys,
|
||||
responses, user actions and attributes associated with these products.
|
||||
</li>
|
||||
<li>This action cannot be undone. If it's gone, it's gone.</li>
|
||||
</ul>
|
||||
<form>
|
||||
<label htmlFor="deleteTeamConfirmation">
|
||||
Please enter <b>{teamData?.name}</b> in the following field to confirm the definitive deletion of
|
||||
this team:
|
||||
</label>
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
placeholder={teamData?.name}
|
||||
className="mt-5"
|
||||
type="text"
|
||||
id="deleteTeamConfirmation"
|
||||
name="deleteTeamConfirmation"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</DeleteDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/ShareInviteModal";
|
||||
import TransferOwnershipModal from "@/app/(app)/environments/[environmentId]/settings/members/TransferOwnershipModal";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
removeMember,
|
||||
resendInvite,
|
||||
shareInvite,
|
||||
transferOwnership,
|
||||
updateInviteeRole,
|
||||
updateMemberRole,
|
||||
useMembers,
|
||||
@@ -36,6 +38,9 @@ import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/out
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import AddMemberModal from "./AddMemberModal";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemberships } from "@/lib/memberships";
|
||||
import CustomDialog from "@/components/shared/CustomDialog";
|
||||
|
||||
type EditMembershipsProps = {
|
||||
environmentId: string;
|
||||
@@ -46,13 +51,16 @@ interface Role {
|
||||
memberRole: MembershipRole;
|
||||
teamId: string;
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
memberAccepted: boolean;
|
||||
inviteId: string;
|
||||
currentUserRole: string;
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
Owner = "owner",
|
||||
Admin = "admin",
|
||||
Editor = "editor",
|
||||
Developer = "developer",
|
||||
@@ -64,13 +72,16 @@ function RoleElement({
|
||||
memberRole,
|
||||
teamId,
|
||||
memberId,
|
||||
memberName,
|
||||
environmentId,
|
||||
userId,
|
||||
memberAccepted,
|
||||
inviteId,
|
||||
currentUserRole,
|
||||
}: Role) {
|
||||
const { mutateTeam } = useMembers(environmentId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false);
|
||||
const disableRole =
|
||||
memberRole && memberId && userId
|
||||
? memberRole === ("owner" as MembershipRole) || memberId === userId
|
||||
@@ -87,34 +98,71 @@ function RoleElement({
|
||||
mutateTeam();
|
||||
};
|
||||
|
||||
const handleOwnershipTransfer = async () => {
|
||||
setLoading(true);
|
||||
const isTransfered = await transferOwnership(teamId, memberId);
|
||||
if (isTransfered) {
|
||||
toast.success("Ownership transferred successfully");
|
||||
} else {
|
||||
toast.error("Something went wrong");
|
||||
}
|
||||
setTransferOwnershipModalOpen(false);
|
||||
setLoading(false);
|
||||
mutateTeam();
|
||||
};
|
||||
|
||||
const handleRoleChange = (role: string) => {
|
||||
if (role === "owner") {
|
||||
setTransferOwnershipModalOpen(true);
|
||||
} else {
|
||||
handleMemberRoleUpdate(role);
|
||||
}
|
||||
};
|
||||
|
||||
const getMembershipRoles = () => {
|
||||
if (currentUserRole === "owner" && memberAccepted) {
|
||||
return Object.keys(MembershipRole);
|
||||
}
|
||||
return Object.keys(MembershipRole).filter((role) => role !== "Owner");
|
||||
};
|
||||
|
||||
if (isAdminOrOwner) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={disableRole}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 p-1.5 text-xs"
|
||||
loading={loading}
|
||||
size="sm">
|
||||
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{!disableRole && (
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={capitalizeFirstLetter(memberRole)}
|
||||
onValueChange={(value) => handleMemberRoleUpdate(value.toLowerCase())}>
|
||||
{Object.keys(MembershipRole).map((role) => (
|
||||
<DropdownMenuRadioItem key={role} value={role}>
|
||||
{capitalizeFirstLetter(role)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={disableRole}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 p-1.5 text-xs"
|
||||
loading={loading}
|
||||
size="sm">
|
||||
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{!disableRole && (
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={capitalizeFirstLetter(memberRole)}
|
||||
onValueChange={(value) => handleRoleChange(value.toLowerCase())}>
|
||||
{getMembershipRoles().map((role) => (
|
||||
<DropdownMenuRadioItem key={role} value={role}>
|
||||
{capitalizeFirstLetter(role)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
<TransferOwnershipModal
|
||||
open={isTransferOwnershipModalOpen}
|
||||
setOpen={setTransferOwnershipModalOpen}
|
||||
memberName={memberName}
|
||||
onSubmit={handleOwnershipTransfer}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,18 +172,26 @@ function RoleElement({
|
||||
export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
const { team, isErrorTeam, isLoadingTeam, mutateTeam } = useMembers(environmentId);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
|
||||
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
|
||||
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
|
||||
const [isLeaveTeamModalOpen, setLeaveTeamModalOpen] = useState(false);
|
||||
const [shareInviteToken, setShareInviteToken] = useState<string>("");
|
||||
|
||||
const [activeMember, setActiveMember] = useState({} as any);
|
||||
const { profile } = useProfile();
|
||||
const { memberships } = useMemberships();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
|
||||
const isAdminOrOwner = role === "admin" || role === "owner";
|
||||
|
||||
const availableTeams = memberships?.length;
|
||||
const isLeaveTeamDisabled = availableTeams <= 1;
|
||||
|
||||
const handleOpenDeleteMemberModal = (e, member) => {
|
||||
e.preventDefault();
|
||||
setActiveMember(member);
|
||||
@@ -194,9 +250,27 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
return now > expiresAt;
|
||||
};
|
||||
|
||||
const handleLeaveTeam = async () => {
|
||||
setLoading(true);
|
||||
const result = await removeMember(team.teamId, profile?.id);
|
||||
setLeaveTeamModalOpen(false);
|
||||
setLoading(false);
|
||||
if (!result) {
|
||||
toast.error("Something went wrong");
|
||||
} else {
|
||||
toast.success("You left the team successfully");
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 text-right">
|
||||
{role !== "owner" && (
|
||||
<Button variant="minimal" className="mr-2" onClick={() => setLeaveTeamModalOpen(true)}>
|
||||
Leave Team
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mr-2"
|
||||
@@ -242,11 +316,13 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
isAdminOrOwner={isAdminOrOwner}
|
||||
memberRole={member.role}
|
||||
memberId={member.userId}
|
||||
memberName={member.name}
|
||||
teamId={team.teamId}
|
||||
environmentId={environmentId}
|
||||
userId={profile?.id}
|
||||
memberAccepted={member.accepted}
|
||||
inviteId={member?.inviteId}
|
||||
currentUserRole={role}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
|
||||
@@ -310,6 +386,23 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
deleteWhat={activeMember.name + " from your team"}
|
||||
onDelete={handleDeleteMember}
|
||||
/>
|
||||
|
||||
<CustomDialog
|
||||
open={isLeaveTeamModalOpen}
|
||||
setOpen={setLeaveTeamModalOpen}
|
||||
title="Are you sure?"
|
||||
text="You wil leave this team and loose access to all surveys and responses. You can only rejoin if you are invited again."
|
||||
onOk={handleLeaveTeam}
|
||||
okBtnText="Yes, leave team"
|
||||
disabled={isLeaveTeamDisabled}
|
||||
isLoading={loading}>
|
||||
{isLeaveTeamDisabled && (
|
||||
<p className="mt-2 text-sm text-red-700">
|
||||
You cannot leave this team as it is your only team. Create a new team first.
|
||||
</p>
|
||||
)}
|
||||
</CustomDialog>
|
||||
|
||||
{showShareInviteModal && (
|
||||
<ShareInviteModal
|
||||
inviteToken={shareInviteToken}
|
||||
|
||||
@@ -5,18 +5,27 @@ import { useTeamMutation } from "@/lib/teams/mutateTeams";
|
||||
import { useTeam } from "@/lib/teams/teams";
|
||||
import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export default function EditTeamName({ environmentId }) {
|
||||
const { team, isLoadingTeam, isErrorTeam, mutateTeam } = useTeam(environmentId);
|
||||
const { register, handleSubmit } = useForm();
|
||||
const { register, control, handleSubmit, setValue } = useForm();
|
||||
const [teamId, setTeamId] = useState("");
|
||||
|
||||
const teamName = useWatch({
|
||||
control,
|
||||
name: "name",
|
||||
});
|
||||
const isTeamNameInputEmpty = !teamName?.trim();
|
||||
const currentTeamName = teamName?.trim().toLowerCase() ?? "";
|
||||
const previousTeamName = team?.name?.trim().toLowerCase() ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (team && team.id !== "") {
|
||||
setTeamId(team.id);
|
||||
}
|
||||
setValue("name", team?.name ?? "");
|
||||
}, [team]);
|
||||
|
||||
const { isMutatingTeam, triggerTeamMutate } = useTeamMutation(teamId);
|
||||
@@ -42,9 +51,20 @@ export default function EditTeamName({ environmentId }) {
|
||||
});
|
||||
})}>
|
||||
<Label htmlFor="teamname">Team Name</Label>
|
||||
<Input type="text" id="teamname" defaultValue={team.name} {...register("name")} />
|
||||
<Input
|
||||
type="text"
|
||||
id="teamname"
|
||||
defaultValue={team?.name ?? ""}
|
||||
{...register("name")}
|
||||
className={isTeamNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="mt-4" variant="darkCTA" loading={isMutatingTeam}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
variant="darkCTA"
|
||||
loading={isMutatingTeam}
|
||||
disabled={isTeamNameInputEmpty || currentTeamName === previousTeamName}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import CustomDialog from "@/components/shared/CustomDialog";
|
||||
import { Input } from "@formbricks/ui";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
interface TransferOwnershipModalProps {
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
memberName: string;
|
||||
onSubmit: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function TransferOwnershipModal({
|
||||
setOpen,
|
||||
open,
|
||||
memberName,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: TransferOwnershipModalProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onOk={onSubmit}
|
||||
okBtnText="Transfer ownership"
|
||||
title="There can only be ONE owner! Are you sure?"
|
||||
cancelBtnText="CANCEL"
|
||||
disabled={inputValue !== memberName}
|
||||
isLoading={isLoading}>
|
||||
<div className="py-5">
|
||||
<ul className="list-disc pb-6 pl-6">
|
||||
<li>
|
||||
There can only be one owner of each team. If you transfer your ownership to <b>{memberName}</b>,
|
||||
you will lose all of your ownership rights.
|
||||
</li>
|
||||
<li>When you transfer the ownership, you will remain an Admin of the team.</li>
|
||||
</ul>
|
||||
<form>
|
||||
<label htmlFor="transferOwnershipConfirmation">
|
||||
Type in <b>{memberName}</b> to confirm:
|
||||
</label>
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
placeholder={memberName}
|
||||
className="mt-5"
|
||||
type="text"
|
||||
id="transferOwnershipConfirmation"
|
||||
name="transferOwnershipConfirmation"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</CustomDialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import { EditMemberships } from "./EditMemberships";
|
||||
import EditTeamName from "./EditTeamName";
|
||||
import DeleteTeam from "./DeleteTeam";
|
||||
|
||||
export default function MembersSettingsPage({ params }) {
|
||||
return (
|
||||
@@ -13,6 +14,11 @@ export default function MembersSettingsPage({ params }) {
|
||||
<SettingsCard title="Team Name" description="Give your team a descriptive name.">
|
||||
<EditTeamName environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete Team"
|
||||
description="Delete team with all its products including all surveys, responses, people, actions and attributes">
|
||||
<DeleteTeam environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -22,7 +22,19 @@ export function EditProductName({ environmentId }) {
|
||||
const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
|
||||
const { mutateEnvironment } = useEnvironment(environmentId);
|
||||
|
||||
const { register, handleSubmit } = useForm();
|
||||
const { register, handleSubmit, control, setValue } = useForm();
|
||||
|
||||
const productName = useWatch({
|
||||
control,
|
||||
name: "name",
|
||||
});
|
||||
const isProductNameInputEmpty = !productName?.trim();
|
||||
const currentProductName = productName?.trim().toLowerCase() ?? "";
|
||||
const previousProductName = product?.name?.trim().toLowerCase() ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
setValue("name", product?.name ?? "");
|
||||
}, [product?.name]);
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
@@ -45,9 +57,20 @@ export function EditProductName({ environmentId }) {
|
||||
});
|
||||
})}>
|
||||
<Label htmlFor="fullname">What's your product called?</Label>
|
||||
<Input type="text" id="fullname" defaultValue={product.name} {...register("name")} />
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={product.name}
|
||||
{...register("name")}
|
||||
className={isProductNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="darkCTA" className="mt-4" loading={isMutatingProduct}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProduct}
|
||||
disabled={isProductNameInputEmpty || currentProductName === previousProductName}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,58 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
|
||||
import { formbricksLogout } from "@/lib/formbricks";
|
||||
import { useProfileMutation } from "@/lib/profile/mutateProfile";
|
||||
import { useProfile } from "@/lib/profile/profile";
|
||||
import { deleteProfile } from "@/lib/users/users";
|
||||
import { Button, ErrorComponent, Input, Label, ProfileAvatar } from "@formbricks/ui";
|
||||
import { Button, Input, ProfileAvatar } from "@formbricks/ui";
|
||||
import { Session } from "next-auth";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export function EditName() {
|
||||
const { register, handleSubmit } = useForm();
|
||||
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
|
||||
|
||||
const { triggerProfileMutate, isMutatingProfile } = useProfileMutation();
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProfile) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={handleSubmit((data) => {
|
||||
triggerProfileMutate(data)
|
||||
.then(() => {
|
||||
toast.success("Your name was updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
})}>
|
||||
<Label htmlFor="fullname">Full Name</Label>
|
||||
<Input type="text" id="fullname" defaultValue={profile.name} {...register("name")} />
|
||||
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
|
||||
</div>
|
||||
<Button type="submit" variant="darkCTA" className="mt-4" loading={isMutatingProfile}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
import { profileDeleteAction } from "./actions";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
|
||||
export function EditAvatar({ session }) {
|
||||
return (
|
||||
@@ -76,13 +34,14 @@ export function EditAvatar({ session }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteAccounModaltProps {
|
||||
interface DeleteAccountModalProps {
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
session: Session;
|
||||
profile: TProfile;
|
||||
}
|
||||
|
||||
function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps) {
|
||||
function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountModalProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
@@ -93,7 +52,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps)
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
setDeleting(true);
|
||||
await deleteProfile();
|
||||
await profileDeleteAction(profile.id);
|
||||
await signOut();
|
||||
await formbricksLogout();
|
||||
} catch (error) {
|
||||
@@ -146,7 +105,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps)
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteAccount({ session }: { session: Session | null }) {
|
||||
export function DeleteAccount({ session, profile }: { session: Session | null; profile: TProfile }) {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
@@ -155,7 +114,7 @@ export function DeleteAccount({ session }: { session: Session | null }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} />
|
||||
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} profile={profile} />
|
||||
<p className="text-sm text-slate-700">
|
||||
Delete your account with all personal data. <strong>This cannot be undone!</strong>
|
||||
</p>
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
|
||||
import { Button, ProfileAvatar } from "@formbricks/ui";
|
||||
import Image from "next/image";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
export function EditAvatar({ session }:{session: Session | null}) {
|
||||
return (
|
||||
<div>
|
||||
{session?.user?.image ? (
|
||||
<Image
|
||||
src={AvatarPlaceholder}
|
||||
width="100"
|
||||
height="100"
|
||||
className="h-24 w-24 rounded-full"
|
||||
alt="Avatar placeholder"
|
||||
/>
|
||||
) : (
|
||||
<ProfileAvatar userId={session!.user.id} />
|
||||
)}
|
||||
|
||||
<Button className="mt-4" variant="darkCTA" disabled={true}>
|
||||
Upload Image
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { profileEditAction } from "./actions";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
|
||||
export function EditName({ profile }: { profile: TProfile }) {
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<{name:string}>()
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={handleSubmit(async(data) => {
|
||||
try {
|
||||
await profileEditAction(profile.id, data);
|
||||
toast.success("Your name was updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
})}>
|
||||
<Label htmlFor="fullname">Full Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={profile.name ? profile.name : ""}
|
||||
{...register("name")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
|
||||
</div>
|
||||
<Button type="submit" variant="darkCTA" className="mt-4" loading={isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { updateProfile, deleteProfile } from "@formbricks/lib/services/profile";
|
||||
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
|
||||
|
||||
export async function profileEditAction(userId: string, data: Partial<TProfileUpdateInput>) {
|
||||
return await updateProfile(userId, data);
|
||||
}
|
||||
|
||||
export async function profileDeleteAction(userId: string) {
|
||||
return await deleteProfile(userId);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
function LoadingCard({ title, description, skeletonLines }) {
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-gray-200 ${line.classes}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Loading() {
|
||||
const cards = [
|
||||
{
|
||||
title: "Personal Information",
|
||||
description: "Update your personal information",
|
||||
skeletonLines: [
|
||||
{ classes: "h-4 w-28" },
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-4 w-28" },
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-8 w-24" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Avatar",
|
||||
description: "Assist your team in identifying you on Formbricks.",
|
||||
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
{
|
||||
title: "Delete account",
|
||||
description: "Delete your account with all of your personal information and data.",
|
||||
skeletonLines: [{ classes: "h-4 w-60" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,37 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { EditName, EditAvatar, DeleteAccount } from "./editProfile";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { DeleteAccount } from "./DeleteAccount";
|
||||
import { EditName } from "./EditName";
|
||||
import { EditAvatar } from "./EditAvatar";
|
||||
import { getProfile } from "@formbricks/lib/services/profile";
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const profile = session ? await getProfile(session.user.id) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Profile" />
|
||||
<SettingsCard title="Personal Information" description="Update your personal information.">
|
||||
<EditName />
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
|
||||
<EditAvatar session={session} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete account"
|
||||
description="Delete your account with all of your personal information and data.">
|
||||
<DeleteAccount session={session} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
<>
|
||||
{profile && (
|
||||
<div>
|
||||
<SettingsTitle title="Profile" />
|
||||
<SettingsCard title="Personal Information" description="Update your personal information.">
|
||||
<EditName profile={profile} />
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
|
||||
<EditAvatar session={session} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete account"
|
||||
description="Delete your account with all of your personal information and data.">
|
||||
<DeleteAccount session={session} profile={profile} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ export default function PreviewSurvey({
|
||||
switch (logic.condition) {
|
||||
case "equals":
|
||||
return (
|
||||
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
|
||||
(Array.isArray(responseValue) &&
|
||||
responseValue.length === 1 &&
|
||||
responseValue.includes(logic.value)) ||
|
||||
responseValue.toString() === logic.value
|
||||
);
|
||||
case "notEquals":
|
||||
@@ -273,7 +275,10 @@ export default function PreviewSurvey({
|
||||
</div>
|
||||
|
||||
{previewType === "modal" ? (
|
||||
<Modal isOpen={isModalOpen} placement={product.placement}>
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
placement={product.placement}
|
||||
highlightBorderColor={product.highlightBorderColor}>
|
||||
{!countdownStop && autoClose !== null && autoClose > 0 && (
|
||||
<Progress progress={countdownProgress} brandColor={brandColor} />
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface LinkSurveyShareButtonProps {
|
||||
|
||||
export default function LinkSurveyShareButton({ survey, className }: LinkSurveyShareButtonProps) {
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import CodeBlock from "@/components/shared/CodeBlock";
|
||||
import Modal from "@/components/shared/Modal";
|
||||
// import Modal from "@/components/shared/Modal";
|
||||
import { Dialog, DialogContent } from "@formbricks/ui";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
@@ -40,8 +41,8 @@ top:0; width:100%; height:100%; border:0;">
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} blur={false}>
|
||||
<div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="bottom-0 max-w-sm bg-white p-4 sm:bottom-auto sm:max-w-xl sm:p-6">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
|
||||
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
|
||||
</div>
|
||||
@@ -50,24 +51,32 @@ top:0; width:100%; height:100%; border:0;">
|
||||
{showEmbed ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-500">Embed survey on your website:</p>
|
||||
<CodeBlock language="html">{iframeCode}</CodeBlock>
|
||||
<CodeBlock
|
||||
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
|
||||
language="html">
|
||||
{iframeCode}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-500">Share this link to let people answer your survey:</p>
|
||||
<p
|
||||
<div
|
||||
ref={linkTextRef}
|
||||
className="relative mt-3 w-full rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
|
||||
className="relative mt-3 max-w-full overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
|
||||
onClick={() => handleTextSelection()}>
|
||||
{`${window.location.protocol}//${window.location.host}/s/${survey.id}`}
|
||||
</p>
|
||||
<span
|
||||
style={{
|
||||
wordBreak: "break-all",
|
||||
}}>{`${window.location.protocol}//${window.location.host}/s/${survey.id}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<div className="mt-4 flex flex-col justify-center gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="Embed survey in your website"
|
||||
aria-label="Embed survey in your website"
|
||||
className="flex justify-center"
|
||||
onClick={() => {
|
||||
setShowEmbed(true);
|
||||
navigator.clipboard.writeText(iframeCode);
|
||||
@@ -87,6 +96,7 @@ top:0; width:100%; height:100%; border:0;">
|
||||
}}
|
||||
title="Copy survey link to clipboard"
|
||||
aria-label="Copy survey link to clipboard"
|
||||
className="flex justify-center"
|
||||
EndIcon={DocumentDuplicateIcon}>
|
||||
Copy URL
|
||||
</Button>
|
||||
@@ -94,6 +104,7 @@ top:0; width:100%; height:100%; border:0;">
|
||||
variant="darkCTA"
|
||||
title="Preview survey"
|
||||
aria-label="Preview survey"
|
||||
className="flex justify-center"
|
||||
href={`${window.location.protocol}//${window.location.host}/s/${survey.id}?preview=true`}
|
||||
target="_blank"
|
||||
EndIcon={EyeIcon}>
|
||||
@@ -101,7 +112,7 @@ top:0; width:100%; height:100%; border:0;">
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter";
|
||||
import SummaryHeader from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/SummaryHeader";
|
||||
import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/SurveyResultsTabs";
|
||||
|
||||
@@ -223,6 +223,15 @@ export default function MultipleChoiceMultiForm({
|
||||
Add "Other"
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateQuestion(questionIdx, { type: "multipleChoiceSingle" });
|
||||
}}>
|
||||
Convert to Single Select
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
<Select
|
||||
|
||||
@@ -223,6 +223,15 @@ export default function MultipleChoiceSingleForm({
|
||||
Add "Other"
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="minimal"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateQuestion(questionIdx, { type: "multipleChoiceMulti" });
|
||||
}}>
|
||||
Convert to Multi Select
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
<Select
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface ResponseOptionsCardProps {
|
||||
localSurvey: Survey;
|
||||
@@ -117,13 +118,27 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputResponse = (e: any) => {
|
||||
let value = parseInt(e.target.value);
|
||||
if (value < 1) value = 1;
|
||||
const updatedSurvey: Survey = { ...localSurvey, autoComplete: value };
|
||||
const handleInputResponse = (e) => {
|
||||
const updatedSurvey: Survey = { ...localSurvey, autoComplete: parseInt(e.target.value) };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const handleInputResponseBlur = (e) => {
|
||||
if (parseInt(e.target.value) === 0) {
|
||||
toast.error("Response limit can't be set to 0");
|
||||
return;
|
||||
}
|
||||
|
||||
const inputResponses = localSurvey?._count?.responses || 0;
|
||||
|
||||
if (parseInt(e.target.value) <= inputResponses) {
|
||||
toast.error(
|
||||
`Response limit needs to exceed number of received responses (${localSurvey?._count?.responses}).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -169,11 +184,16 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
|
||||
<Input
|
||||
autoFocus
|
||||
type="number"
|
||||
min="1"
|
||||
min={
|
||||
localSurvey?._count?.responses
|
||||
? (localSurvey?._count?.responses + 1).toString()
|
||||
: "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"
|
||||
onChange={handleInputResponse}
|
||||
onBlur={handleInputResponseBlur}
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
completed responses.
|
||||
</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
const [localSurvey, setLocalSurvey] = useState<Survey | null>();
|
||||
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId, true);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
|
||||
|
||||
@@ -106,6 +106,17 @@ export default function SurveyMenuBar({
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
Check whether the count for autocomplete responses is not less
|
||||
than the current count of accepted response and also it is not set to 0
|
||||
*/
|
||||
if (
|
||||
(survey.autoComplete && survey._count?.responses && survey._count.responses >= survey.autoComplete) ||
|
||||
survey?.autoComplete === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import PreviewSurvey from "../PreviewSurvey";
|
||||
import TemplateList from "./TemplateList";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { SearchBox } from "@formbricks/ui";
|
||||
|
||||
type TemplateContainerWithPreviewProps = {
|
||||
environmentId: string;
|
||||
@@ -23,7 +24,7 @@ export default function TemplateContainerWithPreview({
|
||||
}: TemplateContainerWithPreviewProps) {
|
||||
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
|
||||
const [templateSearch, setTemplateSearch] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (product && templates?.length) {
|
||||
const newTemplate = replacePresetPlaceholders(templates[0], product);
|
||||
@@ -36,11 +37,26 @@ export default function TemplateContainerWithPreview({
|
||||
<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>
|
||||
<div className="flex flex-col items-center justify-between md:flex-row md:items-start">
|
||||
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
|
||||
<div className="ml-6 mt-6 px-6">
|
||||
<SearchBox
|
||||
autoFocus
|
||||
value={templateSearch ?? ""}
|
||||
onChange={(e) => setTemplateSearch(e.target.value)}
|
||||
placeholder={"Search..."}
|
||||
className="block rounded-md border border-slate-100 bg-white shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm md:w-auto "
|
||||
type="search"
|
||||
name="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
templateSearch={templateSearch ?? ""}
|
||||
onTemplateClick={(template) => {
|
||||
setActiveQuestionId(template.preset.questions[0].id);
|
||||
setActiveTemplate(template);
|
||||
|
||||
@@ -28,11 +28,18 @@ type TemplateList = {
|
||||
onTemplateClick: (template: Template) => void;
|
||||
environment: TEnvironment;
|
||||
product: TProduct;
|
||||
templateSearch?: string;
|
||||
};
|
||||
|
||||
const ALL_CATEGORY_NAME = "All";
|
||||
const RECOMMENDED_CATEGORY_NAME = "For you";
|
||||
export default function TemplateList({ environmentId, onTemplateClick, product, environment }: TemplateList) {
|
||||
export default function TemplateList({
|
||||
environmentId,
|
||||
onTemplateClick,
|
||||
product,
|
||||
environment,
|
||||
templateSearch,
|
||||
}: TemplateList) {
|
||||
const router = useRouter();
|
||||
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -49,15 +56,18 @@ export default function TemplateList({ environmentId, onTemplateClick, product,
|
||||
|
||||
const fullCategories =
|
||||
!!profile?.objective && profile.objective !== "other"
|
||||
? [RECOMMENDED_CATEGORY_NAME, ...defaultCategories]
|
||||
? [RECOMMENDED_CATEGORY_NAME, ALL_CATEGORY_NAME, ...defaultCategories]
|
||||
: [ALL_CATEGORY_NAME, ...defaultCategories];
|
||||
|
||||
setCategories(fullCategories);
|
||||
|
||||
const activeFilter =
|
||||
!!profile?.objective && profile.objective !== "other" ? RECOMMENDED_CATEGORY_NAME : ALL_CATEGORY_NAME;
|
||||
const activeFilter = templateSearch
|
||||
? ALL_CATEGORY_NAME
|
||||
: !!profile?.objective && profile.objective !== "other"
|
||||
? RECOMMENDED_CATEGORY_NAME
|
||||
: ALL_CATEGORY_NAME;
|
||||
setSelectedFilter(activeFilter);
|
||||
}, [profile]);
|
||||
}, [profile, templateSearch]);
|
||||
|
||||
const addSurvey = async (activeTemplate) => {
|
||||
setLoading(true);
|
||||
@@ -72,19 +82,38 @@ export default function TemplateList({ environmentId, onTemplateClick, product,
|
||||
if (isLoadingProfile) return <LoadingSpinner />;
|
||||
if (isErrorProfile) return <ErrorComponent />;
|
||||
|
||||
const filteredTemplates = templates.filter((template) => {
|
||||
const matchesCategory =
|
||||
selectedFilter === ALL_CATEGORY_NAME ||
|
||||
template.category === selectedFilter ||
|
||||
(selectedFilter === RECOMMENDED_CATEGORY_NAME && template.objectives?.includes(profile.objective));
|
||||
|
||||
const templateName = template.name?.toLowerCase();
|
||||
const templateDescription = template.description?.toLowerCase();
|
||||
const searchQuery = templateSearch?.toLowerCase() ?? "";
|
||||
const searchWords = searchQuery.split(" ");
|
||||
|
||||
const matchesSearch = searchWords.every(
|
||||
(word) => templateName?.includes(word) || templateDescription?.includes(word)
|
||||
);
|
||||
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-3 focus:outline-none">
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
<div className="mb-6 flex flex-wrap gap-1">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setSelectedFilter(category)}
|
||||
disabled={templateSearch && templateSearch.length > 0 ? true : false}
|
||||
className={cn(
|
||||
selectedFilter === category
|
||||
? " bg-slate-800 font-semibold text-white"
|
||||
: " bg-white text-slate-700 hover:bg-slate-100",
|
||||
"mt-2 rounded border border-slate-800 px-2 py-1 text-sm transition-all duration-150 "
|
||||
"mt-2 rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150 "
|
||||
)}>
|
||||
{category}
|
||||
{category === RECOMMENDED_CATEGORY_NAME && <SparklesIcon className="ml-1 inline h-5 w-5" />}
|
||||
@@ -121,72 +150,62 @@ export default function TemplateList({ environmentId, onTemplateClick, product,
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{templates
|
||||
.filter(
|
||||
(template) =>
|
||||
selectedFilter === ALL_CATEGORY_NAME ||
|
||||
template.category === selectedFilter ||
|
||||
(selectedFilter === RECOMMENDED_CATEGORY_NAME &&
|
||||
template.objectives?.includes(profile.objective))
|
||||
)
|
||||
.map((template: Template) => (
|
||||
<div
|
||||
onClick={() => {
|
||||
const newTemplate = replacePresetPlaceholders(template, product);
|
||||
onTemplateClick(newTemplate);
|
||||
setActiveTemplate(newTemplate);
|
||||
}}
|
||||
key={template.name}
|
||||
className={cn(
|
||||
activeTemplate?.name === template.name && "ring-2 ring-slate-400",
|
||||
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105"
|
||||
)}>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`rounded border px-1.5 py-0.5 text-xs ${
|
||||
template.category === "Product Experience"
|
||||
? "border-blue-300 bg-blue-50 text-blue-500"
|
||||
: template.category === "Exploration"
|
||||
? "border-pink-300 bg-pink-50 text-pink-500"
|
||||
: template.category === "Growth"
|
||||
? "border-orange-300 bg-orange-50 text-orange-500"
|
||||
: template.category === "Increase Revenue"
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-500"
|
||||
: template.category === "Customer Success"
|
||||
? "border-violet-300 bg-violet-50 text-violet-500"
|
||||
: "border-slate-300 bg-slate-50 text-slate-500" // default color
|
||||
}`}>
|
||||
{template.category}
|
||||
</div>
|
||||
{template.preset.questions.some(
|
||||
(question) => question.logic && question.logic.length > 0
|
||||
) && (
|
||||
<TooltipProvider delayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div>
|
||||
<SplitIcon className="ml-1.5 h-5 w-5 rounded border border-slate-300 bg-slate-50 p-0.5 text-slate-400" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>This survey uses branching logic.</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{filteredTemplates.map((template: Template) => (
|
||||
<div
|
||||
onClick={() => {
|
||||
const newTemplate = replacePresetPlaceholders(template, product);
|
||||
onTemplateClick(newTemplate);
|
||||
setActiveTemplate(newTemplate);
|
||||
}}
|
||||
key={template.name}
|
||||
className={cn(
|
||||
activeTemplate?.name === template.name && "ring-2 ring-slate-400",
|
||||
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105"
|
||||
)}>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`rounded border px-1.5 py-0.5 text-xs ${
|
||||
template.category === "Product Experience"
|
||||
? "border-blue-300 bg-blue-50 text-blue-500"
|
||||
: template.category === "Exploration"
|
||||
? "border-pink-300 bg-pink-50 text-pink-500"
|
||||
: template.category === "Growth"
|
||||
? "border-orange-300 bg-orange-50 text-orange-500"
|
||||
: template.category === "Increase Revenue"
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-500"
|
||||
: template.category === "Customer Success"
|
||||
? "border-violet-300 bg-violet-50 text-violet-500"
|
||||
: "border-slate-300 bg-slate-50 text-slate-500" // default color
|
||||
}`}>
|
||||
{template.category}
|
||||
</div>
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{template.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{template.description}</p>
|
||||
{activeTemplate?.name === template.name && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="mt-6 px-6 py-3"
|
||||
disabled={activeTemplate === null}
|
||||
loading={loading}
|
||||
onClick={() => addSurvey(activeTemplate)}>
|
||||
Use this template
|
||||
</Button>
|
||||
{template.preset.questions.some((question) => question.logic && question.logic.length > 0) && (
|
||||
<TooltipProvider delayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div>
|
||||
<SplitIcon className="ml-1.5 h-5 w-5 rounded border border-slate-300 bg-slate-50 p-0.5 text-slate-400" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>This survey uses branching logic.</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{template.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{template.description}</p>
|
||||
{activeTemplate?.name === template.name && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="mt-6 px-6 py-3"
|
||||
disabled={activeTemplate === null}
|
||||
loading={loading}
|
||||
onClick={() => addSurvey(activeTemplate)}>
|
||||
Use this template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,9 @@ export default function Onboarding({ session }: OnboardingProps) {
|
||||
error: isErrorEnvironment,
|
||||
isLoading: isLoadingEnvironment,
|
||||
} = useSWR(`/api/v1/environments/find-first`, fetcher);
|
||||
|
||||
const { profile } = useProfile();
|
||||
|
||||
const { triggerProfileMutate } = useProfileMutation();
|
||||
const [formbricksResponseId, setFormbricksResponseId] = useState<ResponseId | undefined>();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
@@ -56,28 +58,32 @@ export default function Onboarding({ session }: OnboardingProps) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const doLater = () => {
|
||||
const doLater = async () => {
|
||||
setCurrentStep(4);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (currentStep < MAX_STEPS) {
|
||||
setCurrentStep((value) => value + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const done = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const updatedProfile = { ...profile, onboardingCompleted: true };
|
||||
await triggerProfileMutate(updatedProfile);
|
||||
|
||||
if (environment) {
|
||||
router.push(`/environments/${environment.id}/surveys`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("An error occured saving your settings.");
|
||||
setIsLoading(false);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,6 +57,11 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
|
||||
toast.error("An error occured saving your settings");
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
const handleLaterClick = async () => {
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -138,7 +143,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="lg" className="mr-2" variant="minimal" id="product-skip" onClick={done}>
|
||||
<Button size="lg" className="mr-2" variant="minimal" id="product-skip" onClick={handleLaterClick}>
|
||||
I'll do it later
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -2,6 +2,7 @@ import { env } from "@/env.mjs";
|
||||
import { verifyPassword } from "@/lib/auth";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
@@ -254,7 +255,7 @@ export const authOptions: NextAuthOptions = {
|
||||
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
|
||||
}
|
||||
|
||||
await prisma.user.create({
|
||||
const createdUser = await prisma.user.create({
|
||||
data: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
@@ -362,8 +363,21 @@ export const authOptions: NextAuthOptions = {
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teamId = createdUser.memberships?.[0]?.teamId;
|
||||
if (teamId) {
|
||||
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,10 +61,14 @@ const notificationInsight = (insights: Insights) =>
|
||||
<p style="font-size:0.9em">Completed</p>
|
||||
<h1>${insights.totalCompletedResponses}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
${
|
||||
insights.totalDisplays !== 0
|
||||
? `<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Completion %</p>
|
||||
<h1>${insights.totalDisplays === 0 ? "N/A" : `${Math.round(insights.completionRate)}%`}</h1>
|
||||
</td>
|
||||
<h1>${Math.round(insights.completionRate)}%</h1>
|
||||
</td>`
|
||||
: ""
|
||||
}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,20 @@ const getProducts = async (): Promise<ProductData[]> => {
|
||||
id: true,
|
||||
surveys: {
|
||||
where: {
|
||||
NOT: {
|
||||
AND: [
|
||||
{ status: "completed" },
|
||||
{
|
||||
responses: {
|
||||
none: {
|
||||
createdAt: {
|
||||
gte: sevenDaysAgo,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
not: "draft",
|
||||
},
|
||||
@@ -190,3 +204,79 @@ const getProducts = async (): Promise<ProductData[]> => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* const getProducts = async (): Promise<ProductData[]> => {
|
||||
// gets all products together with team members, surveys, responses, and displays for the last 7 days
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
return await prisma.product.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
where: {
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
surveys: {
|
||||
where: {
|
||||
status: {
|
||||
not: "draft",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
status: true,
|
||||
responses: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: sevenDaysAgo,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: sevenDaysAgo,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
notificationSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { getPerson, selectPerson, 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";
|
||||
@@ -94,7 +94,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
|
||||
},
|
||||
select: {
|
||||
person: {
|
||||
select,
|
||||
select: selectPerson,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { deletePerson, selectPerson, 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";
|
||||
@@ -45,7 +45,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
|
||||
},
|
||||
},
|
||||
},
|
||||
select,
|
||||
select: selectPerson,
|
||||
});
|
||||
// if person exists, reconnect session and delete old user
|
||||
if (existingPerson) {
|
||||
@@ -87,7 +87,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
|
||||
},
|
||||
},
|
||||
},
|
||||
select,
|
||||
select: selectPerson,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
25
apps/web/app/api/v1/teams/[teamId]/add_demo_product/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { createDemoProduct } from "@formbricks/lib/services/team";
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { responses } from "@/lib/api/response";
|
||||
|
||||
export async function POST(_: Request, { params }: { params: { teamId: string } }) {
|
||||
// Check Authentication
|
||||
|
||||
if (headers().get("x-api-key") !== INTERNAL_SECRET) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const teamId = params.teamId;
|
||||
if (teamId === undefined) {
|
||||
return responses.badRequestResponse("Missing teamId");
|
||||
}
|
||||
|
||||
try {
|
||||
const demoProduct = await createDemoProduct(teamId);
|
||||
return NextResponse.json(demoProduct);
|
||||
} catch (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { populateEnvironment } from "@/lib/populate";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env.mjs";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let { inviteToken, ...user } = await request.json();
|
||||
@@ -15,7 +17,7 @@ export async function POST(request: Request) {
|
||||
let inviteId;
|
||||
|
||||
try {
|
||||
let data;
|
||||
let data: Prisma.UserCreateArgs;
|
||||
let invite;
|
||||
|
||||
if (inviteToken) {
|
||||
@@ -89,7 +91,26 @@ export async function POST(request: Request) {
|
||||
};
|
||||
}
|
||||
|
||||
const userData = await prisma.user.create(data);
|
||||
type UserWithMemberships = Prisma.UserGetPayload<{ include: { memberships: true } }>;
|
||||
|
||||
const userData = (await prisma.user.create({
|
||||
...data,
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
// TODO: This is a hack to get the correct types (casting), we should find a better way to do this
|
||||
})) as UserWithMemberships;
|
||||
|
||||
const teamId = userData.memberships[0].teamId;
|
||||
|
||||
if (teamId) {
|
||||
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": INTERNAL_SECRET,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (inviteId) {
|
||||
sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
|
||||
|
||||
@@ -49,3 +49,16 @@ input:focus {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the clear button for input type "search" */
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="search"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="search"]::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
@@ -27,7 +27,9 @@ export const SignupForm = () => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSigningUp(true);
|
||||
|
||||
try {
|
||||
await createUser(
|
||||
e.target.elements.name.value,
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export default function Modal({
|
||||
children,
|
||||
isOpen,
|
||||
placement,
|
||||
highlightBorderColor,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isOpen: boolean;
|
||||
placement: PlacementType;
|
||||
highlightBorderColor: string | null | undefined;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const highlightBorderColorStyle = useMemo(() => {
|
||||
if (!highlightBorderColor) return {};
|
||||
|
||||
return {
|
||||
border: `2px solid ${highlightBorderColor}`,
|
||||
overflow: "hidden",
|
||||
};
|
||||
}, [highlightBorderColor]);
|
||||
|
||||
useEffect(() => {
|
||||
setShow(isOpen);
|
||||
}, [isOpen]);
|
||||
@@ -23,9 +34,10 @@ export default function Modal({
|
||||
<div
|
||||
className={cn(
|
||||
show ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0",
|
||||
"pointer-events-auto absolute max-h-[90%] w-full max-w-sm overflow-hidden overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
|
||||
"pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-hidden overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
|
||||
getPlacementStyle(placement)
|
||||
)}>
|
||||
)}
|
||||
style={highlightBorderColorStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,14 +39,20 @@ export default function MultipleChoiceMultiQuestion({
|
||||
.map((choice) => choice.label);
|
||||
|
||||
useEffect(() => {
|
||||
const nonOtherSavedChoices = storedResponseValue?.filter((answer) => nonOtherChoiceLabels.includes(answer));
|
||||
const savedOtherSpecified = storedResponseValue?.find((answer) => !nonOtherChoiceLabels.includes(answer));
|
||||
if (Array.isArray(storedResponseValue)) {
|
||||
const nonOtherSavedChoices = storedResponseValue?.filter((answer) =>
|
||||
nonOtherChoiceLabels.includes(answer)
|
||||
);
|
||||
const savedOtherSpecified = storedResponseValue?.find(
|
||||
(answer) => !nonOtherChoiceLabels.includes(answer)
|
||||
);
|
||||
|
||||
setSelectedChoices(nonOtherSavedChoices ?? []);
|
||||
setSelectedChoices(nonOtherSavedChoices ?? []);
|
||||
|
||||
if (savedOtherSpecified) {
|
||||
setOtherSpecified(savedOtherSpecified);
|
||||
setShowOther(true);
|
||||
if (savedOtherSpecified) {
|
||||
setOtherSpecified(savedOtherSpecified);
|
||||
setShowOther(true);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storedResponseValue, question.id]);
|
||||
|
||||
@@ -29,7 +29,9 @@ export default function MultipleChoiceSingleQuestion({
|
||||
goToNextQuestion,
|
||||
goToPreviousQuestion,
|
||||
}: MultipleChoiceSingleProps) {
|
||||
const storedResponseValueValue = question.choices.find((choice) => choice.label === storedResponseValue)?.id;
|
||||
const storedResponseValueValue = question.choices.find(
|
||||
(choice) => choice.label === storedResponseValue
|
||||
)?.id;
|
||||
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
|
||||
const [savedOtherAnswer, setSavedOtherAnswer] = useState<string | null>(null);
|
||||
const [questionChoices, setQuestionChoices] = useState<TSurveyChoice[]>(
|
||||
|
||||
@@ -105,7 +105,9 @@ export default function NPSQuestion({
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
{(!question.required || storedResponseValue) && <SubmitButton {...{ question, lastQuestion, brandColor }} />}
|
||||
{(!question.required || storedResponseValue) && (
|
||||
<SubmitButton {...{ question, lastQuestion, brandColor }} />
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -154,7 +154,9 @@ export default function RatingQuestion({
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
{(!question.required || storedResponseValue) && <SubmitButton {...{ question, lastQuestion, brandColor }} />}
|
||||
{(!question.required || storedResponseValue) && (
|
||||
<SubmitButton {...{ question, lastQuestion, brandColor }} />
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function AlertDialog({
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={`Confirm ${confirmWhat}`}>
|
||||
<p>{text || "Are you sure? This action cannot be undone."}</p>
|
||||
<div className="my-4 space-x-2 text-right">
|
||||
<div className="space-x-2 text-right">
|
||||
<Button variant="warn" onClick={onDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/themes/prism.css";
|
||||
@@ -9,15 +10,16 @@ import toast from "react-hot-toast";
|
||||
interface CodeBlockProps {
|
||||
children: React.ReactNode;
|
||||
language: string;
|
||||
customCodeClass?: string;
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ children, language }) => {
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ children, language, customCodeClass = "" }) => {
|
||||
useEffect(() => {
|
||||
Prism.highlightAll();
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div className="group relative mt-4 rounded-md text-sm text-slate-200">
|
||||
<div className="group relative mt-4 rounded-md text-sm text-slate-200">
|
||||
<DocumentDuplicateIcon
|
||||
className="absolute right-4 top-4 z-20 h-5 w-5 cursor-pointer text-slate-600 opacity-0 transition-all duration-150 group-hover:opacity-60"
|
||||
onClick={() => {
|
||||
@@ -27,7 +29,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, language }) => {
|
||||
}}
|
||||
/>
|
||||
<pre>
|
||||
<code className={`language-${language} whitespace-pre-wrap`}>{children}</code>
|
||||
<code className={cn(`language-${language} whitespace-pre-wrap`, customCodeClass)}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
54
apps/web/components/shared/CustomDialog.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
interface CustomDialogProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
title?: string;
|
||||
text?: string;
|
||||
isLoading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
onOk: () => void;
|
||||
okBtnText?: string;
|
||||
onCancel?: () => void;
|
||||
cancelBtnText?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CustomDialog({
|
||||
open,
|
||||
setOpen,
|
||||
title,
|
||||
text,
|
||||
isLoading,
|
||||
children,
|
||||
onOk,
|
||||
okBtnText,
|
||||
onCancel,
|
||||
cancelBtnText,
|
||||
disabled,
|
||||
}: CustomDialogProps) {
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={title}>
|
||||
<p>{text}</p>
|
||||
<div>{children}</div>
|
||||
<div className="my-4 space-x-2 text-right">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
setOpen(false);
|
||||
}}>
|
||||
{cancelBtnText || "Cancel"}
|
||||
</Button>
|
||||
<Button variant="warn" onClick={onOk} loading={isLoading} disabled={disabled}>
|
||||
{okBtnText || "Yes"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export default function DeleteDialog({
|
||||
<Modal open={open} setOpen={setOpen} title={`Delete ${deleteWhat}`}>
|
||||
<p>{text || "Are you sure? This action cannot be undone."}</p>
|
||||
<div>{children}</div>
|
||||
<div className="my-4 space-x-2 text-right">
|
||||
<div className="space-x-2 text-right">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
|
||||