Compare commits
139 Commits
@formbrick
...
@formbrick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
315467ef3f | ||
|
|
3dde021cd0 | ||
|
|
ebbde2b531 | ||
|
|
47a8fd6b62 | ||
|
|
c6686209be | ||
|
|
09436c78fc | ||
|
|
98cdf941e6 | ||
|
|
52a09aa3ae | ||
|
|
bf028a5f64 | ||
|
|
5c60694117 | ||
|
|
179f92077b | ||
|
|
9cdf446f65 | ||
|
|
f98d4f5c11 | ||
|
|
142c1bd35b | ||
|
|
5e3ec7e4f0 | ||
|
|
3bbb4170e2 | ||
|
|
dc085c41c0 | ||
|
|
33b3887b84 | ||
|
|
6a8805de0b | ||
|
|
10e149bb02 | ||
|
|
9f944249fc | ||
|
|
b5765fed74 | ||
|
|
eee9b29723 | ||
|
|
a3aae4ab95 | ||
|
|
5b9db8f353 | ||
|
|
9b98ca4f64 | ||
|
|
6572d5395b | ||
|
|
cd753f1a67 | ||
|
|
6f0a26904f | ||
|
|
e1c8a715d1 | ||
|
|
96e54dbb46 | ||
|
|
b71fdf3205 | ||
|
|
5520edb2c5 | ||
|
|
d824da610d | ||
|
|
2bebc9598c | ||
|
|
580e51dcea | ||
|
|
b570f3c79d | ||
|
|
cf94c1a6d1 | ||
|
|
71832c590f | ||
|
|
15050525fd | ||
|
|
dcc198b151 | ||
|
|
3793f29d0a | ||
|
|
b6da482e3f | ||
|
|
cd1d9196fc | ||
|
|
e3c09ebec3 | ||
|
|
2bda12d4fc | ||
|
|
b072d3b549 | ||
|
|
758fc9af4d | ||
|
|
d4a4b4ec41 | ||
|
|
5aa38a6e39 | ||
|
|
0ebef13805 | ||
|
|
a81ceff09e | ||
|
|
7ebdf9939e | ||
|
|
7631783e7d | ||
|
|
c92b2b00e0 | ||
|
|
e68a7fe763 | ||
|
|
a3b46ee532 | ||
|
|
a1a66ef6be | ||
|
|
6a1b8106b7 | ||
|
|
8b1a074e2c | ||
|
|
57733a75fc | ||
|
|
e5ef71ae87 | ||
|
|
89dae8f1d8 | ||
|
|
370041b0ae | ||
|
|
34ff14d43b | ||
|
|
b6c0dbf5d3 | ||
|
|
488e2801f0 | ||
|
|
ae7d0a4846 | ||
|
|
7d3fa70fe2 | ||
|
|
bb4052690e | ||
|
|
e8a286bd4e | ||
|
|
205593d8d3 | ||
|
|
37afd004af | ||
|
|
ad86c4dbf4 | ||
|
|
fb64eb50a2 | ||
|
|
8d7eeb045b | ||
|
|
fdb1aa2299 | ||
|
|
df9ff011f7 | ||
|
|
e85d95a4eb | ||
|
|
5c9605f4af | ||
|
|
de3d580614 | ||
|
|
ccb89548f0 | ||
|
|
ad42f4cc55 | ||
|
|
51dda67992 | ||
|
|
0598ad2eaa | ||
|
|
44e48e3c3f | ||
|
|
1551baeca7 | ||
|
|
c3f26f7ab8 | ||
|
|
e9e3de2ce8 | ||
|
|
34c4e9bc1a | ||
|
|
c707896eb6 | ||
|
|
ee545b7ade | ||
|
|
7bf0fa450a | ||
|
|
1a8618692a | ||
|
|
e5f371476c | ||
|
|
dba3677633 | ||
|
|
369c9ed7b2 | ||
|
|
0776138c1c | ||
|
|
235c1afe28 | ||
|
|
17b9d686bd | ||
|
|
73904e11a6 | ||
|
|
c423e43aee | ||
|
|
a1b447caad | ||
|
|
6b989487b2 | ||
|
|
d60e0c4e5c | ||
|
|
2a3ab3280f | ||
|
|
5b217e5483 | ||
|
|
ec0d3f2fa2 | ||
|
|
ae702ddd06 | ||
|
|
91f78d875b | ||
|
|
08110b0c34 | ||
|
|
42e6601f13 | ||
|
|
a5c33981a0 | ||
|
|
1a90d1b7e8 | ||
|
|
712431e842 | ||
|
|
3905c2227e | ||
|
|
86da5ff2f4 | ||
|
|
eed9b6635d | ||
|
|
892a58c45e | ||
|
|
4fb9851a6d | ||
|
|
09c37d78a2 | ||
|
|
6335565bf9 | ||
|
|
5ae7f31d01 | ||
|
|
cb4cd706ad | ||
|
|
1e816eb6d9 | ||
|
|
8c31c71251 | ||
|
|
62a08f304b | ||
|
|
35057322a4 | ||
|
|
b1a93de8db | ||
|
|
aca32655cd | ||
|
|
8b14559d5f | ||
|
|
43a623a61e | ||
|
|
2f8257ae62 | ||
|
|
8a5217b39c | ||
|
|
57e6c86e6a | ||
|
|
4519cb8a2d | ||
|
|
b20cda2d06 | ||
|
|
6e8be0c0bd | ||
|
|
c68a9c8d15 |
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
|
||||
|
||||
@@ -1,53 +1,76 @@
|
||||
import fbsetup from "../../public/fb-setup.png";
|
||||
import formbricks from "@formbricks/js";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import fbsetup from "../../public/fb-setup.png";
|
||||
|
||||
export default function AppPage({}) {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.body.classList.add("dark");
|
||||
} else {
|
||||
document.body.classList.remove("dark");
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
return (
|
||||
<div className="px-12 py-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Formbricks In-product Survey Demo App</h1>
|
||||
<p className="text-slate-700">
|
||||
This app helps you test your in-app surveys. You can create an test user actions, create and update
|
||||
user attributes, etc.
|
||||
</p>
|
||||
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
Formbricks In-product Survey Demo App
|
||||
</h1>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
This app helps you test your in-app surveys. You can create and test user actions, create and
|
||||
update user attributes, etc.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
|
||||
onClick={() => setDarkMode(!darkMode)}>
|
||||
Toggle Dark Mode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6">
|
||||
<h3 className="text-lg font-semibold">Setup .env</h3>
|
||||
<p className="text-slate-700">
|
||||
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Setup .env</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Copy the environment ID of your Formbricks app to the env variable in demo/.env
|
||||
</p>
|
||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6">
|
||||
<h3 className="text-lg font-semibold">Widget Logs</h3>
|
||||
<p className="text-slate-700">
|
||||
Look at the logs to understand how the widget works. <strong>Open your browser console</strong>{" "}
|
||||
to see the logs.
|
||||
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Widget Logs</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Look at the logs to understand how the widget works.{" "}
|
||||
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
|
||||
</p>
|
||||
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
|
||||
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
|
||||
<LogsContainer />
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6">
|
||||
<h3 className="text-lg font-semibold">Reset person / pull data from Formbricks app</h3>
|
||||
<p className="text-slate-700">
|
||||
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-gray-600 dark:bg-gray-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
<p className="text-slate-700 dark:text-gray-300">
|
||||
On formbricks.logout() a few things happen: <strong>New person is created</strong> and{" "}
|
||||
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
|
||||
</p>
|
||||
<button
|
||||
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700"
|
||||
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
formbricks.logout();
|
||||
}}>
|
||||
Logout
|
||||
</button>
|
||||
<p className="text-xs text-slate-700">
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
If you made a change in Formbricks app and it does not seem to work, hit 'Logout' and
|
||||
try again.
|
||||
</p>
|
||||
@@ -56,7 +79,7 @@ export default function AppPage({}) {
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700"
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
formbricks.track("Code Action");
|
||||
}}>
|
||||
@@ -64,7 +87,7 @@ export default function AppPage({}) {
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700">
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
This button sends a{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" className="underline" target="_blank">
|
||||
Code Action
|
||||
@@ -75,18 +98,24 @@ export default function AppPage({}) {
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
|
||||
<button className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
|
||||
No-Code Action
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700">
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
This button sends a{" "}
|
||||
<a href="https://formbricks.com/docs/actions/no-code" className="underline" target="_blank">
|
||||
<a
|
||||
href="https://formbricks.com/docs/actions/no-code"
|
||||
className="underline dark:text-blue-500"
|
||||
target="_blank">
|
||||
No Code Action
|
||||
</a>{" "}
|
||||
as long as you created it beforehand in the Formbricks App.{" "}
|
||||
<a href="https://formbricks.com/docs/actions/no-code" target="_blank" className="underline">
|
||||
<a
|
||||
href="https://formbricks.com/docs/actions/no-code"
|
||||
target="_blank"
|
||||
className="underline dark:text-blue-500">
|
||||
Here are instructions on how to do it.
|
||||
</a>
|
||||
</p>
|
||||
@@ -98,17 +127,17 @@ export default function AppPage({}) {
|
||||
onClick={() => {
|
||||
formbricks.setAttribute("Plan", "Free");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
|
||||
Set Plan to 'Free'
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700">
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/attributes/custom-attributes"
|
||||
target="_blank"
|
||||
className="underline">
|
||||
className="underline dark:text-blue-500">
|
||||
attribute
|
||||
</a>{" "}
|
||||
'Plan' to 'Free'. If the attribute does not exist, it creates it.
|
||||
@@ -121,17 +150,17 @@ export default function AppPage({}) {
|
||||
onClick={() => {
|
||||
formbricks.setAttribute("Plan", "Paid");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
|
||||
Set Plan to 'Paid'
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700">
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/attributes/custom-attributes"
|
||||
target="_blank"
|
||||
className="underline">
|
||||
className="underline dark:text-blue-500">
|
||||
attribute
|
||||
</a>{" "}
|
||||
'Plan' to 'Paid'. If the attribute does not exist, it creates it.
|
||||
@@ -144,17 +173,17 @@ export default function AppPage({}) {
|
||||
onClick={() => {
|
||||
formbricks.setEmail("test@web.com");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
|
||||
Set Email
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700">
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/attributes/identify-users"
|
||||
target="_blank"
|
||||
className="underline">
|
||||
className="underline dark:text-blue-500">
|
||||
user email
|
||||
</a>{" "}
|
||||
'test@web.com'
|
||||
@@ -167,17 +196,17 @@ export default function AppPage({}) {
|
||||
onClick={() => {
|
||||
formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
|
||||
Set User ID
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700">
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
This button sets an external{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/attributes/identify-users"
|
||||
target="_blank"
|
||||
className="underline">
|
||||
className="underline dark:text-blue-500">
|
||||
user ID
|
||||
</a>{" "}
|
||||
to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
|
||||
|
||||
@@ -5,6 +5,7 @@ module.exports = {
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Button, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { handleFeedbackSubmit, updateFeedback } from "../../lib/handleFeedbackSubmit";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@formbricks/ui";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const DocsFeedback: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -26,7 +25,7 @@ export const DocsFeedback: React.FC = () => {
|
||||
Is everything on this page clear?
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="mt-2 inline-flex space-x-3 md:ml-4 md:mt-0">
|
||||
{["Yes 👍", " No 👎"].map((option) => (
|
||||
{["Yes 👍", "No 👎"].map((option) => (
|
||||
<PopoverTrigger
|
||||
key={option}
|
||||
className="rounded border border-slate-200 bg-slate-50 px-4 py-2 text-slate-900 hover:bg-slate-100 hover:text-slate-600 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 dark:hover:text-slate-300"
|
||||
|
||||
@@ -2,18 +2,18 @@ import { CodeFileIcon, EyeIcon, HandPuzzleIcon } from "@formbricks/ui";
|
||||
import HeadingCentered from "../shared/HeadingCentered";
|
||||
|
||||
const features = [
|
||||
{
|
||||
id: "compliance",
|
||||
name: "Smoothly Compliant",
|
||||
description: "Use our GDPR-compliant Cloud or self-host the entire solution.",
|
||||
icon: EyeIcon,
|
||||
},
|
||||
{
|
||||
id: "customizable",
|
||||
name: "Fully Customizable",
|
||||
description: "Full customizability and extendability. Integrate with your stack easily.",
|
||||
icon: HandPuzzleIcon,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
name: "Smoothly Compliant",
|
||||
description: "Self-host the entire product and fly through privacy compliance reviews.",
|
||||
icon: EyeIcon,
|
||||
},
|
||||
{
|
||||
id: "independent",
|
||||
name: "Stay independent",
|
||||
@@ -27,9 +27,9 @@ export const Features: React.FC = () => {
|
||||
<div className="relative mx-auto max-w-7xl">
|
||||
<HeadingCentered
|
||||
closer
|
||||
teaser="DATA Privacy at heart"
|
||||
teaser="Data Privacy at heart"
|
||||
heading="The only open-source solution"
|
||||
subheading="Comply with all data privacy regulation with ease. Simply self-host."
|
||||
subheading="Comply with all data privacy regulation with ease. Self-host if you want."
|
||||
/>
|
||||
|
||||
<ul role="list" className="grid grid-cols-1 gap-4 pt-8 sm:grid-cols-2 md:grid-cols-3 lg:gap-10">
|
||||
|
||||
@@ -9,7 +9,7 @@ export const GitHubSponsorship: React.FC = () => {
|
||||
<style jsx>{`
|
||||
@media (min-width: 426px);
|
||||
`}</style>
|
||||
<div className="right-10 lg:absolute">
|
||||
<div className="right-24 lg:absolute">
|
||||
<Image
|
||||
src={GitHubMarkDark}
|
||||
alt="GitHub Sponsors Formbricks badge"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Footer() {
|
||||
<p className="text-base text-slate-500 dark:text-slate-400">Privacy-first Experience Management</p>
|
||||
<div className="border-slate-500">
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">
|
||||
© 2022. All rights reserved.
|
||||
Formbricks GmbH © 2022. All rights reserved.
|
||||
<br />
|
||||
<Link href="/imprint">Imprint</Link> | <Link href="/privacy">Privacy Policy</Link> |{" "}
|
||||
<Link href="/terms">Terms</Link> | <Link href="/oss-friends">OSS Friends</Link>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,39 +26,39 @@ const tiers = [
|
||||
href: "/docs/self-hosting/deployment",
|
||||
},
|
||||
{
|
||||
name: "Free",
|
||||
name: "Cloud",
|
||||
href: "https://app.formbricks.com/auth/signup",
|
||||
priceMonthly: "$0",
|
||||
paymentRythm: "/month",
|
||||
button: "highlight",
|
||||
discounted: false,
|
||||
highlight: true,
|
||||
description: "All Pro features included.",
|
||||
description: "Start with the 'Free forever' plan.",
|
||||
features: [
|
||||
"Unlimited surveys",
|
||||
"Unlimited team members",
|
||||
"Remove branding",
|
||||
"Granular targeting",
|
||||
"In-product surveys",
|
||||
"Link surveys",
|
||||
"Remove branding",
|
||||
"Granular targeting",
|
||||
"30+ templates",
|
||||
"API access",
|
||||
"Integrations (Slack, PostHog, Zapier)",
|
||||
"Integrations (Zapier, Make, ...)",
|
||||
"Unlimited team members",
|
||||
"100 responses per survey",
|
||||
],
|
||||
ctaName: "Start for free",
|
||||
ctaName: "Get started",
|
||||
plausibleGoal: "Pricing_CTA_FreePlan",
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
name: "Cloud Pro",
|
||||
href: "https://app.formbricks.com/auth/signup",
|
||||
priceMonthly: "$99",
|
||||
paymentRythm: "/month",
|
||||
button: "secondary",
|
||||
discounted: false,
|
||||
highlight: false,
|
||||
description: "All features included. Unlimited usage.",
|
||||
features: ["Unlimited responses per survey"],
|
||||
description: "All features, unlimited usage.",
|
||||
features: ["Everything in 'Cloud'", "Unlimited responses per survey"],
|
||||
ctaName: "Start for free",
|
||||
plausibleGoal: "Pricing_CTA_ProPlan",
|
||||
},
|
||||
@@ -146,9 +146,12 @@ export default function Pricing() {
|
||||
{tier.ctaName}
|
||||
</Button>
|
||||
|
||||
{tier.name !== "Self-hosting" && (
|
||||
{tier.name == "Cloud Pro" && (
|
||||
<p className="mt-1.5 text-center text-xs text-slate-500">No Creditcard required.</p>
|
||||
)}
|
||||
{tier.name == "Cloud" && (
|
||||
<p className="mt-1.5 text-center text-xs text-slate-500">Free forever 🤍</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,134 @@
|
||||
import { OSSFriends } from "@/pages/oss-friends";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// GET
|
||||
if (req.method === "GET") {
|
||||
return res.status(200).json({ data: OSSFriends });
|
||||
return res.status(200).json({
|
||||
data: [
|
||||
{
|
||||
name: "Appsmith",
|
||||
description: "Build build custom software on top of your data.",
|
||||
href: "https://www.appsmith.com",
|
||||
},
|
||||
{
|
||||
name: "BoxyHQ",
|
||||
description:
|
||||
"BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
|
||||
href: "https://boxyhq.com",
|
||||
},
|
||||
{
|
||||
name: "Cal.com",
|
||||
description:
|
||||
"Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
|
||||
href: "https://cal.com",
|
||||
},
|
||||
{
|
||||
name: "Crowd.dev",
|
||||
description:
|
||||
"Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
|
||||
href: "https://www.crowd.dev",
|
||||
},
|
||||
{
|
||||
name: "Documenso",
|
||||
description:
|
||||
"The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
|
||||
href: "https://documenso.com",
|
||||
},
|
||||
{
|
||||
name: "Erxes",
|
||||
description:
|
||||
"The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
|
||||
href: "https://erxes.io",
|
||||
},
|
||||
{
|
||||
name: "Formbricks",
|
||||
description:
|
||||
"Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
|
||||
href: "https://formbricks.com",
|
||||
},
|
||||
{
|
||||
name: "GitWonk",
|
||||
description:
|
||||
"GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.",
|
||||
href: "https://gitwonk.com",
|
||||
},
|
||||
{
|
||||
name: "Hanko",
|
||||
description:
|
||||
"Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
|
||||
href: "https://www.hanko.io",
|
||||
},
|
||||
{
|
||||
name: "HTMX",
|
||||
description:
|
||||
"HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
|
||||
href: "https://htmx.org",
|
||||
},
|
||||
{
|
||||
name: "Infisical",
|
||||
description:
|
||||
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
|
||||
href: "https://infisical.com",
|
||||
},
|
||||
{
|
||||
name: "Mockoon",
|
||||
description: "Mockoon is the easiest and quickest way to design and run mock REST APIs.",
|
||||
href: "https://mockoon.com",
|
||||
},
|
||||
{
|
||||
name: "Novu",
|
||||
description:
|
||||
"The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.",
|
||||
href: "https://novu.co",
|
||||
},
|
||||
{
|
||||
name: "OpenBB",
|
||||
description:
|
||||
"Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
|
||||
href: "https://openbb.co",
|
||||
},
|
||||
{
|
||||
name: "Sniffnet",
|
||||
description:
|
||||
"Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
|
||||
href: "https://www.sniffnet.net",
|
||||
},
|
||||
{
|
||||
name: "Tolgee",
|
||||
description: "Software localization from A to Z made really easy.",
|
||||
href: "https://tolgee.io/",
|
||||
},
|
||||
{
|
||||
name: "Trigger.dev",
|
||||
description:
|
||||
"Create long-running Jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.",
|
||||
href: "https://trigger.dev",
|
||||
},
|
||||
{
|
||||
name: "Typebot",
|
||||
description:
|
||||
"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.",
|
||||
href: "https://twenty.com",
|
||||
},
|
||||
{
|
||||
name: "Webiny",
|
||||
description:
|
||||
"Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
|
||||
href: "https://www.webiny.com",
|
||||
},
|
||||
{
|
||||
name: "Webstudio",
|
||||
description: "Webstudio is an open source alternative to Webflow",
|
||||
href: "https://webstudio.is",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import BestPracticeNavigation from "@/components/shared/BestPracticeNavigation";
|
||||
export default function DocsFeedbackPage() {
|
||||
return (
|
||||
<Layout
|
||||
title="Feedback Box"
|
||||
title="Docs Feedback"
|
||||
description="The better your docs, the higher your user adoption. Measure granularly how clear your documentation is.">
|
||||
<div className="grid grid-cols-1 items-center md:grid-cols-2 md:gap-12 md:py-20">
|
||||
<div className="p-6 md:p-0">
|
||||
|
||||
@@ -33,7 +33,7 @@ The API requests are authorized with a personal API key. This API key gives you
|
||||
|
||||
### Delete a personal API key
|
||||
|
||||
1. Go to settings on [app.formbricks.com](https://app.formbricks.com/me/settings).
|
||||
1. Go to settings on [app.formbricks.com](https://app.formbricks.com/).
|
||||
2. Go to page “API keys”.
|
||||
3. Find the key you wish to revoke and select “Delete”.
|
||||
4. Your API key will stop working immediately.
|
||||
|
||||
@@ -20,7 +20,7 @@ Please note that regardless of the method you choose, Formbricks is designed to
|
||||
|
||||
---
|
||||
|
||||
## (Production: Ubuntu) Running the Shell Script
|
||||
## (Most users: One-click Ubuntu setup) Running the Shell Script
|
||||
|
||||
This is the quickest way to get Formbricks up and running on an Ubuntu server. The shell script will automatically install all the required dependencies and configure your server, making the process a breeze.
|
||||
|
||||
@@ -50,7 +50,7 @@ The script will prompt you for the following information:
|
||||
|
||||
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
|
||||
|
||||
## (Most users: Local Setup) Running the pre-built Docker Image
|
||||
## (Manual Deployment) Running the pre-built Docker Image
|
||||
|
||||
### Requirements
|
||||
|
||||
@@ -133,15 +133,9 @@ Ensure `docker` & `docker compose` are installed on your server/system. Both are
|
||||
git clone https://github.com/formbricks/formbricks.git && cd formbricks
|
||||
```
|
||||
|
||||
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and Formbricks works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings in the `.env` file. If you configured your email credentials, you can also comment the following lines to enable email verification (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1`)
|
||||
2. Modify the `.env.docker` file as required by your setup. This file comes with a basic setup and Formbricks works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings. If you configured your email credentials, you can also comment the following lines to enable email verification (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1`)
|
||||
|
||||
2. Copy the `.env.docker` file to `.env` and edit it with an editor of your choice if needed.
|
||||
|
||||
```bash
|
||||
cp .env.docker .env
|
||||
```
|
||||
|
||||
Note: The environment variables are used at build time. When you change environment variables later, you need to rebuild the image with `docker compose build` for the changes to take effect.
|
||||
Note: All environment variables starting with `NEXT_PUBLIC_` are used at build time. When you change environment variables later, you need to rebuild the image with `docker compose build` for the changes to take effect.
|
||||
|
||||
3. Finally start the docker compose process to build and spin up the Formbricks container as well as the PostgreSQL database.
|
||||
|
||||
@@ -158,12 +152,12 @@ Certainly, here is the reformatted version for Formbricks environment variables.
|
||||
|
||||
These variables must also be provided at runtime.
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------- |
|
||||
| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
|
||||
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
|
||||
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
|
||||
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
|
||||
| Variable | Description | Required | Default |
|
||||
| --------------- | --------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------- |
|
||||
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
|
||||
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
|
||||
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
|
||||
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
|
||||
|
||||
### Build-time Variables
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const meta = {
|
||||
]}
|
||||
example={`{
|
||||
"url": "https://mysystem.com/myendpoint",
|
||||
"trigger": "responseFinished"
|
||||
"triggers": ["responseFinished"]
|
||||
}`}
|
||||
responses={[
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Fence } from "@/components/shared/Fence";
|
||||
|
||||
export const meta = {
|
||||
title: "Webhook Payload",
|
||||
description: "Learn how to use the Formbricks Webhook API.",
|
||||
description: "Learn how to handle the Formbricks API payload.",
|
||||
};
|
||||
|
||||
This documentation helps understand the payload structure that will be received when the webhook is triggered in Formbricks.
|
||||
|
||||
@@ -2,6 +2,7 @@ import Layout from "@/components/shared/LayoutMdx";
|
||||
|
||||
export const meta = {
|
||||
title: "Imprint",
|
||||
description: "Imprint of formbricks.com",
|
||||
};
|
||||
|
||||
## Information according to § 5 TMG
|
||||
@@ -17,19 +18,24 @@ E-Mail: hola@formbricks.com
|
||||
|
||||
## EU dispute resolution
|
||||
|
||||
The European Commission provides a platform for online dispute resolution (OS): https://ec.europa.eu/consumers/odr.\
|
||||
You can find our e-mail address in the imprint above.\
|
||||
Consumer dispute resolution/universal dispute resolution body\
|
||||
The European Commission provides a platform for online dispute resolution (OS): https://ec.europa.eu/consumers/odr
|
||||
|
||||
You can also reach out via the e-mail address in the imprint above.
|
||||
|
||||
### Consumer dispute resolution/universal dispute resolution body
|
||||
|
||||
We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board.
|
||||
|
||||
## Liability for contents
|
||||
|
||||
As a service provider, we are responsible for our own content on these pages in accordance with § 7 paragraph 1 TMG under the general laws. According to §§ 8 to 10 TMG, we are not obligated to monitor transmitted or stored information or to investigate circumstances that indicate illegal activity.\
|
||||
|
||||
Obligations to remove or block the use of information under the general laws remain unaffected. However, liability in this regard is only possible from the point in time at which a concrete infringement of the law becomes known. If we become aware of any such infringements, we will remove the relevant content immediately.
|
||||
|
||||
## Liability for links
|
||||
|
||||
Our offer contains links to external websites of third parties, on whose contents we have no influence. Therefore, we cannot assume any liability for these external contents. The respective provider or operator of the sites is always responsible for the content of the linked sites. The linked pages were checked for possible legal violations at the time of linking. Illegal contents were not recognizable at the time of linking.\
|
||||
|
||||
However, a permanent control of the contents of the linked pages is not reasonable without concrete evidence of a violation of the law. If we become aware of any infringements, we will remove such links immediately.
|
||||
|
||||
## Copyright
|
||||
|
||||
@@ -2,105 +2,17 @@ import Layout from "@/components/shared/Layout";
|
||||
import HeroTitle from "@/components/shared/HeroTitle";
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
export const OSSFriends = [
|
||||
{
|
||||
name: "BoxyHQ",
|
||||
description:
|
||||
"BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
|
||||
href: "https://boxyhq.com",
|
||||
},
|
||||
{
|
||||
name: "Cal.com",
|
||||
description:
|
||||
"Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
|
||||
href: "https://cal.com",
|
||||
},
|
||||
{
|
||||
name: "Crowd.dev",
|
||||
description:
|
||||
"Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
|
||||
href: "https://www.crowd.dev",
|
||||
},
|
||||
{
|
||||
name: "Documenso",
|
||||
description:
|
||||
"The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
|
||||
href: "https://documenso.com",
|
||||
},
|
||||
{
|
||||
name: "Erxes",
|
||||
description:
|
||||
"The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
|
||||
href: "https://erxes.io",
|
||||
},
|
||||
{
|
||||
name: "Formbricks",
|
||||
description:
|
||||
"Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
|
||||
href: "https://formbricks.com",
|
||||
},
|
||||
{
|
||||
name: "GitWonk",
|
||||
description:
|
||||
"GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.",
|
||||
href: "https://gitwonk.com",
|
||||
},
|
||||
{
|
||||
name: "Hanko",
|
||||
description:
|
||||
"Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
|
||||
href: "https://www.hanko.io",
|
||||
},
|
||||
{
|
||||
name: "HTMX",
|
||||
description:
|
||||
"HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
|
||||
href: "https://htmx.org",
|
||||
},
|
||||
{
|
||||
name: "Infisical",
|
||||
description:
|
||||
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
|
||||
href: "https://infisical.com",
|
||||
},
|
||||
{
|
||||
name: "Novu",
|
||||
description:
|
||||
"The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.",
|
||||
href: "https://novu.co",
|
||||
},
|
||||
{
|
||||
name: "OpenBB",
|
||||
description:
|
||||
"Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
|
||||
href: "https://openbb.co",
|
||||
},
|
||||
{
|
||||
name: "Sniffnet",
|
||||
description:
|
||||
"Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
|
||||
href: "https://www.sniffnet.net",
|
||||
},
|
||||
{
|
||||
name: "Typebot",
|
||||
description:
|
||||
"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: "Webiny",
|
||||
description:
|
||||
"Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
|
||||
href: "https://www.webiny.com",
|
||||
},
|
||||
{
|
||||
name: "Webstudio",
|
||||
description: "Webstudio is an open source alternative to Webflow",
|
||||
href: "https://webstudio.is",
|
||||
},
|
||||
];
|
||||
type OSSFriend = {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function OSSFriendsPage() {
|
||||
type Props = {
|
||||
OSSFriends: OSSFriend[];
|
||||
};
|
||||
|
||||
export default function OSSFriendsPage({ OSSFriends }: Props) {
|
||||
return (
|
||||
<Layout title="OSS Friends" description="Open-source projects and tools for an open world.">
|
||||
<HeroTitle headingPt1="Our" headingTeal="Open-source" headingPt2="Friends" />
|
||||
@@ -122,3 +34,16 @@ export default function OSSFriendsPage() {
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStaticProps() {
|
||||
const res = await fetch("https://formbricks.com/api/oss-friends");
|
||||
const data = await res.json();
|
||||
|
||||
// By returning { props: { OSSFriends } }, the OSSFriendsPage component
|
||||
// will receive `OSSFriends` as a prop at build time
|
||||
return {
|
||||
props: {
|
||||
OSSFriends: data.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Layout from "@/components/shared/LayoutMdx";
|
||||
|
||||
export const meta = {
|
||||
title: "Privacy Policy",
|
||||
description: "Formbricks Privacy Policy",
|
||||
};
|
||||
|
||||
## **1. Introduction**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Callout } from "@/components/shared/Callout";
|
||||
|
||||
export const meta = {
|
||||
title: "Terms of Service",
|
||||
description: "Terms of Service of Formbricks Cloud.",
|
||||
};
|
||||
|
||||
These Terms of Use constitute a legally binding agreement made between you, whether personally or on behalf of an entity (“you”) and Formbricks ("**Company**", “**we**”, “**us**”, or “**our**”), concerning your access to and use of the https://formbricks.com website as well as any other media form, media channel, mobile website or mobile application related, linked, or otherwise connected thereto (collectively, the “Site”). You agree that by accessing the Site, you have read, understood, and agree to be bound by all of these Terms of Use. If you do not agree with all of these terms of use, then you are expressly prohibited from using the site and you must discontinue use immediately.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# @formbricks/web
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3dde021c: Release version 1.0.2
|
||||
- Updated dependencies [3dde021c]
|
||||
- @formbricks/js@1.0.3
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a1b447ca]
|
||||
- @formbricks/js@1.0.2
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import EditApiKeys from "./EditApiKeys";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getApiKeys } from "@formbricks/lib/services/apiKey";
|
||||
import { getEnvironments } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default function ApiKeyList({
|
||||
export default async function ApiKeyList({
|
||||
environmentId,
|
||||
environmentType,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environmentType: string;
|
||||
}) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
|
||||
const findEnvironmentByType = (environments, targetType) => {
|
||||
for (const environment of environments) {
|
||||
if (environment.type === targetType) {
|
||||
@@ -23,15 +19,12 @@ export default function ApiKeyList({
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
const environments = await getEnvironments(product.id);
|
||||
const environmentTypeId = findEnvironmentByType(environments, environmentType);
|
||||
const apiKeys = await getApiKeys(environmentTypeId);
|
||||
|
||||
if (isErrorProduct) {
|
||||
<ErrorComponent />;
|
||||
}
|
||||
|
||||
const environmentTypeId = findEnvironmentByType(product?.environments, environmentType);
|
||||
|
||||
return <EditApiKeys environmentTypeId={environmentTypeId} environmentType={environmentType} />;
|
||||
return (
|
||||
<EditApiKeys environmentTypeId={environmentTypeId} environmentType={environmentType} apiKeys={apiKeys} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { createApiKey, deleteApiKey, useApiKeys } from "@/lib/apiKeys";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { Button, ErrorComponent } from "@formbricks/ui";
|
||||
import { TApiKey } from "@formbricks/types/v1/apiKeys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import AddAPIKeyModal from "./AddApiKeyModal";
|
||||
import { createApiKeyAction, deleteApiKeyAction } from "./actions";
|
||||
|
||||
export default function EditAPIKeys({
|
||||
environmentTypeId,
|
||||
environmentType,
|
||||
apiKeys,
|
||||
}: {
|
||||
environmentTypeId: string;
|
||||
environmentType: string;
|
||||
apiKeys: TApiKey[];
|
||||
}) {
|
||||
const { apiKeys, mutateApiKeys, isLoadingApiKeys, isErrorApiKeys } = useApiKeys(environmentTypeId);
|
||||
|
||||
const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false);
|
||||
const [isDeleteKeyModalOpen, setOpenDeleteKeyModal] = useState(false);
|
||||
|
||||
const [apiKeysLocal, setApiKeysLocal] = useState<TApiKey[]>(apiKeys);
|
||||
const [activeKey, setActiveKey] = useState({} as any);
|
||||
|
||||
const handleOpenDeleteKeyModal = (e, apiKey) => {
|
||||
@@ -32,26 +32,21 @@ export default function EditAPIKeys({
|
||||
};
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
await deleteApiKey(environmentTypeId, activeKey);
|
||||
mutateApiKeys();
|
||||
await deleteApiKeyAction(activeKey.id);
|
||||
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
setOpenDeleteKeyModal(false);
|
||||
toast.success("API Key deleted");
|
||||
};
|
||||
|
||||
const handleAddAPIKey = async (data) => {
|
||||
const apiKey = await createApiKey(environmentTypeId, { label: data.label });
|
||||
mutateApiKeys([...JSON.parse(JSON.stringify(apiKeys)), apiKey], false);
|
||||
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
|
||||
const updatedApiKeys = [...apiKeysLocal!, apiKey];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
setOpenAddAPIKeyModal(false);
|
||||
toast.success("API key created");
|
||||
};
|
||||
|
||||
if (isLoadingApiKeys) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorApiKeys) {
|
||||
<ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 text-right">
|
||||
@@ -72,19 +67,22 @@ export default function EditAPIKeys({
|
||||
<div className=""></div>
|
||||
</div>
|
||||
<div className="grid-cols-9">
|
||||
{apiKeys.length === 0 ? (
|
||||
{apiKeysLocal && apiKeysLocal.length === 0 ? (
|
||||
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400 ">
|
||||
You don't have any API keys yet
|
||||
</div>
|
||||
) : (
|
||||
apiKeys.map((apiKey) => (
|
||||
apiKeysLocal &&
|
||||
apiKeysLocal.map((apiKey) => (
|
||||
<div
|
||||
className="grid h-12 w-full grid-cols-9 content-center rounded-lg px-6 text-left text-sm text-slate-900"
|
||||
key={apiKey.hashedKey}>
|
||||
<div className="col-span-2 font-semibold">{apiKey.label}</div>
|
||||
<div className="col-span-2">{apiKey.apiKey || <span className="italic">secret</span>}</div>
|
||||
<div className="col-span-2">{apiKey.lastUsed && timeSince(apiKey.lastUsed)}</div>
|
||||
<div className="col-span-2">{timeSince(apiKey.createdAt)}</div>
|
||||
<div className="col-span-2">
|
||||
{apiKey.lastUsedAt && timeSince(apiKey.lastUsedAt.toString())}
|
||||
</div>
|
||||
<div className="col-span-2">{timeSince(apiKey.createdAt.toString())}</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<button onClick={(e) => handleOpenDeleteKeyModal(e, apiKey)}>
|
||||
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { deleteApiKey, createApiKey } from "@formbricks/lib/services/apiKey";
|
||||
import { TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
|
||||
|
||||
export async function deleteApiKeyAction(id: string) {
|
||||
return await deleteApiKey(id);
|
||||
}
|
||||
export async function createApiKeyAction(environmentId: string, apiKeyData: TApiKeyCreateInput) {
|
||||
return await createApiKey(environmentId, apiKeyData);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
function LoadingCard({ title, description }) {
|
||||
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">
|
||||
<div className="flex justify-end">
|
||||
<div className="mt-4 h-6 w-28 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-9 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2">Label</div>
|
||||
<div className="col-span-2">API Key</div>
|
||||
<div className="col-span-2">Last used</div>
|
||||
<div className="col-span-2">Created at</div>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<div className="my-4 h-6 w-full animate-pulse rounded-full bg-gray-200"></div>
|
||||
<div className="my-4 h-6 w-full animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function Loading() {
|
||||
const cards = [
|
||||
{
|
||||
title: "Development Env Keys",
|
||||
description: "Add and remove API keys for your Development environment.",
|
||||
},
|
||||
{
|
||||
title: "Production Env Keys",
|
||||
description: "Add and remove API keys for your Production environment.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">API Keys</h2>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import ApiKeyList from "./ApiKeyList";
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { Button, ColorPicker, Label } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { updateProductAction } from "./actions";
|
||||
|
||||
interface EditBrandColorProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export function EditBrandColor({ product }: EditBrandColorProps) {
|
||||
const [color, setColor] = useState(product.brandColor);
|
||||
const [updatingColor, setUpdatingColor] = useState(false);
|
||||
|
||||
const handleUpdateBrandColor = async () => {
|
||||
try {
|
||||
setUpdatingColor(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
brandColor: color,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success("Brand color updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingColor(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm items-center">
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
<Button variant="darkCTA" className="mt-4" loading={updatingColor} onClick={handleUpdateBrandColor}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button, ColorPicker, Label, Switch } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { updateProductAction } from "./actions";
|
||||
|
||||
interface EditHighlightBorderProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
|
||||
const [showHighlightBorder, setShowHighlightBorder] = useState(product.highlightBorderColor ? true : false);
|
||||
const [color, setColor] = useState<string | null>(product.highlightBorderColor || DEFAULT_BRAND_COLOR);
|
||||
const [updatingBorder, setUpdatingBorder] = useState(false);
|
||||
|
||||
const handleUpdateHighlightBorder = async () => {
|
||||
try {
|
||||
setUpdatingBorder(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
highlightBorderColor: color,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success("Border color updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingBorder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitch = (checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!color) {
|
||||
setColor(DEFAULT_BRAND_COLOR);
|
||||
setShowHighlightBorder(true);
|
||||
} else {
|
||||
setShowHighlightBorder(true);
|
||||
}
|
||||
} else {
|
||||
setShowHighlightBorder(false);
|
||||
setColor(null);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
variant="darkCTA"
|
||||
className="mt-4 flex max-w-[80px] items-center justify-center"
|
||||
loading={updatingBorder}
|
||||
onClick={handleUpdateHighlightBorder}>
|
||||
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 key={num} 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { updateProductAction } from "./actions";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export function EditPlacement({ product }: EditPlacementProps) {
|
||||
const [currentPlacement, setCurrentPlacement] = useState<PlacementType>(product.placement);
|
||||
const [overlay, setOverlay] = useState(product.darkOverlay ? "darkOverlay" : "lightOverlay");
|
||||
const [clickOutside, setClickOutside] = useState(product.clickOutsideClose ? "allow" : "disallow");
|
||||
const [updatingPlacement, setUpdatingPlacement] = useState(false);
|
||||
|
||||
const handleUpdatePlacement = async () => {
|
||||
try {
|
||||
setUpdatingPlacement(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
placement: currentPlacement,
|
||||
darkOverlay: overlay === "darkOverlay",
|
||||
clickOutsideClose: clickOutside === "allow",
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success("Placement updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingPlacement(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex">
|
||||
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Centered modal overlay color</Label>
|
||||
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setClickOutside(e)}
|
||||
value={clickOutside}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button variant="darkCTA" className="mt-4" loading={updatingPlacement} onClick={handleUpdatePlacement}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { updateProductAction } from "./actions";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { Label, Switch } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface EditSignatureProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export function EditFormbricksSignature({ product }: EditSignatureProps) {
|
||||
const [formbricksSignature, setFormbricksSignature] = useState(product.formbricksSignature);
|
||||
const [updatingSignature, setUpdatingSignature] = useState(false);
|
||||
|
||||
const toggleSignature = async () => {
|
||||
try {
|
||||
setUpdatingSignature(true);
|
||||
const newSignatureState = !formbricksSignature;
|
||||
setFormbricksSignature(newSignatureState);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
formbricksSignature: newSignatureState,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success(
|
||||
newSignatureState ? "Formbricks signature will be shown." : "Formbricks signature will now be hidden."
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingSignature(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="signature"
|
||||
checked={formbricksSignature}
|
||||
onCheckedChange={toggleSignature}
|
||||
disabled={updatingSignature}
|
||||
/>
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { updateProduct } from "@formbricks/lib/services/product";
|
||||
import { TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
|
||||
export async function updateProductAction(productId: string, inputProduct: Partial<TProductUpdateInput>) {
|
||||
return await updateProduct(productId, inputProduct);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { useProductMutation } from "@/lib/products/mutateProducts";
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import {
|
||||
Button,
|
||||
ColorPicker,
|
||||
ErrorComponent,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Switch,
|
||||
} from "@formbricks/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
|
||||
export function EditBrandColor({ environmentId }) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [color, setColor] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (product) setColor(product.brandColor);
|
||||
}, [product]);
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProduct) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm items-center">
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProduct}
|
||||
onClick={() => {
|
||||
triggerProductMutate({ brandColor: color })
|
||||
.then(() => {
|
||||
toast.success("Brand color updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditPlacement({ environmentId }) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [currentPlacement, setCurrentPlacement] = useState<PlacementType>("bottomRight");
|
||||
const [overlay, setOverlay] = useState("");
|
||||
const [clickOutside, setClickOutside] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setCurrentPlacement(product.placement);
|
||||
setOverlay(product.darkOverlay ? "darkOverlay" : "lightOverlay");
|
||||
setClickOutside(product.clickOutsideClose ? "allow" : "disallow");
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProduct) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex">
|
||||
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Centered modal overlay color</Label>
|
||||
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setClickOutside(e)}
|
||||
value={clickOutside}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProduct}
|
||||
onClick={() => {
|
||||
triggerProductMutate({
|
||||
placement: currentPlacement,
|
||||
darkOverlay: overlay === "darkOverlay",
|
||||
clickOutsideClose: clickOutside === "allow",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Placement updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditFormbricksSignature({ environmentId }) {
|
||||
const { isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [formbricksSignature, setFormbricksSignature] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormbricksSignature(product.formbricksSignature);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const toggleSignature = () => {
|
||||
const newSignatureState = !formbricksSignature;
|
||||
setFormbricksSignature(newSignatureState);
|
||||
triggerProductMutate({ formbricksSignature: newSignatureState })
|
||||
.then(() => {
|
||||
toast.success(newSignatureState ? "Formbricks signature shown." : "Formbricks signature hidden.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingEnvironment || isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorEnvironment || isErrorProduct) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
if (formbricksSignature !== null) {
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="signature"
|
||||
checked={formbricksSignature}
|
||||
onCheckedChange={toggleSignature}
|
||||
disabled={isMutatingProduct}
|
||||
/>
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import SettingsCard from "@/app/(app)/environments/[environmentId]/settings/SettingsCard";
|
||||
import SettingsTitle from "@/app/(app)/environments/[environmentId]/settings/SettingsTitle";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button, Label, RadioGroup, RadioGroupItem, Switch } from "@formbricks/ui";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Look & Feel" />
|
||||
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
|
||||
<div className="w-full max-w-sm items-center">
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<div className="my-2">
|
||||
<div className="flex w-full items-center justify-between space-x-1 rounded-md border border-slate-300 px-2 text-sm text-slate-400">
|
||||
<div className="ml-2 mr-2 h-10 w-32 border-0 bg-transparent text-slate-500 outline-none focus:border-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-gray-200">
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<div className="w-full items-center">
|
||||
<div className="flex cursor-not-allowed select-none">
|
||||
<RadioGroup>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap ">
|
||||
<RadioGroupItem
|
||||
className="cursor-not-allowed select-none"
|
||||
id={placement.value}
|
||||
value={placement.value}
|
||||
disabled={placement.disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(
|
||||
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
|
||||
)}>
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-gray-200">
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
noPadding
|
||||
title="Highlight Border"
|
||||
description="Make sure your users notice the survey you display">
|
||||
<div className="flex min-h-full w-full">
|
||||
<div className="flex w-1/2 flex-col px-6 py-5">
|
||||
<div className="pointer-events-none mb-6 flex cursor-not-allowed select-none items-center space-x-2">
|
||||
<Switch id="highlightBorder" checked={false} />
|
||||
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none mt-4 flex max-w-[100px] animate-pulse cursor-not-allowed select-none items-center justify-center">
|
||||
Loading
|
||||
</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")}>
|
||||
<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 key={num} 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>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<div className="w-full items-center">
|
||||
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
|
||||
<Switch id="signature" checked={false} />
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature</Label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,37 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import { EditBrandColor, EditPlacement, EditFormbricksSignature } from "./editLookAndFeel";
|
||||
import { EditFormbricksSignature } from "./EditSignature";
|
||||
import { EditBrandColor } from "./EditBrandColor";
|
||||
import { EditPlacement } from "./EditPlacement";
|
||||
import { EditHighlightBorder } from "./EditHighlightBorder";
|
||||
|
||||
export default function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Look & Feel" />
|
||||
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
|
||||
<EditBrandColor environmentId={params.environmentId} />
|
||||
<EditBrandColor product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<EditPlacement environmentId={params.environmentId} />
|
||||
<EditPlacement product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
noPadding
|
||||
title="Highlight Border"
|
||||
description="Make sure your users notice the survey you display">
|
||||
<EditHighlightBorder product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<EditFormbricksSignature environmentId={params.environmentId} />
|
||||
<EditFormbricksSignature product={product} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,46 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import FormbricksSignature from "@/components/preview/FormbricksSignature";
|
||||
import Modal from "@/components/preview/Modal";
|
||||
import Progress from "@/components/preview/Progress";
|
||||
import QuestionConditional from "@/components/preview/QuestionConditional";
|
||||
import TabOption from "@/components/preview/TabOption";
|
||||
import ThankYouCard from "@/components/preview/ThankYouCard";
|
||||
import type { Logic, Question } from "@formbricks/types/questions";
|
||||
import { Survey } from "@formbricks/types/surveys";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { ComputerDesktopIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/solid";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
interface PreviewSurveyProps {
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId?: string | null;
|
||||
@@ -24,6 +28,77 @@ interface PreviewSurveyProps {
|
||||
environment: TEnvironment;
|
||||
}
|
||||
|
||||
function QuestionRenderer({
|
||||
activeQuestionId,
|
||||
lastActiveQuestionId,
|
||||
questions,
|
||||
brandColor,
|
||||
thankYouCard,
|
||||
gotoNextQuestion,
|
||||
showBackButton,
|
||||
goToPreviousQuestion,
|
||||
storedResponseValue,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
|
||||
<ThankYouCard
|
||||
brandColor={brandColor}
|
||||
headline={thankYouCard?.headline || "Thank you!"}
|
||||
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
|
||||
/>
|
||||
) : (
|
||||
questions.map((question, idx) =>
|
||||
(activeQuestionId || lastActiveQuestionId) === question.id ? (
|
||||
<QuestionConditional
|
||||
key={question.id}
|
||||
question={question}
|
||||
brandColor={brandColor}
|
||||
lastQuestion={idx === questions.length - 1}
|
||||
onSubmit={gotoNextQuestion}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={gotoNextQuestion}
|
||||
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
|
||||
autoFocus={false}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewModalContent({
|
||||
activeQuestionId,
|
||||
lastActiveQuestionId,
|
||||
questions,
|
||||
brandColor,
|
||||
thankYouCard,
|
||||
gotoNextQuestion,
|
||||
showBackButton,
|
||||
goToPreviousQuestion,
|
||||
storedResponseValue,
|
||||
showFormbricksSignature,
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-6 sm:p-6">
|
||||
<QuestionRenderer
|
||||
activeQuestionId={activeQuestionId}
|
||||
lastActiveQuestionId={lastActiveQuestionId}
|
||||
questions={questions}
|
||||
brandColor={brandColor}
|
||||
thankYouCard={thankYouCard}
|
||||
gotoNextQuestion={gotoNextQuestion}
|
||||
showBackButton={showBackButton}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
storedResponseValue={storedResponseValue}
|
||||
/>
|
||||
|
||||
{showFormbricksSignature && <FormbricksSignature />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreviewSurvey({
|
||||
setActiveQuestionId,
|
||||
activeQuestionId,
|
||||
@@ -41,6 +116,12 @@ export default function PreviewSurvey({
|
||||
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
|
||||
const [lastActiveQuestionId, setLastActiveQuestionId] = useState("");
|
||||
const [showFormbricksSignature, setShowFormbricksSignature] = useState(false);
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [storedResponseValue, setStoredResponseValue] = useState<any>();
|
||||
const [storedResponse, setStoredResponse] = useState<Record<string, any>>({});
|
||||
const [previewMode, setPreviewMode] = useState("desktop");
|
||||
const showBackButton = progress !== 0 && !finished;
|
||||
const ContentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
@@ -101,6 +182,13 @@ export default function PreviewSurvey({
|
||||
}, [autoClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ContentRef.current) {
|
||||
// scroll to top whenever question changes
|
||||
ContentRef.current.scrollTop = 0;
|
||||
}
|
||||
if (activeQuestionId !== "end") {
|
||||
setFinished(false);
|
||||
}
|
||||
if (activeQuestionId) {
|
||||
setLastActiveQuestionId(activeQuestionId);
|
||||
setProgress(calculateProgress(questions, activeQuestionId));
|
||||
@@ -129,54 +217,56 @@ export default function PreviewSurvey({
|
||||
}
|
||||
}, [activeQuestionId, surveyType, questions, setActiveQuestionId, thankYouCard]);
|
||||
|
||||
function evaluateCondition(logic: Logic, answerValue: any): boolean {
|
||||
function evaluateCondition(logic: Logic, responseValue: any): boolean {
|
||||
switch (logic.condition) {
|
||||
case "equals":
|
||||
return (
|
||||
(Array.isArray(answerValue) && answerValue.length === 1 && answerValue.includes(logic.value)) ||
|
||||
answerValue.toString() === logic.value
|
||||
(Array.isArray(responseValue) &&
|
||||
responseValue.length === 1 &&
|
||||
responseValue.includes(logic.value)) ||
|
||||
responseValue.toString() === logic.value
|
||||
);
|
||||
case "notEquals":
|
||||
return answerValue !== logic.value;
|
||||
return responseValue !== logic.value;
|
||||
case "lessThan":
|
||||
return logic.value !== undefined && answerValue < logic.value;
|
||||
return logic.value !== undefined && responseValue < logic.value;
|
||||
case "lessEqual":
|
||||
return logic.value !== undefined && answerValue <= logic.value;
|
||||
return logic.value !== undefined && responseValue <= logic.value;
|
||||
case "greaterThan":
|
||||
return logic.value !== undefined && answerValue > logic.value;
|
||||
return logic.value !== undefined && responseValue > logic.value;
|
||||
case "greaterEqual":
|
||||
return logic.value !== undefined && answerValue >= logic.value;
|
||||
return logic.value !== undefined && responseValue >= logic.value;
|
||||
case "includesAll":
|
||||
return (
|
||||
Array.isArray(answerValue) &&
|
||||
Array.isArray(responseValue) &&
|
||||
Array.isArray(logic.value) &&
|
||||
logic.value.every((v) => answerValue.includes(v))
|
||||
logic.value.every((v) => responseValue.includes(v))
|
||||
);
|
||||
case "includesOne":
|
||||
return (
|
||||
Array.isArray(answerValue) &&
|
||||
Array.isArray(responseValue) &&
|
||||
Array.isArray(logic.value) &&
|
||||
logic.value.some((v) => answerValue.includes(v))
|
||||
logic.value.some((v) => responseValue.includes(v))
|
||||
);
|
||||
case "accepted":
|
||||
return answerValue === "accepted";
|
||||
return responseValue === "accepted";
|
||||
case "clicked":
|
||||
return answerValue === "clicked";
|
||||
return responseValue === "clicked";
|
||||
case "submitted":
|
||||
if (typeof answerValue === "string") {
|
||||
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
|
||||
} else if (Array.isArray(answerValue)) {
|
||||
return answerValue.length > 0;
|
||||
} else if (typeof answerValue === "number") {
|
||||
return answerValue !== null;
|
||||
if (typeof responseValue === "string") {
|
||||
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
|
||||
} else if (Array.isArray(responseValue)) {
|
||||
return responseValue.length > 0;
|
||||
} else if (typeof responseValue === "number") {
|
||||
return responseValue !== null;
|
||||
}
|
||||
return false;
|
||||
case "skipped":
|
||||
return (
|
||||
(Array.isArray(answerValue) && answerValue.length === 0) ||
|
||||
answerValue === "" ||
|
||||
answerValue === null ||
|
||||
answerValue === "dismissed"
|
||||
(Array.isArray(responseValue) && responseValue.length === 0) ||
|
||||
responseValue === "" ||
|
||||
responseValue === null ||
|
||||
responseValue === "dismissed"
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
@@ -191,14 +281,14 @@ export default function PreviewSurvey({
|
||||
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
|
||||
if (currentQuestionIndex === -1) throw new Error("Question not found");
|
||||
|
||||
const answerValue = answer[activeQuestionId];
|
||||
const responseValue = answer[activeQuestionId];
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
|
||||
if (currentQuestion.logic && currentQuestion.logic.length > 0) {
|
||||
for (let logic of currentQuestion.logic) {
|
||||
if (!logic.destination) continue;
|
||||
|
||||
if (evaluateCondition(logic, answerValue)) {
|
||||
if (evaluateCondition(logic, responseValue)) {
|
||||
return logic.destination;
|
||||
}
|
||||
}
|
||||
@@ -207,11 +297,13 @@ export default function PreviewSurvey({
|
||||
}
|
||||
|
||||
const gotoNextQuestion = (data) => {
|
||||
setStoredResponse({ ...storedResponse, ...data });
|
||||
const nextQuestionId = getNextQuestion(data);
|
||||
|
||||
setStoredResponseValue(storedResponse[nextQuestionId]);
|
||||
if (nextQuestionId !== "end") {
|
||||
setActiveQuestionId(nextQuestionId);
|
||||
} else {
|
||||
setFinished(true);
|
||||
if (thankYouCard?.enabled) {
|
||||
setActiveQuestionId("thank-you-card");
|
||||
setProgress(1);
|
||||
@@ -225,6 +317,21 @@ export default function PreviewSurvey({
|
||||
}
|
||||
};
|
||||
|
||||
function goToPreviousQuestion(data: any) {
|
||||
setStoredResponse({ ...storedResponse, ...data });
|
||||
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
|
||||
if (currentQuestionIndex === -1) throw new Error("Question not found");
|
||||
const previousQuestionId = questions[currentQuestionIndex - 1].id;
|
||||
setStoredResponseValue(storedResponse[previousQuestionId]);
|
||||
setActiveQuestionId(previousQuestionId);
|
||||
}
|
||||
|
||||
function resetQuestionProgress() {
|
||||
setProgress(0);
|
||||
setActiveQuestionId(questions[0].id);
|
||||
setStoredResponse({});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && environment.widgetSetupCompleted) {
|
||||
setWidgetSetupCompleted(true);
|
||||
@@ -242,87 +349,159 @@ export default function PreviewSurvey({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-5/6 flex-1 flex-col rounded-lg border border-slate-300 bg-slate-200 ">
|
||||
<div className="flex h-8 items-center rounded-t-lg bg-slate-100">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<p>
|
||||
<span className="ml-4 font-mono text-sm text-slate-400">
|
||||
{previewType === "modal" ? "Your web app" : "Preview"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{previewType === "modal" ? (
|
||||
<Modal isOpen={isModalOpen} placement={product.placement}>
|
||||
{!countdownStop && autoClose !== null && autoClose > 0 && (
|
||||
<Progress progress={countdownProgress} brandColor={brandColor} />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleStopCountdown()}
|
||||
onMouseOver={() => handleStopCountdown()}
|
||||
className="px-4 py-6 sm:p-6">
|
||||
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
|
||||
<ThankYouCard
|
||||
brandColor={brandColor}
|
||||
headline={thankYouCard?.headline || "Thank you!"}
|
||||
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
|
||||
/>
|
||||
) : (
|
||||
questions.map((question, idx) =>
|
||||
(activeQuestionId || lastActiveQuestionId) === question.id ? (
|
||||
<QuestionConditional
|
||||
key={question.id}
|
||||
question={question}
|
||||
<div className="flex h-full w-full flex-col items-center justify-items-center">
|
||||
<div className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
|
||||
{previewMode === "mobile" && (
|
||||
<>
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
|
||||
</div>
|
||||
<div className="relative h-[90%] max-h-[40rem] w-80 overflow-hidden rounded-[3rem] border-8 border-slate-500 bg-slate-400">
|
||||
{/* below element is use to create notch for the mobile device mockup */}
|
||||
<div className="absolute left-1/2 right-1/2 top-0 z-20 h-4 w-1/2 -translate-x-1/2 transform rounded-b-md bg-slate-500"></div>
|
||||
{previewType === "modal" ? (
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
placement={product.placement}
|
||||
highlightBorderColor={product.highlightBorderColor}
|
||||
previewMode="mobile">
|
||||
{!countdownStop && autoClose !== null && autoClose > 0 && (
|
||||
<Progress progress={countdownProgress} brandColor={brandColor} />
|
||||
)}
|
||||
<PreviewModalContent
|
||||
activeQuestionId={activeQuestionId}
|
||||
lastActiveQuestionId={lastActiveQuestionId}
|
||||
questions={questions}
|
||||
brandColor={brandColor}
|
||||
lastQuestion={idx === questions.length - 1}
|
||||
onSubmit={gotoNextQuestion}
|
||||
autoFocus={false}
|
||||
thankYouCard={thankYouCard}
|
||||
gotoNextQuestion={gotoNextQuestion}
|
||||
showBackButton={showBackButton}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
storedResponseValue={storedResponseValue}
|
||||
showFormbricksSignature={showFormbricksSignature}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
{showFormbricksSignature && <FormbricksSignature />}
|
||||
</div>
|
||||
<Progress progress={progress} brandColor={brandColor} />
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="flex flex-grow flex-col overflow-y-auto">
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
|
||||
<div className="w-full max-w-md">
|
||||
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
|
||||
<ThankYouCard
|
||||
brandColor={brandColor}
|
||||
headline={thankYouCard?.headline || "Thank you!"}
|
||||
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
|
||||
/>
|
||||
<Progress progress={progress} brandColor={brandColor} />
|
||||
</Modal>
|
||||
) : (
|
||||
questions.map((question, idx) =>
|
||||
(activeQuestionId || lastActiveQuestionId) === question.id ? (
|
||||
<QuestionConditional
|
||||
key={question.id}
|
||||
question={question}
|
||||
brandColor={brandColor}
|
||||
lastQuestion={idx === questions.length - 1}
|
||||
onSubmit={gotoNextQuestion}
|
||||
autoFocus={false}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
<div
|
||||
className="absolute top-0 z-10 flex h-full w-full flex-grow flex-col overflow-y-auto"
|
||||
ref={ContentRef}>
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
|
||||
<div className="w-full max-w-md px-4">
|
||||
<QuestionRenderer
|
||||
activeQuestionId={activeQuestionId}
|
||||
lastActiveQuestionId={lastActiveQuestionId}
|
||||
questions={questions}
|
||||
brandColor={brandColor}
|
||||
thankYouCard={thankYouCard}
|
||||
gotoNextQuestion={gotoNextQuestion}
|
||||
showBackButton={showBackButton}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
storedResponseValue={storedResponseValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 w-full rounded-b-lg bg-white">
|
||||
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
|
||||
<Progress progress={progress} brandColor={brandColor} />
|
||||
{showFormbricksSignature && <FormbricksSignature />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 w-full rounded-b-lg bg-white">
|
||||
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
|
||||
<Progress progress={progress} brandColor={brandColor} />
|
||||
{showFormbricksSignature && <FormbricksSignature />}
|
||||
</>
|
||||
)}
|
||||
{previewMode === "desktop" && (
|
||||
<div className="flex h-full w-5/6 flex-1 flex-col">
|
||||
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
|
||||
<div className="ml-6 flex space-x-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<p className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
{previewType === "modal" ? "Your web app" : "Preview"}
|
||||
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{previewType === "modal" ? (
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
placement={product.placement}
|
||||
highlightBorderColor={product.highlightBorderColor}
|
||||
previewMode="desktop">
|
||||
{!countdownStop && autoClose !== null && autoClose > 0 && (
|
||||
<Progress progress={countdownProgress} brandColor={brandColor} />
|
||||
)}
|
||||
<PreviewModalContent
|
||||
activeQuestionId={activeQuestionId}
|
||||
lastActiveQuestionId={lastActiveQuestionId}
|
||||
questions={questions}
|
||||
brandColor={brandColor}
|
||||
thankYouCard={thankYouCard}
|
||||
gotoNextQuestion={gotoNextQuestion}
|
||||
showBackButton={showBackButton}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
storedResponseValue={storedResponseValue}
|
||||
showFormbricksSignature={showFormbricksSignature}
|
||||
/>
|
||||
<Progress progress={progress} brandColor={brandColor} />
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="flex flex-grow flex-col overflow-y-auto" ref={ContentRef}>
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
|
||||
<div className="w-full max-w-md">
|
||||
<QuestionRenderer
|
||||
activeQuestionId={activeQuestionId}
|
||||
lastActiveQuestionId={lastActiveQuestionId}
|
||||
questions={questions}
|
||||
brandColor={brandColor}
|
||||
thankYouCard={thankYouCard}
|
||||
gotoNextQuestion={gotoNextQuestion}
|
||||
showBackButton={showBackButton}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
storedResponseValue={storedResponseValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 w-full rounded-b-lg bg-white">
|
||||
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
|
||||
<Progress progress={progress} brandColor={brandColor} />
|
||||
{showFormbricksSignature && <FormbricksSignature />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<TabOption
|
||||
active={previewMode === "mobile"}
|
||||
icon={<DevicePhoneMobileIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
|
||||
onClick={() => setPreviewMode("mobile")}
|
||||
/>
|
||||
<TabOption
|
||||
active={previewMode === "desktop"}
|
||||
icon={<ComputerDesktopIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
|
||||
onClick={() => setPreviewMode("desktop")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetProgressButton({ resetQuestionProgress }) {
|
||||
return (
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="py-0.2 bg-white px-2 text-sm text-slate-500"
|
||||
onClick={resetQuestionProgress}>
|
||||
Restart
|
||||
<ArrowPathRoundedSquareIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ export default function SurveyStarter({
|
||||
const router = useRouter();
|
||||
const newSurveyFromTemplate = async (template: Template) => {
|
||||
setIsCreateSurveyLoading(true);
|
||||
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
|
||||
const autoComplete = surveyType === "web" ? 50 : null;
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: environment?.widgetSetupCompleted ? "web" : "link",
|
||||
type: surveyType,
|
||||
autoComplete,
|
||||
};
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||