mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
formhq: add simple layout, add forms overview, add customers overview, add api endpoints
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"@headlessui/react": "^1.7.4",
|
||||
"@heroicons/react": "^2.0.13",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^2.29.3",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"next": "^13.0.5",
|
||||
|
||||
@@ -5,24 +5,14 @@ import { Bars3Icon, BellIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Logo } from "../Logo";
|
||||
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
|
||||
import Link from "next/link";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import clsx from "clsx";
|
||||
|
||||
const navigation = [
|
||||
/* { name: "Forms", href: "#", current: true },
|
||||
{ name: "Team", href: "#", current: false },
|
||||
{ name: "Projects", href: "#", current: false },
|
||||
{ name: "Calendar", href: "#", current: false }, */
|
||||
];
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function ProjectsLayout({ children }) {
|
||||
export default function ProjectsLayout({ params, children }) {
|
||||
const router = useRouter();
|
||||
const userNavigation = [
|
||||
{
|
||||
@@ -46,13 +36,14 @@ export default function ProjectsLayout({ children }) {
|
||||
router.push(`/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`);
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-full">
|
||||
<div className="h-screen">
|
||||
<Disclosure as="nav" className="border-b border-gray-200 bg-white">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto w-full px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 justify-between">
|
||||
<div className="flex">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
@@ -60,22 +51,6 @@ export default function ProjectsLayout({ children }) {
|
||||
<Logo className="block h-8 w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "border-indigo-500 text-gray-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:items-center">
|
||||
{/* <button
|
||||
@@ -113,7 +88,7 @@ export default function ProjectsLayout({ children }) {
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={item.onClick}
|
||||
className={classNames(
|
||||
className={clsx(
|
||||
active ? "bg-gray-100" : "",
|
||||
"flex w-full justify-start px-4 py-2 text-sm text-gray-700"
|
||||
)}>
|
||||
@@ -141,23 +116,6 @@ export default function ProjectsLayout({ children }) {
|
||||
</div>
|
||||
|
||||
<Disclosure.Panel className="sm:hidden">
|
||||
<div className="space-y-1 pt-2 pb-3">
|
||||
{navigation.map((item) => (
|
||||
<Disclosure.Button
|
||||
key={item.name}
|
||||
as="a"
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "border-indigo-500 bg-indigo-50 text-indigo-700"
|
||||
: "border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800",
|
||||
"block border-l-4 py-2 pl-3 pr-4 text-base font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
{item.name}
|
||||
</Disclosure.Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 pb-3">
|
||||
<div className="flex items-center px-4">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -195,11 +153,7 @@ export default function ProjectsLayout({ children }) {
|
||||
)}
|
||||
</Disclosure>
|
||||
|
||||
<div className="py-10">
|
||||
<main>
|
||||
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
<main className="h-full">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useMemberships } from "@/lib/memberships";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { memberships, isErrorMemberships } = useMemberships();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (memberships) {
|
||||
const teamId = memberships[0].teamId;
|
||||
router.push(`/app/teams/${teamId}/forms`);
|
||||
}
|
||||
}, [memberships, router]);
|
||||
|
||||
if (isErrorMemberships) {
|
||||
return <div>Something went wrong...</div>;
|
||||
}
|
||||
return (
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">Dashboard</h1>
|
||||
<div className="max-w-3xl bg-white shadow sm:rounded-lg">
|
||||
<div className="mt-8 px-4 py-5 sm:p-6 ">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Welcome to Formbricks HQ</h3>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
Formbricks HQ is your backend for Form & Survey Data. Collect data from any form, store and
|
||||
analyze it in Formbricks HQ or pipe it to third party services.
|
||||
<br />
|
||||
<br />
|
||||
To get started read the docs first or go directly to your account settings to create a new
|
||||
personal API Key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button variant="secondary" href="/app/me/settings">
|
||||
Create API Key
|
||||
</Button>
|
||||
<Button className="ml-3" href="https://formbricks.com/docs">
|
||||
Read the Docs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
apps/hq/src/app/app/teams/[teamId]/customers/page.tsx
Normal file
95
apps/hq/src/app/app/teams/[teamId]/customers/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/app/LoadingSpinner";
|
||||
import EmptyPageFiller from "@/components/EmptyPageFiller";
|
||||
import { useCustomers } from "@/lib/customers";
|
||||
import { useTeam } from "@/lib/teams";
|
||||
import { convertDateTimeString } from "@/lib/utils";
|
||||
import { UsersIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export default function FormsPage({ params }) {
|
||||
const { customers, isLoadingCustomers, isErrorCustomers } = useCustomers(params.teamId);
|
||||
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
|
||||
|
||||
if (isLoadingCustomers || isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorCustomers || isErrorTeam) {
|
||||
return <div>Error loading ressources. Maybe you don‘t have enough access rights</div>;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
|
||||
Customers
|
||||
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
</h1>
|
||||
</header>
|
||||
{customers.length === 0 ? (
|
||||
<EmptyPageFiller
|
||||
alertText={"We don't know your customers yet"}
|
||||
hintText={
|
||||
"Make a first submission and reference your customer. We will collect all submission from them accross all of your forms and display them here."
|
||||
}>
|
||||
<UsersIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
|
||||
</EmptyPageFiller>
|
||||
) : (
|
||||
<div className="mt-8 flex flex-col">
|
||||
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">
|
||||
Id
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||
created At
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||
# submissions
|
||||
</th>
|
||||
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span className="sr-only">View</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
{customers.map((customer, customerIdx) => (
|
||||
<tr key={customer.email} className={customerIdx % 2 === 0 ? undefined : "bg-gray-50"}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||
{customer.id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{convertDateTimeString(customer.createdAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{customer._count?.submissions}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<a href="#" className="text-brand-dark hover:text-brand-light">
|
||||
View<span className="sr-only">, {customer.name}</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
apps/hq/src/app/app/teams/[teamId]/forms/FormsList.tsx
Normal file
141
apps/hq/src/app/app/teams/[teamId]/forms/FormsList.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import EmptyPageFiller from "@/components/EmptyPageFiller";
|
||||
import { deleteForm, useForms } from "@/lib/forms";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { CommandLineIcon, DocumentPlusIcon, PlusIcon, SquaresPlusIcon } from "@heroicons/react/24/outline";
|
||||
import { EllipsisHorizontalIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import NewFormModal from "./NewFormModal";
|
||||
|
||||
export default function FormsList({ teamId }) {
|
||||
const { forms, mutateForms } = useForms(teamId);
|
||||
const [openNewFormModal, setOpenNewFormModal] = useState(false);
|
||||
|
||||
const newForm = async () => {
|
||||
setOpenNewFormModal(true);
|
||||
};
|
||||
|
||||
const deleteFormAction = async (form, formIdx) => {
|
||||
try {
|
||||
await deleteForm(teamId, form.id);
|
||||
// remove locally
|
||||
const updatedForms = JSON.parse(JSON.stringify(forms));
|
||||
updatedForms.splice(formIdx, 1);
|
||||
mutateForms(updatedForms);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full">
|
||||
{forms &&
|
||||
(forms.length === 0 ? (
|
||||
<div className="mt-5 text-center">
|
||||
<EmptyPageFiller
|
||||
onClick={() => newForm()}
|
||||
alertText="You don't have any forms yet."
|
||||
hintText="Start by creating a form."
|
||||
buttonText="create form"
|
||||
borderStyles="border-4 border-dotted border-red"
|
||||
hasButton={true}>
|
||||
<DocumentPlusIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
|
||||
</EmptyPageFiller>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 ">
|
||||
<button onClick={() => newForm()}>
|
||||
<li className="col-span-1 h-56">
|
||||
<div className="from-brand-light to-brand-dark delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-b font-light text-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-4 py-8 sm:p-14">
|
||||
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
|
||||
create form
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</button>
|
||||
{forms
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((form, formIdx) => (
|
||||
<li key={form.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="p-6">
|
||||
<p className="line-clamp-3 text-lg">{form.label}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/app/teams/${teamId}/forms/${form.id}`}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100 ">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<p className="text-xs text-slate-400 ">{form._count?.submissions} submissions</p>
|
||||
<Menu as="div" className="relative z-10 inline-block text-left">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="text-red -m-2 flex items-center rounded-full p-2">
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="absolute left-0 mt-2 w-56 origin-top-right rounded-sm bg-white px-1 shadow-lg">
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to delete this form? This also deletes all submissions that are captures with this form. This action cannot be undone."
|
||||
)
|
||||
) {
|
||||
deleteFormAction(form, formIdx);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
active
|
||||
? "text-ui-black rounded-sm bg-slate-100"
|
||||
: "text-slate-800",
|
||||
"flex w-full px-4 py-2 text-sm"
|
||||
)}>
|
||||
<TrashIcon
|
||||
className="mr-3 h-5 w-5 text-slate-800"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Delete Form</span>
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
<NewFormModal open={openNewFormModal} setOpen={setOpenNewFormModal} teamId={teamId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
apps/hq/src/app/app/teams/[teamId]/forms/NewFormModal.tsx
Normal file
102
apps/hq/src/app/app/teams/[teamId]/forms/NewFormModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, RadioGroup, Transition } from "@headlessui/react";
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Fragment, useState } from "react";
|
||||
import { BsPlus } from "react-icons/bs";
|
||||
import { createForm } from "@/lib/forms";
|
||||
import clsx from "clsx";
|
||||
import { Button } from "@formbricks/ui";
|
||||
|
||||
type FormOnboardingModalProps = {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export default function NewFormModal({ open, setOpen, teamId }: FormOnboardingModalProps) {
|
||||
const router = useRouter();
|
||||
const [label, setLabel] = useState("");
|
||||
|
||||
const createFormAction = async (e) => {
|
||||
e.preventDefault();
|
||||
const form = await createForm(teamId, {
|
||||
label,
|
||||
});
|
||||
router.push(`app/teams/${teamId}/forms/${form.id}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={setOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-30 backdrop-blur-md transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-0 focus:ring-offset-2"
|
||||
onClick={() => setOpen(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between">
|
||||
<h2 className="flex-none p-2 text-xl font-bold text-slate-800">Create new form</h2>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => createFormAction(e)}
|
||||
className="inline-block w-full transform overflow-hidden p-2 text-left align-bottom transition-all sm:align-middle">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-sm font-light text-slate-800">
|
||||
Name your form
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
name="label"
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-gray-300 shadow-sm sm:text-sm"
|
||||
placeholder="e.g. Customer Research Survey"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<Button type="submit" className="w-full justify-center">
|
||||
create form
|
||||
<BsPlus className="ml-1 h-6 w-6"></BsPlus>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
34
apps/hq/src/app/app/teams/[teamId]/forms/[formId]/page.tsx
Normal file
34
apps/hq/src/app/app/teams/[teamId]/forms/[formId]/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/app/LoadingSpinner";
|
||||
import { useForm } from "@/lib/forms";
|
||||
import { useTeam } from "@/lib/teams";
|
||||
|
||||
export default function FormsPage({ params }) {
|
||||
const { form, isLoadingForm, isErrorForm } = useForm(params.formId, params.teamId);
|
||||
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
|
||||
|
||||
if (isLoadingForm || isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorForm || isErrorTeam) {
|
||||
return <div>Error loading ressources. Maybe you don‘t have enough access rights</div>;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
|
||||
{form.label}
|
||||
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
</h1>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
apps/hq/src/app/app/teams/[teamId]/forms/page.tsx
Normal file
36
apps/hq/src/app/app/teams/[teamId]/forms/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/app/LoadingSpinner";
|
||||
import { useForms } from "@/lib/forms";
|
||||
import { useTeam } from "@/lib/teams";
|
||||
import FormsList from "./FormsList";
|
||||
|
||||
export default function FormsPage({ params }) {
|
||||
const { forms, isLoadingForms, isErrorForms } = useForms(params.teamId);
|
||||
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
|
||||
|
||||
if (isLoadingForms || isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorForms || isErrorTeam) {
|
||||
return <div>Error loading ressources. Maybe you don‘t have enough access rights</div>;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
|
||||
Forms
|
||||
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
</h1>
|
||||
</header>
|
||||
<FormsList teamId={params.teamId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
apps/hq/src/app/app/teams/[teamId]/layout.tsx
Normal file
175
apps/hq/src/app/app/teams/[teamId]/layout.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Cog8ToothIcon, RectangleStackIcon, UsersIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function Example({ children, params }) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const sidebarNavigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Forms",
|
||||
href: `/app/teams/${params.teamId}/forms`,
|
||||
icon: RectangleStackIcon,
|
||||
current: pathname.includes("/form"),
|
||||
},
|
||||
{
|
||||
name: "Customers",
|
||||
href: `/app/teams/${params.teamId}/customers`,
|
||||
icon: UsersIcon,
|
||||
current: pathname.includes("/customers"),
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: `/app/teams/${params.teamId}/settings`,
|
||||
icon: Cog8ToothIcon,
|
||||
current: pathname.includes("/settings"),
|
||||
},
|
||||
],
|
||||
[params, pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
This example requires updating your template:
|
||||
|
||||
```
|
||||
<html class="h-full bg-gray-50">
|
||||
<body class="h-full overflow-hidden">
|
||||
```
|
||||
*/}
|
||||
<div className="flex h-full">
|
||||
{/* Narrow sidebar */}
|
||||
<div className="bg-brand-dark hidden w-28 overflow-y-auto bg-gradient-to-r md:block">
|
||||
<div className="flex w-full flex-col items-center py-6">
|
||||
<div className="w-full flex-1 space-y-1 px-2">
|
||||
{sidebarNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "bg-brand-light text-white"
|
||||
: "hover:bg-brand-light text-teal-100 hover:text-white",
|
||||
"group flex w-full flex-col items-center rounded-md p-3 text-xs font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current ? "text-white" : "text-teal-300 group-hover:text-white",
|
||||
"h-6 w-6"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="mt-2">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<Transition.Root show={mobileMenuOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20 md:hidden" onClose={setMobileMenuOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-40 flex">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full">
|
||||
<Dialog.Panel className="bg-brand-light relative flex w-full max-w-xs flex-1 flex-col pt-5 pb-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="absolute top-1 right-0 -mr-14 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-white"
|
||||
onClick={() => setMobileMenuOpen(false)}>
|
||||
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<img
|
||||
className="h-8 w-auto"
|
||||
src="https://tailwindui.com/img/logos/mark.svg?color=white"
|
||||
alt="Your Company"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 h-0 flex-1 overflow-y-auto px-2">
|
||||
<nav className="flex h-full flex-col">
|
||||
<div className="space-y-1">
|
||||
{sidebarNavigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "bg-brand-dark text-white"
|
||||
: "hover:bg-brand-dark text-teal-100 hover:text-white",
|
||||
"group flex items-center rounded-md py-2 px-3 text-sm font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current ? "text-white" : "text-teal-300 group-hover:text-white",
|
||||
"mr-3 h-6 w-6"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
<div className="w-14 flex-shrink-0" aria-hidden="true">
|
||||
{/* Dummy element to force sidebar to shrink to fit close icon */}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 items-stretch overflow-hidden">
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
apps/hq/src/app/app/teams/[teamId]/settings/page.tsx
Normal file
34
apps/hq/src/app/app/teams/[teamId]/settings/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/app/LoadingSpinner";
|
||||
import { useForms } from "@/lib/forms";
|
||||
import { useTeam } from "@/lib/teams";
|
||||
|
||||
export default function FormsPage({ params }) {
|
||||
const { forms, isLoadingForms, isErrorForms } = useForms(params.teamId);
|
||||
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
|
||||
|
||||
if (isLoadingForms || isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorForms || isErrorTeam) {
|
||||
return <div>Error loading ressources. Maybe you don‘t have enough access rights</div>;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto py-8 sm:px-6 lg:px-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
|
||||
Settings
|
||||
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
</h1>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const PasswordResetForm = ({}) => {
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ResetPasswordForm = ({ token }) => {
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
New password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
||||
const router = useRouter();
|
||||
|
||||
if (session) {
|
||||
router.push("/projects");
|
||||
router.push("/app");
|
||||
}
|
||||
return (
|
||||
<div className="isolate bg-white">
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function SignInPage() {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="bg-ui-gray-light flex min-h-screen">
|
||||
<div className="flex min-h-screen bg-slate-100">
|
||||
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
|
||||
{error && (
|
||||
<div className="absolute top-10 rounded-md bg-red-50 p-4">
|
||||
@@ -49,7 +49,7 @@ export default function SignInPage() {
|
||||
action="/api/auth/callback/credentials"
|
||||
className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -59,12 +59,12 @@ export default function SignInPage() {
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="placeholder-ui-gray-medium border-ui-gray-medium ph-no-capture block w-full appearance-none rounded-md border px-3 py-2 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
|
||||
className="ph-no-capture block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-300 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -74,7 +74,7 @@ export default function SignInPage() {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="placeholder-ui-gray-medium border-ui-gray-medium ph-no-capture block w-full appearance-none rounded-md border px-3 py-2 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
|
||||
className="ph-no-capture block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-300 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SigninForm = ({ callbackUrl, error }) => {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("credentials", {
|
||||
callbackUrl: callbackUrl || "/projects",
|
||||
callbackUrl: callbackUrl || "/app",
|
||||
email: e.target.elements.email.value,
|
||||
password: e.target.elements.password.value,
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export const SigninForm = ({ callbackUrl, error }) => {
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -50,7 +50,7 @@ export const SigninForm = ({ callbackUrl, error }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
|
||||
@@ -50,7 +50,7 @@ export const SignupForm = () => {
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-800">
|
||||
Full Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -65,7 +65,7 @@ export const SignupForm = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -80,7 +80,7 @@ export const SignupForm = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
|
||||
39
apps/hq/src/components/EmptyPageFiller.tsx
Normal file
39
apps/hq/src/components/EmptyPageFiller.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@formbricks/ui";
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
alertText: string;
|
||||
hintText: string;
|
||||
buttonText?: string;
|
||||
borderStyles?: string;
|
||||
hasButton?: boolean;
|
||||
}
|
||||
|
||||
const EmptyPageFiller: React.FC<Props> = ({
|
||||
children,
|
||||
onClick = () => {},
|
||||
alertText,
|
||||
hintText,
|
||||
buttonText,
|
||||
borderStyles,
|
||||
hasButton = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`mx-auto mt-8 rounded-lg border border-slate-200 p-8 text-center ` + borderStyles}>
|
||||
{children}
|
||||
<h3 className="mt-5 text-base font-bold text-slate-400">{alertText}</h3>
|
||||
<p className="mt-1 text-xs font-light text-slate-400">{hintText}</p>
|
||||
{hasButton && (
|
||||
<div className="mt-6">
|
||||
<Button onClick={onClick}>{buttonText}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyPageFiller;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createHash } from "crypto";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { unstable_getServerSession } from "next-auth";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
@@ -23,3 +26,23 @@ export const hasOwnership = async (model, session, id) => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSessionOrUser = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// check for session (browser usage)
|
||||
let session = await unstable_getServerSession(req, res, authOptions);
|
||||
if (session && "user" in session) return session.user;
|
||||
// check for api key
|
||||
if (req.headers["x-api-key"]) {
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey: hashApiKey(req.headers["x-api-key"].toString()),
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
if (apiKey && apiKey.user) {
|
||||
return apiKey.user;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
35
apps/hq/src/lib/customers.ts
Normal file
35
apps/hq/src/lib/customers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const useCustomers = (teamId: number) => {
|
||||
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/customers`, fetcher);
|
||||
|
||||
return {
|
||||
customers: data,
|
||||
isLoadingCustomers: !error && !data,
|
||||
isErrorCustomers: error,
|
||||
mutateCustomers: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCustomer = (id: string, teamId: number) => {
|
||||
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/customers/${id}`, fetcher);
|
||||
|
||||
return {
|
||||
customer: data,
|
||||
isLoadingCustomer: !error && !data,
|
||||
isErrorCustomer: error,
|
||||
mutateCustomer: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteCustomer = async (id: string, teamId: number) => {
|
||||
try {
|
||||
await fetch(`/api/teams/${teamId}/customers/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`deleteCustomer: unable to delete customer: ${error.message}`);
|
||||
}
|
||||
};
|
||||
102
apps/hq/src/lib/forms.ts
Normal file
102
apps/hq/src/lib/forms.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const useForms = (teamId) => {
|
||||
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/forms`, fetcher);
|
||||
|
||||
return {
|
||||
forms: data,
|
||||
isLoadingForms: !error && !data,
|
||||
isErrorForms: error,
|
||||
mutateForms: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const useForm = (id: string, teamId: string) => {
|
||||
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/forms/${id}`, fetcher);
|
||||
|
||||
return {
|
||||
form: data,
|
||||
isLoadingForm: !error && !data,
|
||||
isErrorForm: error,
|
||||
mutateForm: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const persistForm = async (form) => {
|
||||
try {
|
||||
await fetch(`/api/forms/${form.id}/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createForm = async (teamId, form = {}) => {
|
||||
try {
|
||||
const res = await fetch(`/api/teams/${teamId}/forms`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`createForm: unable to create form: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteForm = async (teamId, formId) => {
|
||||
try {
|
||||
await fetch(`/api/teams/${teamId}/forms/${formId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`deleteForm: unable to delete form: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const getFormElementFieldSetter = (
|
||||
form: any,
|
||||
mutateForm: (any, boolean?) => void,
|
||||
pageId: string,
|
||||
elementId: string
|
||||
) => {
|
||||
return (input, field, parentField = "") =>
|
||||
setFormElementField(form, mutateForm, pageId, elementId, input, field, parentField);
|
||||
};
|
||||
|
||||
export const setFormElementField = (
|
||||
form: any,
|
||||
mutateForm: (any, boolean?) => void,
|
||||
pageId: string,
|
||||
elementId: string,
|
||||
input: string | number,
|
||||
field: string,
|
||||
parentField: string = ""
|
||||
) => {
|
||||
const updatedForm = JSON.parse(JSON.stringify(form));
|
||||
const elementIdx = getFormPage(updatedForm, pageId).elements.findIndex((e) => e.id === elementId);
|
||||
if (typeof elementIdx === "undefined") {
|
||||
throw Error(`setFormElementField: unable to find element with id ${elementId}`);
|
||||
}
|
||||
if (parentField !== "") {
|
||||
getFormPage(updatedForm, pageId).elements[elementIdx][parentField][field] = input;
|
||||
} else {
|
||||
getFormPage(updatedForm, pageId).elements[elementIdx][field] = input;
|
||||
}
|
||||
mutateForm(updatedForm, false);
|
||||
return updatedForm;
|
||||
};
|
||||
|
||||
export const getFormPage = (form, pageId) => {
|
||||
const page = form.pages.find((p) => p.id === pageId);
|
||||
if (typeof page === "undefined") {
|
||||
throw Error(`getFormPage: unable to find page with id ${pageId}`);
|
||||
}
|
||||
return page;
|
||||
};
|
||||
13
apps/hq/src/lib/memberships.ts
Normal file
13
apps/hq/src/lib/memberships.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const useMemberships = () => {
|
||||
const { data, error, mutate } = useSWR(`/api/memberships`, fetcher);
|
||||
|
||||
return {
|
||||
memberships: data,
|
||||
isLoadingMemberships: !error && !data,
|
||||
isErrorMemberships: error,
|
||||
mutateMemberships: mutate,
|
||||
};
|
||||
};
|
||||
13
apps/hq/src/lib/teams.ts
Normal file
13
apps/hq/src/lib/teams.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const useTeam = (id: string) => {
|
||||
const { data, error, mutate } = useSWR(`/api/teams/${id}/`, fetcher);
|
||||
|
||||
return {
|
||||
team: data,
|
||||
isLoadingTeam: !error && !data,
|
||||
isErrorTeam: error,
|
||||
mutateTeam: mutate,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const formId = parseInt(req.query.formId.toString());
|
||||
if (isNaN(formId)) {
|
||||
return res.status(400).json({ message: "Invalid formId" });
|
||||
}
|
||||
|
||||
// POST/capture/forms/[formId]/submissions
|
||||
// Create a new form submission
|
||||
// Required fields in body: -
|
||||
// Optional fields in body: customerId, data
|
||||
else if (req.method === "POST") {
|
||||
const submission = req.body;
|
||||
|
||||
const event: any = {
|
||||
data: {
|
||||
data: submission.data,
|
||||
form: { connect: { id: formId } },
|
||||
},
|
||||
};
|
||||
|
||||
if (submission.customerId) {
|
||||
// get team
|
||||
const form = await prisma.form.findUnique({
|
||||
where: { id: formId },
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
// create or link customer
|
||||
event.data.customer = {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
id_teamId: {
|
||||
id: submission.customerId,
|
||||
teamId: form.teamId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: submission.customerId,
|
||||
team: { connect: { id: form.teamId } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// create form in db
|
||||
const result = await prisma.submission.create(event);
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
30
apps/hq/src/pages/api/memberships/index.ts
Normal file
30
apps/hq/src/pages/api/memberships/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const session = await getSessionOrUser(req, res);
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
// GET /api/teams
|
||||
// Get all of my teams
|
||||
if (req.method === "GET") {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
user: { email: session.email },
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
return res.json(memberships);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
53
apps/hq/src/pages/api/teams/[teamId]/customers/index.ts
Normal file
53
apps/hq/src/pages/api/teams/[teamId]/customers/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const user: any = await getSessionOrUser(req, res);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const teamId = parseInt(req.query.teamId.toString());
|
||||
if (isNaN(teamId)) {
|
||||
return res.status(400).json({ message: "Invalid teamId" });
|
||||
}
|
||||
|
||||
// check team permission
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership === null) {
|
||||
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
|
||||
}
|
||||
|
||||
// GET /api/teams[teamId]/customers
|
||||
// Get all customers of a specific team
|
||||
if (req.method === "GET") {
|
||||
const forms = await prisma.customer.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { submissions: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(forms);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
60
apps/hq/src/pages/api/teams/[teamId]/forms/[formId]/index.ts
Normal file
60
apps/hq/src/pages/api/teams/[teamId]/forms/[formId]/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const user: any = await getSessionOrUser(req, res);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const teamId = parseInt(req.query.teamId.toString());
|
||||
if (isNaN(teamId)) {
|
||||
return res.status(400).json({ message: "Invalid teamId" });
|
||||
}
|
||||
|
||||
const formId = parseInt(req.query.formId.toString());
|
||||
if (isNaN(formId)) {
|
||||
return res.status(400).json({ message: "Invalid formId" });
|
||||
}
|
||||
|
||||
// check team permission
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership === null) {
|
||||
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
|
||||
}
|
||||
|
||||
// GET /api/teams[teamId]/forms/[formId]
|
||||
// Get a specific team
|
||||
if (req.method === "GET") {
|
||||
const forms = await prisma.form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(forms);
|
||||
}
|
||||
|
||||
// Delete /api/teams[teamId]/forms/[formId]
|
||||
// Deletes a single form
|
||||
else if (req.method === "DELETE") {
|
||||
const prismaRes = await prisma.form.delete({
|
||||
where: { id: formId },
|
||||
});
|
||||
return res.json(prismaRes);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const user: any = await getSessionOrUser(req, res);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const teamId = parseInt(req.query.teamId.toString());
|
||||
if (isNaN(teamId)) {
|
||||
return res.status(400).json({ message: "Invalid teamId" });
|
||||
}
|
||||
|
||||
const formId = parseInt(req.query.formId.toString());
|
||||
if (isNaN(formId)) {
|
||||
return res.status(400).json({ message: "Invalid formId" });
|
||||
}
|
||||
|
||||
// check team permission
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership === null) {
|
||||
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
|
||||
}
|
||||
|
||||
// GET /api/teams[teamId]/forms/[formId]/submissions
|
||||
// Get submissions
|
||||
if (req.method === "GET") {
|
||||
// get submission
|
||||
const submissions = await prisma.submission.findMany({
|
||||
where: {
|
||||
form: {
|
||||
id: formId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(submissions);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
70
apps/hq/src/pages/api/teams/[teamId]/forms/index.ts
Normal file
70
apps/hq/src/pages/api/teams/[teamId]/forms/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { getSessionOrUser } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const user: any = await getSessionOrUser(req, res);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const teamId = parseInt(req.query.teamId.toString());
|
||||
if (isNaN(teamId)) {
|
||||
return res.status(400).json({ message: "Invalid teamId" });
|
||||
}
|
||||
|
||||
// check team permission
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership === null) {
|
||||
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
|
||||
}
|
||||
|
||||
// GET /api/teams[teamId]/forms
|
||||
// Get a specific team
|
||||
if (req.method === "GET") {
|
||||
const forms = await prisma.form.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { submissions: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(forms);
|
||||
}
|
||||
|
||||
// POST /api/teams[teamId]/forms
|
||||
// Create a new form
|
||||
// Required fields in body: -
|
||||
// Optional fields in body: label, schema
|
||||
else if (req.method === "POST") {
|
||||
const form = req.body;
|
||||
|
||||
// create form in db
|
||||
const result = await prisma.form.create({
|
||||
data: {
|
||||
...form,
|
||||
team: { connect: { id: teamId } },
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
48
apps/hq/src/pages/api/teams/[teamId]/index.ts
Normal file
48
apps/hq/src/pages/api/teams/[teamId]/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getSessionOrUser, hashApiKey } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { unstable_getServerSession } from "next-auth";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const user: any = await getSessionOrUser(req, res);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const teamId = parseInt(req.query.teamId.toString());
|
||||
if (isNaN(teamId)) {
|
||||
return res.status(400).json({ message: "Invalid teamId" });
|
||||
}
|
||||
|
||||
// GET /api/teams[teamId]
|
||||
// Get a specific team
|
||||
if (req.method === "GET") {
|
||||
// check if membership exists
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership === null) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ message: "You don't have access to this team or this team doesn't exist" });
|
||||
}
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
return res.json(team);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { prisma } from "@formbricks/database";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { unstable_getServerSession } from "next-auth";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
@@ -16,7 +15,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
// GET /api/users/[userId]/api-keys/
|
||||
// Gets all ApiKeys of a user
|
||||
if (req.method === "GET") {
|
||||
const session = await getSession({ req });
|
||||
const apiKeys = await prisma.apiKey.findMany({
|
||||
where: {
|
||||
user: { email: session.user.email },
|
||||
@@ -32,8 +30,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
const apiKey = req.body;
|
||||
|
||||
const key = randomBytes(16).toString("hex");
|
||||
|
||||
const session = await getSession({ req });
|
||||
// create form in database
|
||||
const result = await prisma.apiKey.create({
|
||||
data: {
|
||||
37
apps/hq/src/pages/api/users/me/index.ts
Normal file
37
apps/hq/src/pages/api/users/me/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getSessionOrUser, hashApiKey } from "@/lib/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { unstable_getServerSession } from "next-auth";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Check Authentication
|
||||
const session = await getSessionOrUser(req, res);
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
// GET /api/users/me
|
||||
// Get the current user
|
||||
if (req.method === "GET") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: session.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
email: true,
|
||||
name: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
return res.json(user);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
[data-nextjs-scroll-focus-boundary] {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const base = require("@formbricks/tailwind-config/tailwind.config");
|
||||
const base = require("../../packages/tailwind-config/tailwind.config");
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
|
||||
@@ -23,13 +23,13 @@ CREATE TABLE "Pipeline" (
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Customer" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"data" JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
|
||||
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id","teamId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
@@ -37,6 +37,7 @@ CREATE TABLE "Form" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"schema" JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
@@ -48,9 +49,9 @@ CREATE TABLE "Submission" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"teamId" INTEGER,
|
||||
"formId" INTEGER NOT NULL,
|
||||
"customerId" INTEGER NOT NULL,
|
||||
"customerId" TEXT,
|
||||
"teamId" INTEGER,
|
||||
"data" JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
CONSTRAINT "Submission_pkey" PRIMARY KEY ("id")
|
||||
@@ -109,7 +110,7 @@ CREATE TABLE "Account" (
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
@@ -120,7 +121,7 @@ CREATE TABLE "users" (
|
||||
"identityProvider" "IdentityProvider" NOT NULL DEFAULT 'EMAIL',
|
||||
"identityProviderAccountId" TEXT,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
@@ -133,4 +134,4 @@ CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey");
|
||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
@@ -30,48 +30,49 @@ model Pipeline {
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id Int @id @default(autoincrement())
|
||||
id String
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int
|
||||
Submissions Submission[]
|
||||
submissions Submission[]
|
||||
data Json @default("{}")
|
||||
|
||||
@@id([id, teamId])
|
||||
}
|
||||
|
||||
model Form {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int
|
||||
schema Json @default("{}")
|
||||
submission Submission[]
|
||||
Pipeline Pipeline[]
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
label String
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int
|
||||
schema Json @default("{}")
|
||||
submissions Submission[]
|
||||
pipelines Pipeline[]
|
||||
}
|
||||
|
||||
model Submission {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
Team Team? @relation(fields: [teamId], references: [id])
|
||||
teamId Int?
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
formId Int
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
customerId Int
|
||||
data Json @default("{}")
|
||||
customer Customer? @relation(fields: [customerId, teamId], references: [id, teamId])
|
||||
customerId String?
|
||||
teamId Int?
|
||||
data Json @default("{}")
|
||||
}
|
||||
|
||||
model Team {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
members Membership[]
|
||||
forms Form[]
|
||||
Customer Customer[]
|
||||
Submission Submission[]
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
members Membership[]
|
||||
forms Form[]
|
||||
customers Customer[]
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
@@ -138,7 +139,5 @@ model User {
|
||||
identityProviderAccountId String?
|
||||
teams Membership[]
|
||||
accounts Account[]
|
||||
ApiKey ApiKey[]
|
||||
|
||||
@@map(name: "users")
|
||||
apiKeys ApiKey[]
|
||||
}
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -128,6 +128,7 @@ importers:
|
||||
'@types/react-dom': ^18.0.9
|
||||
autoprefixer: ^10.4.13
|
||||
bcryptjs: ^2.4.3
|
||||
clsx: ^1.2.1
|
||||
date-fns: ^2.29.3
|
||||
eslint: ^8.28.0
|
||||
eslint-config-formbricks: workspace:*
|
||||
@@ -149,6 +150,7 @@ importers:
|
||||
'@headlessui/react': 1.7.4_biqbaboplfbrettd7655fr4n2y
|
||||
'@heroicons/react': 2.0.13_react@18.2.0
|
||||
bcryptjs: 2.4.3
|
||||
clsx: 1.2.1
|
||||
date-fns: 2.29.3
|
||||
jsonwebtoken: 8.5.1
|
||||
next: 13.0.5_biqbaboplfbrettd7655fr4n2y
|
||||
|
||||
Reference in New Issue
Block a user