Add Payment to Formbricks Cloud (#192)

* add enterprise license, add ee package

* migrate database from workspaces to organisations

* add payment api endpoints and billing pages

* add stripe env variables to .env.example
This commit is contained in:
Matti Nannt
2023-02-03 11:42:40 +01:00
committed by GitHub
parent 97a0accca0
commit a2cbf87a20
90 changed files with 1393 additions and 563 deletions
+7 -1
View File
@@ -67,4 +67,10 @@ NEXT_PUBLIC_SENTRY_DSN=
# Configure Github Login
NEXT_PUBLIC_GITHUB_AUTH_ENABLED=0
GITHUB_ID=
GITHUB_SECRET=
GITHUB_SECRET=
# Stripe Billing Variables
NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID=
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
+6 -2
View File
@@ -1,6 +1,10 @@
MIT License
Copyright (c) 2022 Matthias Nannt, Johannes Dancker
Copyright (c) 2022 Matthias Nannt
Portions of this software are licensed as follows:
- All content that resides under the "packages/ee/" directory of this repository, if that directory exists, is licensed under the license defined in "packages/ee/LICENSE".
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -3,12 +3,12 @@ import Image from "next/image";
import { Callout } from "@/components/shared/Callout";
import HeroAnimation from "@/components/shared/HeroAnimation.tsx";
import MdxCTA from "@/components/shared/MdxCTA.tsx";
import HeaderImage from "/images/SEO/Google Forms Open Source Alternative Comparison with Formbricks Open-source Online Form Builder.png";
import WhyGoogle from "/images/SEO/GoogleForms GDPR compliant for EU company open source self-hosting alternative.png";
import ControllerVsProcessor from "/images/SEO/Data Controller vs Data Processor Overview for open source forms and surveys.png";
import LemonadeExample from "/images/SEO/Example Lemonade Radio Field with Image in React Library Open Source.PNG";
import GoogleExample from "/images/SEO/Google Form Example Customize and make it comply with GDPR CCPA HIPAA open source alternative.png";
import CrownGIF from "/images/SEO/who gets the crown of open source forms.gif";
import HeaderImage from "@/images/SEO/Google Forms Open Source Alternative Comparison with Formbricks Open-source Online Form Builder.png";
import WhyGoogle from "@/images/SEO/GoogleForms GDPR compliant for EU company open source self-hosting alternative.png";
import ControllerVsProcessor from "@/images/SEO/Data Controller vs Data Processor Overview for open source forms and surveys.png";
import LemonadeExample from "@/images/SEO/example-lemonade-radio-field-with-image-in-react-library-Oopen-source.png";
import GoogleExample from "@/images/SEO/Google Form Example Customize and make it comply with GDPR CCPA HIPAA open source alternative.png";
import CrownGIF from "@/images/SEO/who gets the crown of open source forms.gif";
import HeaderAnimation from "@/components/shared/HeroAnimation.tsx";
export const meta = {
-1
View File
@@ -25,7 +25,6 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
+6 -2
View File
@@ -5,6 +5,8 @@
var path = require("path");
const { withSentryConfig } = require("@sentry/nextjs");
const withTM = require("next-transpile-modules")(["@formbricks/ee"]);
const nextConfig = {
reactStrictMode: true,
output: "standalone",
@@ -55,6 +57,8 @@ const sentryWebpackPluginOptions = {
silent: true, // Suppresses all logs
};
const moduleExports = () => [withTM].reduce((acc, next) => next(acc), nextConfig);
module.exports = process.env.NEXT_PUBLIC_SENTRY_DSN
? withSentryConfig(nextConfig, sentryWebpackPluginOptions)
: nextConfig;
? withSentryConfig(moduleExports, sentryWebpackPluginOptions)
: moduleExports;
+3
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@formbricks/charts": "workspace:*",
"@formbricks/ee": "workspace:*",
"@formbricks/react": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^1.7.8",
@@ -22,6 +23,7 @@
"jsonwebtoken": "^9.0.0",
"next": "^13.1.6",
"next-auth": "^4.19.0",
"next-transpile-modules": "^10.0.0",
"nodemailer": "^6.9.1",
"platform": "^1.3.6",
"prismjs": "^1.29.0",
@@ -30,6 +32,7 @@
"react-icons": "^4.7.1",
"react-loader-spinner": "^5.3.4",
"react-toastify": "^9.1.1",
"stripe": "^11.8.0",
"swr": "^2.0.3"
},
"devDependencies": {
+17
View File
@@ -0,0 +1,17 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});
+19
View File
@@ -0,0 +1,19 @@
import PricingTable from "@formbricks/ee/billing/components/PricingTable";
import Modal from "./Modal";
export default function UpgradeModal({ open, setOpen, organisationId }) {
return (
<Modal open={open} setOpen={setOpen}>
<div className="my-6 sm:flex-auto">
<h1 className="text-xl font-semibold text-gray-900">Upgrade to benefit from all features</h1>
<p className="mt-2 text-sm text-gray-700">
You do not currently have an active subscription. Upgrade to get access to all features and improve
your user research.
</p>
</div>
<div className="overflow-hidden rounded-lg">
<PricingTable organisationId={organisationId} />
</div>
</Modal>
);
}
@@ -3,7 +3,7 @@
import LoadingSpinner from "@/components/LoadingSpinner";
import EmptyPageFiller from "@/components/EmptyPageFiller";
import { useCustomers } from "@/lib/customers";
import { useWorkspace } from "@/lib/workspaces";
import { useOrganisation } from "@/lib/organisations";
import { convertDateTimeString } from "@/lib/utils";
import { UsersIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
@@ -12,13 +12,13 @@ import { useRouter } from "next/router";
export default function FormsPage() {
const router = useRouter();
const { customers, isLoadingCustomers, isErrorCustomers } = useCustomers(
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const { workspace, isLoadingWorkspace, isErrorWorkspace } = useWorkspace(
router.query.workspaceId?.toString()
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
if (isLoadingCustomers || isLoadingWorkspace) {
if (isLoadingCustomers || isLoadingOrganisation) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
@@ -26,7 +26,7 @@ export default function FormsPage() {
);
}
if (isErrorCustomers || isErrorWorkspace) {
if (isErrorCustomers || isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights.</div>;
}
return (
@@ -35,7 +35,7 @@ export default function FormsPage() {
<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">
{workspace.name}
{organisation.name}
</span>
</h1>
<p className="mt-4 text-slate-600">
@@ -78,7 +78,8 @@ export default function FormsPage() {
{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">
<Link href={`/workspaces/${router.query.workspaceId}/customers/${customer.email}`}>
<Link
href={`/organisations/${router.query.organisationId}/customers/${customer.email}`}>
{customer.email}
</Link>
</td>
@@ -90,7 +91,7 @@ export default function FormsPage() {
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Link
href={`/workspaces/${router.query.workspaceId}/customers/${customer.id}`}
href={`/organisations/${router.query.organisationId}/customers/${customer.id}`}
className="text-brand-dark hover:text-brand-light">
View<span className="sr-only">, {customer.name}</span>
</Link>
@@ -15,7 +15,7 @@ import EmptyPageFiller from "../EmptyPageFiller";
export default function SingleCustomerPage() {
const router = useRouter();
const { customer, isLoadingCustomer, isErrorCustomer } = useCustomer(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.customerId?.toString()
);
@@ -41,7 +41,7 @@ export default function SingleCustomerPage() {
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<Link
className="inline-flex pt-5 text-sm text-gray-500"
href={`/workspaces/${router.query.workspaceId}/customers/`}>
href={`/organisations/${router.query.organisationId}/customers/`}>
<BackIcon className="mr-2 h-5 w-5" />
Back to customers overview
</Link>
+5 -5
View File
@@ -11,8 +11,8 @@ import { Fragment, useState } from "react";
import NewFormModal from "@/components/forms/NewFormModal";
import LoadingSpinner from "../LoadingSpinner";
export default function FormsList({ workspaceId }) {
const { forms, mutateForms, isLoadingForms } = useForms(workspaceId);
export default function FormsList({ organisationId }) {
const { forms, mutateForms, isLoadingForms } = useForms(organisationId);
const [openNewFormModal, setOpenNewFormModal] = useState(false);
const newForm = async () => {
@@ -21,7 +21,7 @@ export default function FormsList({ workspaceId }) {
const deleteFormAction = async (form, formIdx) => {
try {
await deleteForm(workspaceId, form.id);
await deleteForm(organisationId, form.id);
// remove locally
const updatedForms = JSON.parse(JSON.stringify(forms));
updatedForms.splice(formIdx, 1);
@@ -72,7 +72,7 @@ export default function FormsList({ workspaceId }) {
<p className="line-clamp-3 text-lg">{form.label}</p>
</div>
<Link
href={`/workspaces/${workspaceId}/forms/${form.id}/${form.type}/`}
href={`/organisations/${organisationId}/forms/${form.id}/${form.type}/`}
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">
@@ -140,7 +140,7 @@ export default function FormsList({ workspaceId }) {
</ul>
))}
</div>
<NewFormModal open={openNewFormModal} setOpen={setOpenNewFormModal} workspaceId={workspaceId} />
<NewFormModal open={openNewFormModal} setOpen={setOpenNewFormModal} organisationId={organisationId} />
</>
);
}
+8 -8
View File
@@ -3,17 +3,17 @@
import FormsList from "@/components/forms/FormsList";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForms } from "@/lib/forms";
import { useWorkspace } from "@/lib/workspaces";
import { useOrganisation } from "@/lib/organisations";
import { useRouter } from "next/router";
export default function FormsPage({}) {
const router = useRouter();
const { isLoadingForms, isErrorForms } = useForms(router.query.workspaceId?.toString());
const { workspace, isLoadingWorkspace, isErrorWorkspace } = useWorkspace(
router.query.workspaceId?.toString()
const { isLoadingForms, isErrorForms } = useForms(router.query.organisationId?.toString());
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
if (isLoadingForms || isLoadingWorkspace) {
if (isLoadingForms || isLoadingOrganisation) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
@@ -21,7 +21,7 @@ export default function FormsPage({}) {
);
}
if (isErrorForms || isErrorWorkspace) {
if (isErrorForms || isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
@@ -30,11 +30,11 @@ export default function FormsPage({}) {
<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">
{workspace.name}
{organisation.name}
</span>
</h1>
</header>
<FormsList workspaceId={router.query.workspaceId} />
<FormsList organisationId={router.query.organisationId} />
</div>
);
}
+181 -140
View File
@@ -7,40 +7,62 @@ import { PMFIcon, FeedbackIcon, UserCommentIcon } from "@formbricks/ui";
import { XMarkIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { useRouter } from "next/navigation";
import { Fragment, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { BsPlus } from "react-icons/bs";
import Link from "next/link";
import LoadingSpinner from "../LoadingSpinner";
import { useOrganisation } from "@/lib/organisations";
import UpgradeModal from "../UpgradeModal";
type FormOnboardingModalProps = {
open: boolean;
setOpen: (v: boolean) => void;
workspaceId: string;
organisationId: string;
};
const formTypes = [
{
id: "feedback",
name: "Feedback Box",
description: "A direct channel to feel the pulse of your users.",
icon: FeedbackIcon,
},
{
id: "custom",
name: "Custom Survey",
description: "Create and analyze your custom survey.",
icon: UserCommentIcon,
},
{
id: "pmf",
name: "Product-Market Fit Survey",
description: "Leverage the Superhuman PMF engine.",
icon: PMFIcon,
},
];
export default function NewFormModal({ open, setOpen, workspaceId }: FormOnboardingModalProps) {
export default function NewFormModal({ open, setOpen, organisationId }: FormOnboardingModalProps) {
const router = useRouter();
const [openUpgradeModal, setOpenUpgradeModal] = useState(false);
const [label, setLabel] = useState("");
const [formType, setFormType] = useState(formTypes[0].id);
const [formType, setFormType] = useState("feedback");
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(organisationId);
const formTypes = useMemo(
() => [
{
id: "feedback",
name: "Feedback Box",
description: "A direct channel to feel the pulse of your users.",
icon: FeedbackIcon,
},
{
id: "custom",
name: "Custom Survey",
description: "Create and analyze your custom survey.",
icon: UserCommentIcon,
},
{
id: "pmf",
name: "Product-Market Fit Survey",
description: "Leverage the Superhuman PMF engine.",
icon: PMFIcon,
needsUpgrade: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD && organisation?.plan === "free",
},
],
[organisation]
);
if (isLoadingOrganisation) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
const createFormAction = async (e) => {
e.preventDefault();
@@ -194,129 +216,148 @@ export default function NewFormModal({ open, setOpen, workspaceId }: FormOnboard
} else {
throw new Error("Unknown form type");
}
const form = await createForm(workspaceId, formTemplate);
router.push(`/workspaces/${workspaceId}/forms/${form.id}/${form.type}/`);
const form = await createForm(organisationId, formTemplate);
router.push(`/organisations/${organisationId}/forms/${form.id}/${form.type}/`);
};
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>
<>
<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. Feedback Box App"
value={label}
onChange={(e) => setLabel(e.target.value)}
autoFocus
required
/>
</div>
<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>
<hr className="my-6 text-gray-600" />
<RadioGroup value={formType} onChange={setFormType}>
<RadioGroup.Label className="text-sm font-light text-slate-800">
Choose your form type
</RadioGroup.Label>
<div className="mt-3 space-y-4">
{formTypes.map((formType) => (
<RadioGroup.Option
key={formType.name}
value={formType.id}
className={({ checked, active }) =>
clsx(
checked ? "border-transparent" : "border-gray-300",
active ? "border-brand ring-brand ring-2" : "",
"relative block cursor-pointer rounded-lg border bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex"
)
}>
{({ active, checked }) => (
<>
<RadioGroup.Description
as="span"
className="mt-2 mr-3 flex text-sm sm:mt-0 sm:flex-col sm:text-right">
<formType.icon className="h-8 w-8" />
</RadioGroup.Description>
<span className="flex items-center">
<span className="flex flex-col text-sm">
<RadioGroup.Label as="span" className="font-medium text-gray-900">
{formType.name}
</RadioGroup.Label>
<RadioGroup.Description as="span" className="text-gray-500">
{formType.description}
<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. Feedback Box App"
value={label}
onChange={(e) => setLabel(e.target.value)}
autoFocus
required
/>
</div>
</div>
<hr className="my-6 text-gray-600" />
<RadioGroup value={formType} onChange={setFormType}>
<RadioGroup.Label className="text-sm font-light text-slate-800">
Choose your form type
</RadioGroup.Label>
<div className="mt-3 space-y-4">
{formTypes.map((formType) => (
<div
key={formType.name}
onClick={() => {
if (formType.needsUpgrade) {
setOpenUpgradeModal(true);
}
}}>
<RadioGroup.Option
disabled={formType.needsUpgrade}
value={formType.id}
className={({ checked, active, disabled }) =>
clsx(
checked ? "border-transparent" : "border-gray-300",
active ? "border-brand ring-brand ring-2" : "",
disabled ? "bg-gray-100" : "bg-white",
"relative block cursor-pointer rounded-lg border bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex"
)
}>
{({ active, checked }) => (
<>
<RadioGroup.Description
as="span"
className="mt-2 mr-3 flex text-sm sm:mt-0 sm:flex-col sm:text-right">
<formType.icon className="h-8 w-8" />
</RadioGroup.Description>
</span>
</span>
<span className="flex items-center">
<span className="flex flex-col text-sm">
<RadioGroup.Label as="span" className="font-medium text-gray-900">
{formType.name}
{formType.needsUpgrade && (
<Link href={`/organisations/${organisation.id}/settings/billing`}>
<span className="ml-2 inline-flex items-center rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-800">
Pro Feature
</span>
</Link>
)}
</RadioGroup.Label>
<RadioGroup.Description as="span" className="text-gray-500">
{formType.description}
</RadioGroup.Description>
</span>
</span>
<span
className={clsx(
active ? "border" : "border-2",
checked ? "border-brand" : "border-transparent",
"pointer-events-none absolute -inset-px rounded-lg"
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
))}
<span
className={clsx(
active ? "border" : "border-2",
checked ? "border-brand" : "border-transparent",
"pointer-events-none absolute -inset-px rounded-lg"
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
</div>
))}
</div>
</RadioGroup>
<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>
</RadioGroup>
<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>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</div>
</Dialog>
</Transition.Root>
</Dialog>
</Transition.Root>
<UpgradeModal open={openUpgradeModal} setOpen={setOpenUpgradeModal} organisationId={organisationId} />
</>
);
}
@@ -2,7 +2,7 @@
import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useWorkspace } from "@/lib/workspaces";
import { useOrganisation } from "@/lib/organisations";
import { Button } from "@formbricks/ui";
import { UserIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
@@ -24,10 +24,10 @@ export default function FormOverviewPage() {
const router = useRouter();
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const { workspace, isLoadingWorkspace, isErrorWorkspace } = useWorkspace(
router.query.workspaceId?.toString()
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
const [activeTab, setActiveTab] = useState(tabs[0]);
@@ -43,7 +43,7 @@ export default function FormOverviewPage() {
}
}, [isLoadingForm]);
if (isLoadingForm || isLoadingWorkspace) {
if (isLoadingForm || isLoadingOrganisation) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
@@ -51,7 +51,7 @@ export default function FormOverviewPage() {
);
}
if (isErrorForm || isErrorWorkspace) {
if (isErrorForm || isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
@@ -60,7 +60,7 @@ export default function FormOverviewPage() {
<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">
{workspace.name}
{organisation.name}
</span>
</h1>
</header>
@@ -179,7 +179,7 @@ export default function FormOverviewPage() {
<li>
View submission under{" "}
<Link
href={`/workspaces/${router.query.workspaceId}/forms/${router.query.formId}/submissions`}
href={`/organisations/${router.query.organisationId}/forms/${router.query.formId}/submissions`}
className="underline">
Submissions
</Link>{" "}
@@ -188,7 +188,7 @@ export default function FormOverviewPage() {
<li>
Get notified or pipe submission data to a different tool in the{" "}
<Link
href={`/workspaces/${router.query.workspaceId}/forms/${router.query.formId}/pipelines`}
href={`/organisations/${router.query.organisationId}/forms/${router.query.formId}/pipelines`}
className="underline">
Pipelines
</Link>{" "}
@@ -21,7 +21,7 @@ export default function FeedbackPage() {
const [currentTab, setCurrentTab] = useState("Results");
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
if (isLoadingForm) {
@@ -20,7 +20,7 @@ const subCategories = [
export default function FeedbackResults() {
const router = useRouter();
const { submissions, isLoadingSubmissions, isErrorSubmissions } = useSubmissions(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
@@ -12,7 +12,7 @@ export default function FeedbackTimeline({ submissions }) {
const router = useRouter();
const { submissions: allSubmissions, mutateSubmissions } = useSubmissions(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
@@ -22,7 +22,7 @@ export default function FeedbackTimeline({ submissions }) {
// save submission without customer
const submissionWoCustomer = { ...updatedSubmission };
delete submissionWoCustomer.customer;
persistSubmission(submissionWoCustomer, router.query.workspaceId?.toString());
persistSubmission(submissionWoCustomer, router.query.organisationId?.toString());
// update all submissions
const submissionIdx = allSubmissions.findIndex((s) => s.id === submission.id);
const updatedSubmissions = JSON.parse(JSON.stringify(allSubmissions));
@@ -107,7 +107,7 @@ export default function FeedbackTimeline({ submissions }) {
{submission.customerEmail ? (
<Link
className="text-sm font-medium text-gray-700"
href={`/workspaces/${router.query.workspaceId}/customers/${submission.customerEmail}`}>
href={`/organisations/${router.query.organisationId}/customers/${submission.customerEmail}`}>
{submission.customerEmail}
</Link>
) : (
@@ -23,7 +23,7 @@ export default function AddPipelineModal({ open, setOpen }) {
const [pipeline, setPipeline] = useState(getEmptyPipeline());
const { pipelines, mutatePipelines } = usePipelines(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
useEffect(() => {
@@ -43,7 +43,7 @@ export default function AddPipelineModal({ open, setOpen }) {
e.preventDefault();
const newPipeline = await createPipeline(
router.query.formId?.toString(),
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
pipeline
);
const newPipelines = JSON.parse(JSON.stringify(pipelines));
@@ -86,11 +86,11 @@ export default function PipelinesOverview({}) {
const router = useRouter();
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const { pipelines, isLoadingPipelines, isErrorPipelines, mutatePipelines } = usePipelines(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const [openAddModal, setOpenAddModal] = useState(false);
@@ -100,7 +100,7 @@ export default function PipelinesOverview({}) {
const toggleEnabled = async (pipeline) => {
const newPipeline = JSON.parse(JSON.stringify(pipeline));
newPipeline.enabled = !newPipeline.enabled;
await persistPipeline(router.query.formId, router.query.workspaceId, newPipeline);
await persistPipeline(router.query.formId, router.query.organisationId, newPipeline);
const pipelineIdx = pipelines.findIndex((p) => p.id === pipeline.id);
if (pipelineIdx !== -1) {
const newPipelines = JSON.parse(JSON.stringify(pipelines));
@@ -115,7 +115,11 @@ export default function PipelinesOverview({}) {
};
const deletePipelineAction = async (pipelineId) => {
await deletePipeline(router.query.formId?.toString(), router.query.workspaceId?.toString(), pipelineId);
await deletePipeline(
router.query.formId?.toString(),
router.query.organisationId?.toString(),
pipelineId
);
const newPipelines = JSON.parse(JSON.stringify(pipelines));
const pipelineIdx = newPipelines.findIndex((p) => p.id === pipelineId);
if (pipelineIdx > -1) {
@@ -7,18 +7,18 @@ import PipelineSettings from "./PipelineSettings";
export default function UpdatePipelineModal({ open, setOpen, pipelineId }) {
const router = useRouter();
const { pipeline, isLoadingPipeline, mutatePipeline } = usePipeline(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString(),
pipelineId
);
const { pipelines, mutatePipelines } = usePipelines(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const handleSubmit = async (e) => {
e.preventDefault();
await persistPipeline(router.query.formId?.toString(), router.query.workspaceId?.toString(), pipeline);
await persistPipeline(router.query.formId?.toString(), router.query.organisationId?.toString(), pipeline);
const newPipelines = JSON.parse(JSON.stringify(pipelines));
const pipelineIdx = pipelines.findIndex((p) => p.id === pipelineId);
if (pipelineIdx > -1) {
@@ -28,7 +28,7 @@ export default function PMFPage() {
const [currentTab, setCurrentTab] = useState("Results");
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
if (isLoadingForm) {
@@ -12,7 +12,7 @@ import PMFTimeline from "./PMFTimeline";
export default function PMFResults() {
const router = useRouter();
const { submissions, isLoadingSubmissions, isErrorSubmissions } = useSubmissions(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
@@ -18,11 +18,11 @@ export default function PMFTimeline({ submissions }) {
mutateSubmissions,
isLoadingSubmissions,
isErrorSubmissions,
} = useSubmissions(router.query.workspaceId?.toString(), router.query.formId?.toString());
} = useSubmissions(router.query.organisationId?.toString(), router.query.formId?.toString());
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const toggleArchiveSubmission = (submission) => {
@@ -31,7 +31,7 @@ export default function PMFTimeline({ submissions }) {
// save submission without customer
const submissionWoCustomer = { ...updatedSubmission };
delete submissionWoCustomer.customer;
persistSubmission(submissionWoCustomer, router.query.workspaceId?.toString());
persistSubmission(submissionWoCustomer, router.query.organisationId?.toString());
// update all submissions
const submissionIdx = allSubmissions.findIndex((s) => s.id === submission.id);
const updatedSubmissions = JSON.parse(JSON.stringify(allSubmissions));
@@ -136,7 +136,7 @@ export default function PMFTimeline({ submissions }) {
{submission.customerEmail ? (
<Link
className="text-sm font-medium text-gray-700"
href={`/workspaces/${router.query.workspaceId}/customers/${submission.customerEmail}`}>
href={`/organisations/${router.query.organisationId}/customers/${submission.customerEmail}`}>
{submission.customerEmail}
</Link>
) : (
@@ -13,10 +13,10 @@ export default function SegmentResults() {
const router = useRouter();
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const { submissions, isLoadingSubmissions, isErrorSubmissions } = useSubmissions(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
const [filteredSubmissions, setFilteredSubmissions] = useState([]);
@@ -15,7 +15,7 @@ export default function SegmentResults() {
const router = useRouter();
const [filteredSubmissions, setFilteredSubmissions] = useState([]);
const { submissions, isLoadingSubmissions, isErrorSubmissions } = useSubmissions(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
@@ -18,10 +18,10 @@ interface Filter {
export default function FilterNavigation({ submissions, setFilteredSubmissions }) {
const router = useRouter();
const { formId, workspaceId } = router.query;
const { formId, organisationId } = router.query;
const [filters, setFilters] = useState<Filter[]>([]);
const { form, isLoadingForm, isErrorForm } = useForm(formId?.toString(), workspaceId?.toString());
const { form, isLoadingForm, isErrorForm } = useForm(formId?.toString(), organisationId?.toString());
// filter submissions based on selected filters
useEffect(() => {
@@ -18,12 +18,12 @@ export default function SubmissionsPage() {
const [finishedOnly, setFinishedOnly] = useState(false);
const [filteredSubmissions, setFileredSubmission] = useState([]);
const { submissions, isLoadingSubmissions, mutateSubmissions, isErrorSubmissions } = useSubmissions(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const [activeSubmission, setActiveSubmission] = useState<Submission | null>(null);
@@ -40,7 +40,7 @@ export default function SubmissionsPage() {
const handleDelete = async (submission: Submission) => {
try {
await deleteSubmission(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString(),
submission.id
);
@@ -5,7 +5,7 @@ import LoadingSpinner from "@/components/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useSubmissions } from "@/lib/submissions";
import { capitalizeFirstLetter } from "@/lib/utils";
import { useWorkspace } from "@/lib/workspaces";
import { useOrganisation } from "@/lib/organisations";
import { Bar, Nps, Table } from "@formbricks/charts";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
@@ -15,17 +15,17 @@ export default function SummaryPage() {
const router = useRouter();
const { form, isLoadingForm, isErrorForm } = useForm(
router.query.formId?.toString(),
router.query.workspaceId?.toString()
router.query.organisationId?.toString()
);
const { workspace, isLoadingWorkspace, isErrorWorkspace } = useWorkspace(
router.query.workspaceId?.toString()
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
const { submissions, isLoadingSubmissions } = useSubmissions(
router.query.workspaceId?.toString(),
router.query.organisationId?.toString(),
router.query.formId?.toString()
);
if (isLoadingForm || isLoadingWorkspace || isLoadingSubmissions) {
if (isLoadingForm || isLoadingOrganisation || isLoadingSubmissions) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
@@ -33,7 +33,7 @@ export default function SummaryPage() {
);
}
if (isErrorForm || isErrorWorkspace) {
if (isErrorForm || isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
@@ -43,7 +43,7 @@ export default function SummaryPage() {
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
Summary - {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">
{workspace.name}
{organisation.name}
</span>
</h1>
</header>
+79 -21
View File
@@ -1,32 +1,34 @@
"use client";
import LoadingSpinner from "@/components/LoadingSpinner";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import { useMemberships } from "@/lib/memberships";
import { Disclosure, Menu, Transition } from "@headlessui/react";
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { signOut, useSession } from "next-auth/react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Fragment } from "react";
import { useRouter } from "next/router";
import { Fragment, useMemo } from "react";
import { ToastContainer } from "react-toastify";
import LoadingSpinner from "@/components/LoadingSpinner";
import { Logo } from "../Logo";
import Head from "next/head";
export default function LayoutApp({ children }) {
const userNavigation = [
{
name: "Settings",
onClick: () => {
router.push("/me/settings");
},
},
{ name: "Sign out", onClick: () => signOut() },
];
const router = useRouter();
const { data: session, status } = useSession();
const { memberships, isLoadingMemberships, isErrorMemberships } = useMemberships();
const userNavigation = useMemo(
() => [
{
name: "Settings",
href: "/me/settings",
},
],
[]
);
if (status === "loading") {
return (
@@ -41,6 +43,18 @@ export default function LayoutApp({ children }) {
return <div></div>;
}
if (isLoadingMemberships) {
return (
<div className="flex h-full w-full items-center justify-center p-8">
<LoadingSpinner />
</div>
);
}
if (isErrorMemberships) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<>
<Head>
@@ -90,21 +104,65 @@ export default function LayoutApp({ children }) {
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{userNavigation.map((item) => (
<Menu.Item key={item.name}>
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-100 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="px-4 py-3">
<p className="text-sm">Signed in as</p>
<p className="truncate text-sm font-medium text-gray-900">{session.user.name}</p>
</div>
<div className="py-1">
{userNavigation.map((item) => (
<Menu.Item key={item.name}>
{({ active }) => (
<Link
href={item.href}
className={clsx(
active ? "bg-gray-100" : "",
"flex justify-start px-4 py-2 text-sm text-gray-700"
)}>
{item.name}
</Link>
)}
</Menu.Item>
))}
</div>
{process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD === "1" &&
memberships.map((membership) => (
<>
<div className="px-4 py-3">
<p className="truncate text-sm font-medium text-gray-900">
{membership.organisation.name}
</p>
</div>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<Link
href={`/organisations/${membership.organisation.id}/settings/billing`}
className={clsx(
active ? "bg-gray-100" : "",
"flex justify-start px-4 py-2 text-sm text-gray-700"
)}>
Billing
</Link>
)}
</Menu.Item>
</div>
</>
))}
<div className="py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={item.onClick}
onClick={() => signOut()}
className={clsx(
active ? "bg-gray-100" : "",
"flex w-full justify-start px-4 py-2 text-sm text-gray-700"
)}>
{item.name}
Sign out
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
@@ -149,7 +207,7 @@ export default function LayoutApp({ children }) {
<Disclosure.Button
key={item.name}
as="a"
onClick={item.onClick}
href={item.href}
className="block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800">
{item.name}
</Disclosure.Button>
@@ -14,22 +14,22 @@ export default function LayoutWrapperCustomForm({ children }) {
() => [
{
name: "Form",
href: `/workspaces/${router.query.workspaceId}/forms/${router.query.formId}/custom/`,
href: `/organisations/${router.query.organisationId}/forms/${router.query.formId}/custom/`,
current: pathname.endsWith("custom") || pathname.endsWith("custom/"),
},
{
name: "Pipelines",
href: `/workspaces/${router.query.workspaceId}/forms/${router.query.formId}/custom/pipelines/`,
href: `/organisations/${router.query.organisationId}/forms/${router.query.formId}/custom/pipelines/`,
current: pathname.includes("pipelines"),
},
{
name: "Summary",
href: `/workspaces/${router.query.workspaceId}/forms/${router.query.formId}/custom/summary/`,
href: `/organisations/${router.query.organisationId}/forms/${router.query.formId}/custom/summary/`,
current: pathname.includes("summary"),
},
{
name: "Submissions",
href: `/workspaces/${router.query.workspaceId}/forms/${router.query.formId}/custom/submissions/`,
href: `/organisations/${router.query.organisationId}/forms/${router.query.formId}/custom/submissions/`,
current: pathname.includes("submissions"),
},
],
@@ -10,7 +10,7 @@ import { usePathname } from "next/navigation";
import { useRouter } from "next/router";
import { Fragment, useMemo, useState } from "react";
export default function LayoutWrapperWorkspace({ children }) {
export default function LayoutWrapperOrganisation({ children }) {
const router = useRouter();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const pathname = usePathname();
@@ -18,19 +18,19 @@ export default function LayoutWrapperWorkspace({ children }) {
() => [
{
name: "Forms",
href: `/workspaces/${router.query.workspaceId}/forms`,
href: `/organisations/${router.query.organisationId}/forms`,
icon: FormIcon,
current: pathname.includes("/form"),
},
{
name: "Customers",
href: `/workspaces/${router.query.workspaceId}/customers`,
href: `/organisations/${router.query.organisationId}/customers`,
icon: CustomersIcon,
current: pathname.includes("/customers"),
},
/* {
name: "Settings",
href: `/workspaces/${router.query.workspaceId}/settings`,
href: `/organisations/${router.query.organisationId}/settings`,
icon: Cog8ToothIcon,
current: pathname.includes("/settings"),
}, */
@@ -0,0 +1,86 @@
"use client";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useOrganisation } from "@/lib/organisations";
import PricingTable from "@formbricks/ee/billing/components/PricingTable";
import { Button } from "@formbricks/ui";
import { useRouter } from "next/router";
import { useState } from "react";
export default function SettingsPage() {
const router = useRouter();
const [loadingCustomerPortal, setLoadingCustomerPortal] = useState(false);
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
const openCustomerPortal = async () => {
setLoadingCustomerPortal(true);
const res = await fetch("/api/billing/create-customer-portal-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
stripeCustomerId: organisation.stripeCustomerId,
returnUrl: `${window.location}`,
}),
});
if (!res.ok) {
setLoadingCustomerPortal(false);
alert("Error loading billing portal");
}
const { sessionUrl } = await res.json();
router.push(sessionUrl);
};
if (isLoadingOrganisation) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;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">
Billing
<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">
{organisation.name}
</span>
</h1>
</header>
{organisation.plan === "free" ? (
<>
<div className="my-6 sm:flex-auto">
<h1 className="text-xl font-semibold text-gray-900">Upgrade to benefit from all features</h1>
<p className="mt-2 text-sm text-gray-700">
You do not currently have an active subscription. Upgrade to get access to all features and
improve your user research.
</p>
</div>
<div className="overflow-hidden rounded-lg">
<PricingTable organisationId={organisation.id} />
</div>
</>
) : (
<>
<div className="my-6 sm:flex-auto">
<h1 className="text-xl font-semibold text-gray-900">View and manage your billing details</h1>
<p className="mt-2 text-sm text-gray-700">
View and edit your billing details, as well as cancel your subscription.
</p>
</div>
<Button onClick={() => openCustomerPortal()} loading={loadingCustomerPortal}>
Billing Portal
</Button>
</>
)}
</div>
);
}
@@ -1,17 +1,17 @@
"use client";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useWorkspace } from "@/lib/workspaces";
import { useOrganisation } from "@/lib/organisations";
import { InformationCircleIcon } from "@heroicons/react/20/solid";
import { useRouter } from "next/router";
export default function SettingsPage() {
const router = useRouter();
const { workspace, isLoadingWorkspace, isErrorWorkspace } = useWorkspace(
router.query.workspaceId?.toString()
const { organisation, isLoadingOrganisation, isErrorOrganisation } = useOrganisation(
router.query.organisationId?.toString()
);
if (isLoadingWorkspace) {
if (isLoadingOrganisation) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
@@ -19,7 +19,7 @@ export default function SettingsPage() {
);
}
if (isErrorWorkspace) {
if (isErrorOrganisation) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
@@ -28,7 +28,7 @@ export default function SettingsPage() {
<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">
{workspace.name}
{organisation.name}
</span>
</h1>
</header>
@@ -39,7 +39,7 @@ export default function SettingsPage() {
</div>
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm text-teal-700">
Workspace Management Settings coming to Formbricks HQ soon.
Organisation Management Settings coming to Formbricks HQ soon.
</p>
</div>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@ 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";
import { getServerSession } from "next-auth";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
@@ -29,7 +29,7 @@ export const hasOwnership = async (model, session, id) => {
export const getSessionOrUser = async (req: NextApiRequest, res: NextApiResponse) => {
// check for session (browser usage)
let session: any = await unstable_getServerSession(req, res, authOptions);
let session: any = await getServerSession(req, res, authOptions);
if (session && "user" in session) return session.user;
// check for api key
if (req.headers["x-api-key"]) {
+9 -6
View File
@@ -1,8 +1,8 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useCustomers = (workspaceId: string) => {
const { data, error, mutate } = useSWR(`/api/workspaces/${workspaceId}/customers`, fetcher);
export const useCustomers = (organisationId: string) => {
const { data, error, mutate } = useSWR(`/api/organisations/${organisationId}/customers`, fetcher);
return {
customers: data,
@@ -12,8 +12,11 @@ export const useCustomers = (workspaceId: string) => {
};
};
export const useCustomer = (workspaceId: string, customerId: string) => {
const { data, error, mutate } = useSWR(`/api/workspaces/${workspaceId}/customers/${customerId}`, fetcher);
export const useCustomer = (organisationId: string, customerId: string) => {
const { data, error, mutate } = useSWR(
`/api/organisations/${organisationId}/customers/${customerId}`,
fetcher
);
return {
customer: data,
@@ -23,9 +26,9 @@ export const useCustomer = (workspaceId: string, customerId: string) => {
};
};
export const deleteCustomer = async (id: string, workspaceId: string) => {
export const deleteCustomer = async (id: string, organisationId: string) => {
try {
await fetch(`/api/workspaces/${workspaceId}/customers/${id}`, {
await fetch(`/api/organisations/${organisationId}/customers/${id}`, {
method: "DELETE",
});
} catch (error) {
+5 -5
View File
@@ -45,7 +45,7 @@ export const sendVerificationEmail = async (user) => {
The link is valid for one day. If it has expired please request a new token here:<br/>
<a href="${verificationRequestLink}">${verificationRequestLink}</a><br/>
<br/>
Your Formbricks Workspace`,
Your Formbricks Organisation`,
});
};
@@ -66,7 +66,7 @@ export const sendForgotPasswordEmail = async (user) => {
<br/>
Your password won't change until you access the link above and create a new one.<br/>
<br/>
Your Formbricks Workspace`,
Your Formbricks Organisation`,
});
};
@@ -76,14 +76,14 @@ export const sendPasswordResetNotifyEmail = async (user) => {
subject: "Your Formbricks password has been changed",
html: `We're contacting you to notify you that your password has been changed.<br/>
<br/>
Your Formbricks Workspace`,
Your Formbricks Organisation`,
});
};
export const sendSubmissionEmail = async (
email: string,
event: "created" | "updated" | "finished",
workspaceId,
organisationId,
formId,
formLabel: string,
schema: any,
@@ -120,7 +120,7 @@ export const sendSubmissionEmail = async (
Click <a href="${
process.env.NEXTAUTH_URL
}/workspaces/${workspaceId}/forms/${formId}/feedback">here</a> to see the submission.
}/organisations/${organisationId}/forms/${formId}/feedback">here</a> to see the submission.
${submission.customerEmail ? "<hr/>You can reply to this email to contact the user directly." : ""}`,
});
};
+9 -9
View File
@@ -1,8 +1,8 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useForms = (workspaceId: string) => {
const { data, error, mutate } = useSWR(`/api/workspaces/${workspaceId}/forms`, fetcher);
export const useForms = (organisationId: string) => {
const { data, error, mutate } = useSWR(`/api/organisations/${organisationId}/forms`, fetcher);
return {
forms: data,
@@ -12,8 +12,8 @@ export const useForms = (workspaceId: string) => {
};
};
export const useForm = (id: string, workspaceId: string) => {
const { data, error, mutate } = useSWR(`/api/workspaces/${workspaceId}/forms/${id}`, fetcher);
export const useForm = (id: string, organisationId: string) => {
const { data, error, mutate } = useSWR(`/api/organisations/${organisationId}/forms/${id}`, fetcher);
return {
form: data,
@@ -25,7 +25,7 @@ export const useForm = (id: string, workspaceId: string) => {
export const persistForm = async (form) => {
try {
await fetch(`/api/workspaces/${form.workspaceId}/forms/${form.id}/`, {
await fetch(`/api/organisations/${form.organisationId}/forms/${form.id}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
@@ -35,9 +35,9 @@ export const persistForm = async (form) => {
}
};
export const createForm = async (workspaceId: string, form = {}) => {
export const createForm = async (organisationId: string, form = {}) => {
try {
const res = await fetch(`/api/workspaces/${workspaceId}/forms`, {
const res = await fetch(`/api/organisations/${organisationId}/forms`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
@@ -49,9 +49,9 @@ export const createForm = async (workspaceId: string, form = {}) => {
}
};
export const deleteForm = async (workspaceId: string, formId: string) => {
export const deleteForm = async (organisationId: string, formId: string) => {
try {
await fetch(`/api/workspaces/${workspaceId}/forms/${formId}`, {
await fetch(`/api/organisations/${organisationId}/forms/${formId}`, {
method: "DELETE",
});
} catch (error) {
+13
View File
@@ -0,0 +1,13 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useOrganisation = (id: string) => {
const { data, error, mutate } = useSWR(`/api/organisations/${id}/`, fetcher);
return {
organisation: data,
isLoadingOrganisation: !error && !data,
isErrorOrganisation: error,
mutateOrganisation: mutate,
};
};
+13 -10
View File
@@ -2,8 +2,11 @@ import useSWR from "swr";
import { fetcher } from "./utils";
export const usePipelines = (formId: string, workspaceId: string) => {
const { data, error, mutate } = useSWR(`/api/workspaces/${workspaceId}/forms/${formId}/pipelines`, fetcher);
export const usePipelines = (formId: string, organisationId: string) => {
const { data, error, mutate } = useSWR(
`/api/organisations/${organisationId}/forms/${formId}/pipelines`,
fetcher
);
return {
pipelines: data,
@@ -13,9 +16,9 @@ export const usePipelines = (formId: string, workspaceId: string) => {
};
};
export const usePipeline = (workspaceId: string, formId: string, pipelineId: string) => {
export const usePipeline = (organisationId: string, formId: string, pipelineId: string) => {
const { data, error, mutate } = useSWR(
`/api/workspaces/${workspaceId}/forms/${formId}/pipelines/${pipelineId}`,
`/api/organisations/${organisationId}/forms/${formId}/pipelines/${pipelineId}`,
fetcher
);
@@ -27,9 +30,9 @@ export const usePipeline = (workspaceId: string, formId: string, pipelineId: str
};
};
export const persistPipeline = async (formId, workspaceId, pipeline) => {
export const persistPipeline = async (formId, organisationId, pipeline) => {
try {
await fetch(`/api/workspaces/${workspaceId}/forms/${formId}/pipelines/${pipeline.id}/`, {
await fetch(`/api/organisations/${organisationId}/forms/${formId}/pipelines/${pipeline.id}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pipeline),
@@ -39,9 +42,9 @@ export const persistPipeline = async (formId, workspaceId, pipeline) => {
}
};
export const createPipeline = async (formId: string, workspaceId: string, pipeline = {}) => {
export const createPipeline = async (formId: string, organisationId: string, pipeline = {}) => {
try {
const res = await fetch(`/api/workspaces/${workspaceId}/forms/${formId}/pipelines`, {
const res = await fetch(`/api/organisations/${organisationId}/forms/${formId}/pipelines`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pipeline),
@@ -53,9 +56,9 @@ export const createPipeline = async (formId: string, workspaceId: string, pipeli
}
};
export const deletePipeline = async (formId: string, workspaceId: string, pipelineId: string) => {
export const deletePipeline = async (formId: string, organisationId: string, pipelineId: string) => {
try {
await fetch(`/api/workspaces/${workspaceId}/forms/${formId}/pipelines/${pipelineId}`, {
await fetch(`/api/organisations/${organisationId}/forms/${formId}/pipelines/${pipelineId}`, {
method: "DELETE",
});
} catch (error) {
+6 -6
View File
@@ -66,7 +66,7 @@ async function handleEmailNotification(triggeredEvents, pipeline, form, submissi
await sendSubmissionEmail(
email,
"created",
form.workspaceId,
form.organisationId,
form.id,
form.label,
form.schema,
@@ -77,7 +77,7 @@ async function handleEmailNotification(triggeredEvents, pipeline, form, submissi
await sendSubmissionEmail(
email,
"updated",
form.workspaceId,
form.organisationId,
form.id,
form.label,
form.schema,
@@ -88,7 +88,7 @@ async function handleEmailNotification(triggeredEvents, pipeline, form, submissi
await sendSubmissionEmail(
email,
"finished",
form.workspaceId,
form.organisationId,
form.id,
form.label,
form.schema,
@@ -108,7 +108,7 @@ async function handleSlackNotification(triggeredEvents, pipeline, form, submissi
type: "section",
text: {
type: "mrkdwn",
text: `Someone just filled out your form "${form.label}". <${process.env.NEXTAUTH_URL}/workspaces/${form.workspaceId}/forms/${form.id}/feedback|View in Formbricks>`,
text: `Someone just filled out your form "${form.label}". <${process.env.NEXTAUTH_URL}/organisations/${form.organisationId}/forms/${form.id}/feedback|View in Formbricks>`,
},
},
{
@@ -132,7 +132,7 @@ async function handleSlackNotification(triggeredEvents, pipeline, form, submissi
type: "section",
text: {
type: "mrkdwn",
text: `Someone just updated a submission in your form "${form.label}". <${process.env.NEXTAUTH_URL}/workspaces/${form.workspaceId}/forms/${form.id}/feedback|View in Formbricks>`,
text: `Someone just updated a submission in your form "${form.label}". <${process.env.NEXTAUTH_URL}/organisations/${form.organisationId}/forms/${form.id}/feedback|View in Formbricks>`,
},
},
{
@@ -156,7 +156,7 @@ async function handleSlackNotification(triggeredEvents, pipeline, form, submissi
type: "section",
text: {
type: "mrkdwn",
text: `Someone just finished your form "${form.label}". <${process.env.NEXTAUTH_URL}/workspaces/${form.workspaceId}/forms/${form.id}/feedback|View in Formbricks>`,
text: `Someone just finished your form "${form.label}". <${process.env.NEXTAUTH_URL}/organisations/${form.organisationId}/forms/${form.id}/feedback|View in Formbricks>`,
},
},
{
+13 -10
View File
@@ -1,9 +1,9 @@
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
export const useSubmissions = (workspaceId: string, formId: string) => {
export const useSubmissions = (organisationId: string, formId: string) => {
const { data, error, mutate } = useSWR(
`/api/workspaces/${workspaceId}/forms/${formId}/submissions`,
`/api/organisations/${organisationId}/forms/${formId}/submissions`,
fetcher
);
@@ -15,9 +15,9 @@ export const useSubmissions = (workspaceId: string, formId: string) => {
};
};
export const deleteSubmission = async (workspaceId: string, formId: string, submissionId: string) => {
export const deleteSubmission = async (organisationId: string, formId: string, submissionId: string) => {
try {
await fetch(`/api/workspaces/${workspaceId}/forms/${formId}/submissions/${submissionId}`, {
await fetch(`/api/organisations/${organisationId}/forms/${formId}/submissions/${submissionId}`, {
method: "DELETE",
});
} catch (error) {
@@ -26,13 +26,16 @@ export const deleteSubmission = async (workspaceId: string, formId: string, subm
}
};
export const persistSubmission = async (submission, workspaceId) => {
export const persistSubmission = async (submission, organisationId) => {
try {
await fetch(`/api/workspaces/${workspaceId}/forms/${submission.formId}/submissions/${submission.id}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submission),
});
await fetch(
`/api/organisations/${organisationId}/forms/${submission.formId}/submissions/${submission.id}/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submission),
}
);
} catch (error) {
console.error(error);
}
-13
View File
@@ -1,13 +0,0 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useWorkspace = (id: string) => {
const { data, error, mutate } = useSWR(`/api/workspaces/${id}/`, fetcher);
return {
workspace: data,
isLoadingWorkspace: !error && !data,
isErrorWorkspace: error,
mutateWorkspace: mutate,
};
};
+3 -3
View File
@@ -226,14 +226,14 @@ export const authOptions: NextAuthOptions = {
accounts: {
create: [{ ...account }],
},
workspaces: {
organisations: {
create: [
{
accepted: true,
role: "owner",
workspace: {
organisation: {
create: {
name: `${user.name}'s Workspace`,
name: `${user.name}'s Organisation`,
},
},
},
@@ -0,0 +1 @@
export { default } from "@formbricks/ee/billing/api/create-customer-portal-session";
@@ -0,0 +1 @@
export { config, default } from "@formbricks/ee/billing/api/stripe-webhook";
@@ -60,14 +60,14 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
event.data.customer = {
connectOrCreate: {
where: {
email_workspaceId: {
email_organisationId: {
email: submission.customer.email,
workspaceId: form.workspaceId,
organisationId: form.organisationId,
},
},
create: {
email: customerEmail,
workspace: { connect: { id: form.workspaceId } },
organisation: { connect: { id: form.organisationId } },
data: customerData,
},
},
@@ -86,12 +86,12 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
await runPipelines(pipelineEvents, form, submission, submissionResult);
// tracking
if (submission.finished) {
capturePosthogEvent(form.workspaceId, "submission finished", {
capturePosthogEvent(form.organisationId, "submission finished", {
formId,
});
captureTelemetry("submission finished");
} else {
capturePosthogEvent(form.workspaceId, "submission updated", {
capturePosthogEvent(form.organisationId, "submission updated", {
formId,
});
captureTelemetry("submission updated");
@@ -48,14 +48,14 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
event.data.customer = {
connectOrCreate: {
where: {
email_workspaceId: {
email_organisationId: {
email: submission.customer.email,
workspaceId: form.workspaceId,
organisationId: form.organisationId,
},
},
create: {
email: customerEmail,
workspace: { connect: { id: form.workspaceId } },
organisation: { connect: { id: form.organisationId } },
data: customerData,
},
},
@@ -70,7 +70,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
const submissionResult = await prisma.submission.create(event);
await runPipelines(pipelineEvents, form, submission, submissionResult);
// tracking
capturePosthogEvent(form.workspaceId, "submission received", {
capturePosthogEvent(form.organisationId, "submission received", {
formId,
});
captureTelemetry("submission received");
+3 -3
View File
@@ -9,15 +9,15 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
// GET /api/workspaces
// Get all of my workspaces
// GET /api/organisations
// Get all of my organisations
if (req.method === "GET") {
const memberships = await prisma.membership.findMany({
where: {
user: { email: session.email },
},
include: {
workspace: true,
organisation: true,
},
});
return res.json(memberships);
@@ -9,33 +9,33 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
const customerEmail = req.query.customerEmail.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/customers/[customerEmail]
// Get a specific workspace
// GET /api/organisations[organisationId]/customers/[customerEmail]
// Get a specific organisation
if (req.method === "GET") {
const customer = await prisma.customer.findUnique({
where: {
email_workspaceId: {
email_organisationId: {
email: customerEmail,
workspaceId,
organisationId,
},
},
include: {
@@ -46,15 +46,15 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(customer);
}
// POST /api/workspaces[workspaceId]/customer/[customerEmail]
// POST /api/organisations[organisationId]/customer/[customerEmail]
// Replace a specific customer
else if (req.method === "POST") {
const data = { ...req.body, updatedAt: new Date() };
const prismaRes = await prisma.customer.update({
where: {
email_workspaceId: {
email_organisationId: {
email: customerEmail,
workspaceId,
organisationId,
},
},
data,
@@ -62,14 +62,14 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(prismaRes);
}
// Delete /api/workspaces[workspaceId]/customer/[customerEmail]
// Delete /api/organisations[organisationId]/customer/[customerEmail]
// Deletes a single customer
else if (req.method === "DELETE") {
const prismaRes = await prisma.customer.delete({
where: {
email_workspaceId: {
email_organisationId: {
email: customerEmail,
workspaceId: workspaceId,
organisationId: organisationId,
},
},
});
@@ -9,30 +9,30 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/customers
// Get all customers of a specific workspace
// GET /api/organisations[organisationId]/customers
// Get all customers of a specific organisation
if (req.method === "GET") {
const forms = await prisma.customer.findMany({
where: {
workspace: {
id: workspaceId,
organisation: {
id: organisationId,
},
},
include: {
@@ -10,39 +10,39 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
const formId = req.query.formId.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/forms/[formId]
// Get a specific workspace
// GET /api/organisations[organisationId]/forms/[formId]
// Get a specific organisation
if (req.method === "GET") {
const forms = await prisma.form.findFirst({
where: {
id: formId,
workspaceId,
organisationId,
},
});
return res.json(forms);
}
// POST /api/workspaces[workspaceId]/forms/[formId]
// POST /api/organisations[organisationId]/forms/[formId]
// Replace a specific form
else if (req.method === "POST") {
const data = { ...req.body, updatedAt: new Date() };
@@ -53,13 +53,13 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(prismaRes);
}
// Delete /api/workspaces[workspaceId]/forms/[formId]
// Delete /api/organisations[organisationId]/forms/[formId]
// Deletes a single form
else if (req.method === "DELETE") {
const prismaRes = await prisma.form.delete({
where: { id: formId },
});
capturePosthogEvent(workspaceId, "form created", {
capturePosthogEvent(organisationId, "form created", {
formId,
});
return res.json(prismaRes);
@@ -10,26 +10,26 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
const formId = req.query.formId.toString();
const pipelineId = req.query.pipelineId.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/forms/[formId]/pipelines/[pipelineId]
// GET /api/organisations[organisationId]/forms/[formId]/pipelines/[pipelineId]
// Get a specific pipeline
if (req.method === "GET") {
const pipeline = await prisma.pipeline.findFirst({
@@ -42,7 +42,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(pipeline);
}
// POST /api/workspaces[workspaceId]/forms/[formId]/pipelines/[pipelineId]
// POST /api/organisations[organisationId]/forms/[formId]/pipelines/[pipelineId]
// Replace a specific pipeline
else if (req.method === "POST") {
const data = { ...req.body, updatedAt: new Date() };
@@ -53,13 +53,13 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(prismaRes);
}
// Delete /api/workspaces[workspaceId]/forms/[formId]/pipelines/[pipelineId]
// Delete /api/organisations[organisationId]/forms/[formId]/pipelines/[pipelineId]
// Deletes a single form
else if (req.method === "DELETE") {
const prismaRes = await prisma.pipeline.delete({
where: { id: pipelineId },
});
capturePosthogEvent(workspaceId, "pipeline deleted", {
capturePosthogEvent(organisationId, "pipeline deleted", {
formId,
pipelineId,
});
@@ -10,25 +10,25 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
const formId = req.query.formId.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/forms/[formId]/pipelines
// GET /api/organisations[organisationId]/forms/[formId]/pipelines
// Get pipelines
if (req.method === "GET") {
// get submission
@@ -36,7 +36,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
where: {
form: {
id: formId,
workspaceId,
organisationId,
},
},
});
@@ -44,7 +44,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(pipelines);
}
// POST /api/workspaces[workspaceId]/forms/[formId]/pipelines
// POST /api/organisations[organisationId]/forms/[formId]/pipelines
// Create a new pipeline
// Required fields in body: name, type
// Optional fields in body: enabled, config
@@ -58,7 +58,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
form: { connect: { id: formId } },
},
});
capturePosthogEvent(workspaceId, "pipeline created", {
capturePosthogEvent(organisationId, "pipeline created", {
formId,
pipelineId: result.id,
});
@@ -9,26 +9,26 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
const formId = req.query.formId.toString();
const submissionId = req.query.submissionId.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/forms/[formId]/submissions/[submissionId]
// GET /api/organisations[organisationId]/forms/[formId]/submissions/[submissionId]
// Get a specific submission
if (req.method === "GET") {
const submission = await prisma.submission.findFirst({
@@ -41,7 +41,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(submission);
}
// POST /api/workspaces[workspaceId]/forms/[formId]/submissions/[submissionId]
// POST /api/organisations[organisationId]/forms/[formId]/submissions/[submissionId]
// Replace a specific submission
else if (req.method === "POST") {
const data = { ...req.body, updatedAt: new Date() };
@@ -52,7 +52,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(prismaRes);
}
// Delete /api/workspaces[workspaceId]/forms/[formId]/submissions/[submissionId]
// Delete /api/organisations[organisationId]/forms/[formId]/submissions/[submissionId]
// Deletes a single form
else if (req.method === "DELETE") {
const prismaRes = await prisma.submission.delete({
@@ -9,25 +9,25 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
const formId = req.query.formId.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/forms/[formId]/submissions
// GET /api/organisations[organisationId]/forms/[formId]/submissions
// Get submissions
if (req.method === "GET") {
// get submission
@@ -10,30 +10,30 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
// check workspace permission
// check organisation permission
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
// GET /api/workspaces[workspaceId]/forms
// Get a specific workspace
// GET /api/organisations[organisationId]/forms
// Get a specific organisation
if (req.method === "GET") {
const forms = await prisma.form.findMany({
where: {
workspace: {
id: workspaceId,
organisation: {
id: organisationId,
},
},
include: {
@@ -46,7 +46,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(forms);
}
// POST /api/workspaces[workspaceId]/forms
// POST /api/organisations[organisationId]/forms
// Create a new form
// Required fields in body: -
// Optional fields in body: label, schema
@@ -57,10 +57,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
const result = await prisma.form.create({
data: {
...form,
workspace: { connect: { id: workspaceId } },
organisation: { connect: { id: organisationId } },
},
});
capturePosthogEvent(workspaceId, "form created", {
capturePosthogEvent(organisationId, "form created", {
formId: result.id,
});
res.json(result);
@@ -9,31 +9,31 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
const workspaceId = req.query.workspaceId.toString();
const organisationId = req.query.organisationId.toString();
// GET /api/workspaces[workspaceId]
// Get a specific workspace
// GET /api/organisations[organisationId]
// Get a specific organisation
if (req.method === "GET") {
// check if membership exists
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId_organisationId: {
userId: user.id,
workspaceId,
organisationId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this workspace or this workspace doesn't exist" });
.json({ message: "You don't have access to this organisation or this organisation doesn't exist" });
}
const workspace = await prisma.workspace.findUnique({
const organisation = await prisma.organisation.findUnique({
where: {
id: workspaceId,
id: organisationId,
},
});
return res.json(workspace);
return res.json(organisation);
}
// Unknown HTTP Method
+3 -3
View File
@@ -21,14 +21,14 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
const userData = await prisma.user.create({
data: {
...user,
workspaces: {
organisations: {
create: [
{
accepted: true,
role: "owner",
workspace: {
organisation: {
create: {
name: `${user.name}'s Workspace`,
name: `${user.name}'s Organisation`,
},
},
},
@@ -0,0 +1,25 @@
import LayoutApp from "@/components/layout/LayoutApp";
import { Button, Confetti } from "@formbricks/ui";
export default function Billing({}) {
if (process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1") {
return <div>Not available</div>;
}
return (
<LayoutApp>
<Confetti />
<div className="mx-auto max-w-sm py-8 sm:px-6 lg:px-8">
<div className="my-6 sm:flex-auto">
<h1 className="text-center text-xl font-semibold text-gray-900">Upgrade successful</h1>
<p className="mt-2 text-center text-sm text-gray-700">
Thanks a lot for upgrading your formbricks subscription. You can now access all features and
improve your user research.
</p>
</div>
<Button className="w-full justify-center" href="/">
Got to my forms
</Button>
</div>
</LayoutApp>
);
}
+2 -2
View File
@@ -14,8 +14,8 @@ export default function ProjectsPage() {
useEffect(() => {
if (session && memberships && memberships.length > 0) {
const workspaceId = memberships[0].workspaceId;
router.push(`/workspaces/${workspaceId}/forms`);
const organisationId = memberships[0].organisationId;
router.push(`/organisations/${organisationId}/forms`);
}
if (!session) {
router.push(`/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`);
+1 -1
View File
@@ -3,7 +3,7 @@
import LayoutApp from "@/components/layout/LayoutApp";
import ProfileSettingsPage from "@/components/me/ProfileSettingsPage";
export default function WorkspaceFormsPage({}) {
export default function OrganisationFormsPage({}) {
return (
<LayoutApp>
<ProfileSettingsPage />
@@ -2,14 +2,14 @@
import SingleCustomerPage from "@/components/customers/SingleCustomerPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Customers({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<SingleCustomerPage />
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -2,14 +2,14 @@
import CustomersPage from "@/components/customers/CustomersPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Customers({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<CustomersPage />
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,16 +1,16 @@
import FormOverviewPage from "@/components/forms/custom/FormOverviewPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function FormOverview({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<LayoutWrapperForm>
<FormOverviewPage />
</LayoutWrapperForm>
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,18 +1,18 @@
import PipelinesPage from "@/components/forms/pipelines/PipelinesOverview";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperCustomForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Pipeline({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<LayoutWrapperCustomForm>
<div className="p-5">
<PipelinesPage />
</div>
</LayoutWrapperCustomForm>
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,16 +1,16 @@
import SubmissionsPage from "@/components/forms/submissions/SubmissionsPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Submissions({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<LayoutWrapperForm>
<SubmissionsPage />
</LayoutWrapperForm>
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,16 +1,16 @@
import SummaryPage from "@/components/forms/summary/SummaryPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function Submissions({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<LayoutWrapperForm>
<SummaryPage />
</LayoutWrapperForm>
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -2,14 +2,14 @@
import FeedbackPage from "@/components/forms/feedback/FeedbackPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function WorkspaceFormsPage({}) {
export default function OrganisationFormsPage({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<FeedbackPage />
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,16 +1,16 @@
import FormOverviewPage from "@/components/forms/custom/FormOverviewPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperForm from "@/components/layout/LayoutWrapperCustomForm";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function FormOverview({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<LayoutWrapperForm>
<FormOverviewPage />
</LayoutWrapperForm>
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -0,0 +1,15 @@
"use client";
import PMFPage from "@/components/forms/pmf/PMFPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function OrganisationFormsPage({}) {
return (
<LayoutApp>
<LayoutWrapperOrganisation>
<PMFPage />
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -0,0 +1,15 @@
"use client";
import FormsPage from "@/components/forms/FormsPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
export default function OrganisationFormsPage({}) {
return (
<LayoutApp>
<LayoutWrapperOrganisation>
<FormsPage />
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -0,0 +1,13 @@
import LayoutApp from "@/components/layout/LayoutApp";
import BillingPage from "@/components/settings/BillingPage";
export default function Billing({}) {
if (process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1") {
return <div>Not available</div>;
}
return (
<LayoutApp>
<BillingPage />
</LayoutApp>
);
}
@@ -1,15 +1,15 @@
"use client";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
import LayoutWrapperOrganisation from "@/components/layout/LayoutWrapperOrganisation";
import SettingsPage from "@/components/settings/SettingsPage";
export default function WorkspaceFormsPage({}) {
export default function OrganisationFormsPage({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<LayoutWrapperOrganisation>
<SettingsPage />
</LayoutWrapperWorkspace>
</LayoutWrapperOrganisation>
</LayoutApp>
);
}
@@ -1,15 +0,0 @@
"use client";
import PMFPage from "@/components/forms/pmf/PMFPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
export default function WorkspaceFormsPage({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<PMFPage />
</LayoutWrapperWorkspace>
</LayoutApp>
);
}
@@ -1,15 +0,0 @@
"use client";
import FormsPage from "@/components/forms/FormsPage";
import LayoutApp from "@/components/layout/LayoutApp";
import LayoutWrapperWorkspace from "@/components/layout/LayoutWrapperWorkspace";
export default function WorkspaceFormsPage({}) {
return (
<LayoutApp>
<LayoutWrapperWorkspace>
<FormsPage />
</LayoutWrapperWorkspace>
</LayoutApp>
);
}
@@ -0,0 +1,71 @@
/*
Warnings:
- The primary key for the `Customer` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `Membership` table will be changed. If it partially fails, the table could be left without primary key constraint.
- Added the required column `organisationId` to the `Customer` table without a default value. This is not possible if the table is not empty.
- Added the required column `organisationId` to the `Form` table without a default value. This is not possible if the table is not empty.
- Added the required column `organisationId` to the `Membership` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Customer" DROP CONSTRAINT "Customer_workspaceId_fkey";
-- AlterTable
ALTER TABLE "Form" DROP CONSTRAINT "Form_workspaceId_fkey";
-- AlterTable
ALTER TABLE "Membership" DROP CONSTRAINT "Membership_workspaceId_fkey";
-- AlterTable
ALTER TABLE "Submission" DROP CONSTRAINT "Submission_customerEmail_customerWorkspaceId_fkey";
-- AlterTable
ALTER TABLE "Customer" DROP CONSTRAINT "Customer_pkey";
-- AlterTable
ALTER TABLE "Membership" DROP CONSTRAINT "Membership_pkey";
-- AlterTable
ALTER TABLE "Customer"
RENAME COLUMN "workspaceId" TO "organisationId";
-- AlterTable
ALTER TABLE "Form"
RENAME COLUMN "workspaceId" TO "organisationId";
-- AlterTable
ALTER TABLE "Membership"
RENAME COLUMN "workspaceId" TO "organisationId";
-- AlterTable
ALTER TABLE "Submission"
RENAME COLUMN "customerWorkspaceId" TO "customerOrganisationId";
-- AlterTable
ALTER TABLE "Workspace" RENAME TO "Organisation";
-- AlterTable
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_pkey" PRIMARY KEY ("email", "organisationId");
-- AlterTable
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_pkey" PRIMARY KEY ("userId", "organisationId");
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Form" ADD CONSTRAINT "Form_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Submission" ADD CONSTRAINT "Submission_customerEmail_customerOrganisationId_fkey" FOREIGN KEY ("customerEmail", "customerOrganisationId") REFERENCES "Customer"("email", "organisationId") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "Organisation" RENAME CONSTRAINT "Workspace_pkey" TO "Organisation_pkey";
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('free', 'pro');
-- AlterTable
ALTER TABLE "Organisation" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'free',
ADD COLUMN "stripeCustomerId" TEXT;
+53 -46
View File
@@ -37,15 +37,15 @@ model Pipeline {
}
model Customer {
email String
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
submissions Submission[]
data Json @default("{}")
email String
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
organisationId String
submissions Submission[]
data Json @default("{}")
@@id([email, workspaceId])
@@id([email, organisationId])
}
enum FormType {
@@ -55,41 +55,48 @@ enum FormType {
}
model Form {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
type FormType
label String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
schema Json @default("{}")
submissions Submission[]
pipelines Pipeline[]
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
type FormType
label String
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
organisationId String
schema Json @default("{}")
submissions Submission[]
pipelines Pipeline[]
}
model Submission {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
archived Boolean @default(false)
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
customer Customer? @relation(fields: [customerEmail, customerWorkspaceId], references: [email, workspaceId])
customerEmail String?
customerWorkspaceId String?
data Json @default("{}")
meta Json @default("{}")
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
archived Boolean @default(false)
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
customer Customer? @relation(fields: [customerEmail, customerOrganisationId], references: [email, organisationId])
customerEmail String?
customerOrganisationId String?
data Json @default("{}")
meta Json @default("{}")
}
model Workspace {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
members Membership[]
forms Form[]
customers Customer[]
enum Plan {
free
pro
}
model Organisation {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
members Membership[]
forms Form[]
customers Customer[]
plan Plan @default(free)
stripeCustomerId String?
}
enum MembershipRole {
@@ -99,14 +106,14 @@ enum MembershipRole {
}
model Membership {
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
accepted Boolean @default(false)
role MembershipRole
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
organisationId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
accepted Boolean @default(false)
role MembershipRole
@@id([userId, workspaceId])
@@id([userId, organisationId])
}
model ApiKey {
@@ -154,7 +161,7 @@ model User {
password String?
identityProvider IdentityProvider @default(email)
identityProviderAccountId String?
workspaces Membership[]
organisations Membership[]
accounts Account[]
apiKeys ApiKey[]
}
+43
View File
@@ -0,0 +1,43 @@
The Formbricks.com Enterprise Edition (EE) license (the “EE License”)
Copyright (c) 2020-present Formbricks.com
With regard to the Formbricks.com Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Formbricks.com Subscription Terms of Service, available
at https://formbricks.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Formbricks.com,
and otherwise have a valid Formbricks.com Enterprise license for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Formbricks.com
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid PostHog Enterprise license for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Formbricks.com and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This Enterprise License applies only to the part of this Software that is not distributed under
the MIT license. Any part of this Software distributed under the MIT license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the MIT license. The full text of this Enterprise License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Formbricks.com Software, those
components are licensed under the original license provided by the owner of the
applicable component.
@@ -0,0 +1,26 @@
//import { buffer } from "micro";
import { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: "2022-11-15",
});
const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
const { stripeCustomerId, returnUrl } = req.body;
// Authenticate your user.
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl,
});
res.json({ sessionUrl: session.url });
} else {
res.setHeader("Allow", "POST");
res.status(405).end("Method Not Allowed");
}
};
export default webhookHandler;
+75
View File
@@ -0,0 +1,75 @@
//import { buffer } from "micro";
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@formbricks/database";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: "2022-11-15",
});
const webhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET!;
// Stripe requires the raw body to construct the event.
export const config = {
api: {
bodyParser: false,
},
};
async function buffer(readable: any) {
const chunks = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
const buf = await buffer(req);
const sig = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(buf.toString(), sig, webhookSecret);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
// On error, log and return the error message.
if (err! instanceof Error) console.log(err);
console.log(`Error message: ${errorMessage}`);
res.status(400).send(`Webhook Error: ${errorMessage}`);
return;
}
// Cast event data to Stripe object.
if (event.type === "checkout.session.completed") {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
const organisationId = checkoutSession.client_reference_id;
if (!organisationId) {
console.error("No organisationId found in checkout session");
return res.json({ message: "skipping, no organisationId found" });
}
const stripeCustomerId = checkoutSession.customer as string;
const plan = "pro";
await prisma.organisation.update({
where: { id: organisationId },
data: {
stripeCustomerId,
plan,
},
});
} else {
console.warn(`🤷‍♀️ Unhandled event type: ${event.type}`);
}
// Return a response to acknowledge receipt of the event.
res.json({ received: true });
} else {
res.setHeader("Allow", "POST");
res.status(405).end("Method Not Allowed");
}
};
export default webhookHandler;
@@ -0,0 +1,27 @@
import Script from "next/script";
declare global {
namespace JSX {
interface IntrinsicElements {
["stripe-pricing-table"]: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
}
}
}
export default function BillingPage({ organisationId }: { organisationId: string }) {
if (!process.env.NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID || !process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY) {
return <div>Stripe environment variables not set</div>;
}
console.log(organisationId);
return (
<>
<Script async src="https://js.stripe.com/v3/pricing-table.js" />
<stripe-pricing-table
pricing-table-id={process.env.NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID}
publishable-key={process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY}
client-reference-id={organisationId}></stripe-pricing-table>
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@formbricks/ee",
"sideEffects": false,
"description": "Formbricks Enterprise Features",
"authors": "Formbricks",
"version": "1.0.0",
"main": "index.ts",
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"concurrently": "^7.5.0",
"eslint": "^8.27.0",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.19",
"react": "^18.2.0",
"typescript": "^4.8.4"
},
"dependencies": {
"@formbricks/database": "workspace:*",
"next": "^13.1.6"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@formbricks/tsconfig/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["/*"]
},
"resolveJsonModule": true
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
+3 -1
View File
@@ -35,6 +35,8 @@
"dependencies": {
"clsx": "^1.2.1",
"next": "^13.0.2",
"react-dom": "^18.2.0"
"react-confetti": "^6.1.0",
"react-dom": "^18.2.0",
"react-use": "^17.4.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { useWindowSize } from "react-use";
import ReactConfetti from "react-confetti";
export function Confetti({ colors = ["#00C4B8", "#eee"] }: { colors?: string[] }) {
const { width, height } = useWindowSize();
return <ReactConfetti width={width} height={height} colors={colors} numberOfPieces={400} recycle={false} />;
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./Button";
export * from "./Confetti";
/* Icons */
export * from "./icons/BackIcon";
+266 -30
View File
@@ -12,7 +12,7 @@ importers:
'@changesets/cli': 2.25.0
prettier: 2.8.3
tsx: 3.9.0
turbo: 1.7.0
turbo: 1.7.2
apps/demo:
specifiers:
@@ -183,6 +183,7 @@ importers:
specifiers:
'@formbricks/charts': workspace:*
'@formbricks/database': workspace:*
'@formbricks/ee': workspace:*
'@formbricks/react': workspace:*
'@formbricks/tailwind-config': workspace:*
'@formbricks/tsconfig': workspace:*
@@ -203,6 +204,7 @@ importers:
jsonwebtoken: ^9.0.0
next: ^13.1.6
next-auth: ^4.19.0
next-transpile-modules: ^10.0.0
nodemailer: ^6.9.1
platform: ^1.3.6
postcss: ^8.4.21
@@ -212,10 +214,12 @@ importers:
react-icons: ^4.7.1
react-loader-spinner: ^5.3.4
react-toastify: ^9.1.1
stripe: ^11.8.0
swr: ^2.0.3
typescript: ^4.9.4
dependencies:
'@formbricks/charts': link:../../packages/charts
'@formbricks/ee': link:../../packages/ee
'@formbricks/react': link:../../packages/react
'@formbricks/ui': link:../../packages/ui
'@headlessui/react': 1.7.8_biqbaboplfbrettd7655fr4n2y
@@ -228,6 +232,7 @@ importers:
jsonwebtoken: 9.0.0
next: 13.1.6_biqbaboplfbrettd7655fr4n2y
next-auth: 4.19.0_phzvn2nhb6zk7o4ds4z4gvqlei
next-transpile-modules: 10.0.0
nodemailer: 6.9.1
platform: 1.3.6
prismjs: 1.29.0
@@ -236,6 +241,7 @@ importers:
react-icons: 4.7.1_react@18.2.0
react-loader-spinner: 5.3.4_biqbaboplfbrettd7655fr4n2y
react-toastify: 9.1.1_biqbaboplfbrettd7655fr4n2y
stripe: 11.8.0
swr: 2.0.3_react@18.2.0
devDependencies:
'@formbricks/database': link:../../packages/database
@@ -304,6 +310,33 @@ importers:
tsx: 3.12.1
typescript: 4.8.4
packages/ee:
specifiers:
'@formbricks/database': workspace:*
'@formbricks/tsconfig': workspace:*
'@types/react': ^18.0.25
'@types/react-dom': ^18.0.8
concurrently: ^7.5.0
eslint: ^8.27.0
eslint-config-formbricks: workspace:*
next: ^13.1.6
postcss: ^8.4.19
react: ^18.2.0
typescript: ^4.8.4
dependencies:
'@formbricks/database': link:../database
next: 13.1.6_biqbaboplfbrettd7655fr4n2y
devDependencies:
'@formbricks/tsconfig': link:../tsconfig
'@types/react': 18.0.27
'@types/react-dom': 18.0.10
concurrently: 7.6.0
eslint: 8.33.0
eslint-config-formbricks: link:../eslint-config-formbricks
postcss: 8.4.21
react: 18.2.0
typescript: 4.9.4
packages/eslint-config-formbricks:
specifiers:
eslint: ^8.26.0
@@ -425,13 +458,17 @@ importers:
next: ^13.0.2
postcss: ^8.4.19
react: ^18.2.0
react-confetti: ^6.1.0
react-dom: ^18.2.0
react-use: ^17.4.0
tsup: ^6.4.0
typescript: ^4.8.4
dependencies:
clsx: 1.2.1
next: 13.0.5_biqbaboplfbrettd7655fr4n2y
react-confetti: 6.1.0_react@18.2.0
react-dom: 18.2.0_react@18.2.0
react-use: 17.4.0_biqbaboplfbrettd7655fr4n2y
devDependencies:
'@formbricks/tsconfig': link:../tsconfig
'@types/react': 18.0.25
@@ -712,6 +749,20 @@ packages:
lru-cache: 5.1.1
semver: 6.3.0
/@babel/helper-compilation-targets/7.20.7_@babel+core@7.20.5:
resolution: {integrity: sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
'@babel/compat-data': 7.20.5
'@babel/core': 7.20.5
'@babel/helper-validator-option': 7.18.6
browserslist: 4.21.4
lru-cache: 5.1.1
semver: 6.3.0
dev: true
/@babel/helper-create-class-features-plugin/7.20.5_@babel+core@7.20.12:
resolution: {integrity: sha512-3RCdA/EmEaikrhayahwToF0fpweU/8o2p8vhc1c/1kftHOdTKuC65kik/TLc+qfbS8JKw4qqJbne4ovICDhmww==}
engines: {node: '>=6.9.0'}
@@ -2521,7 +2572,7 @@ packages:
'@babel/helper-module-imports': 7.18.6
'@babel/helper-plugin-utils': 7.20.2
'@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.20.5
'@babel/types': 7.20.5
'@babel/types': 7.20.7
dev: true
/@babel/plugin-transform-react-pure-annotations/7.18.6_@babel+core@7.20.12:
@@ -2840,7 +2891,7 @@ packages:
dependencies:
'@babel/compat-data': 7.20.5
'@babel/core': 7.20.5
'@babel/helper-compilation-targets': 7.20.0_@babel+core@7.20.5
'@babel/helper-compilation-targets': 7.20.7_@babel+core@7.20.5
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-validator-option': 7.18.6
'@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.18.6_@babel+core@7.20.5
@@ -2908,7 +2959,7 @@ packages:
'@babel/plugin-transform-unicode-escapes': 7.18.10_@babel+core@7.20.5
'@babel/plugin-transform-unicode-regex': 7.18.6_@babel+core@7.20.5
'@babel/preset-modules': 0.1.5_@babel+core@7.20.5
'@babel/types': 7.20.5
'@babel/types': 7.20.7
babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.20.5
babel-plugin-polyfill-corejs3: 0.6.0_@babel+core@7.20.5
babel-plugin-polyfill-regenerator: 0.4.1_@babel+core@7.20.5
@@ -6129,6 +6180,10 @@ packages:
'@types/istanbul-lib-report': 3.0.0
dev: true
/@types/js-cookie/2.2.7:
resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==}
dev: false
/@types/json-schema/7.0.11:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
@@ -6617,6 +6672,10 @@ packages:
'@xtuc/long': 4.2.2
dev: true
/@xobotyi/scrollbar-width/1.9.5:
resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
dev: false
/@xtuc/ieee754/1.2.0:
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -8532,6 +8591,12 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/copy-to-clipboard/3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
dependencies:
toggle-selection: 1.0.6
dev: false
/core-js-compat/3.26.1:
resolution: {integrity: sha512-622/KzTudvXCDLRw70iHW4KKs1aGpcRcowGWyYJr2DEBfRrd6hNJybxSWJFuZYD4ma86xhrwDDHxmDaIq4EA8A==}
dependencies:
@@ -8713,6 +8778,12 @@ packages:
postcss: 8.4.21
dev: true
/css-in-js-utils/3.1.0:
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
dependencies:
hyphenate-style-name: 1.0.4
dev: false
/css-loader/3.6.0_webpack@4.46.0:
resolution: {integrity: sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==}
engines: {node: '>= 8.9.0'}
@@ -8759,7 +8830,6 @@ packages:
dependencies:
mdn-data: 2.0.14
source-map: 0.6.1
dev: true
/css-unit-converter/1.1.2:
resolution: {integrity: sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==}
@@ -9472,7 +9542,6 @@ packages:
resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==}
dependencies:
stackframe: 1.3.4
dev: true
/es-abstract/1.20.4:
resolution: {integrity: sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA==}
@@ -10559,6 +10628,14 @@ packages:
/fast-levenshtein/2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
/fast-loops/1.1.3:
resolution: {integrity: sha512-8EZzEP0eKkEEVX+drtd9mtuQ+/QrlfW/5MlwcwK5Nds6EkZ/tRzEexkzUY2mIssnAyVLT+TKHuRXmFNNXYUd6g==}
dev: false
/fast-shallow-equal/1.0.0:
resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==}
dev: false
/fast-url-parser/1.1.3:
resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==}
dependencies:
@@ -10569,6 +10646,10 @@ packages:
resolution: {integrity: sha512-WvJe06IfNYlr+6cO3uQkdKdy3Cb1LlCJSF8zRs2eT8yuhdbSlR9nIt+TgQ92RUxiRrQm+/S7RARnMfCs5iuAjw==}
dev: true
/fastest-stable-stringify/2.0.2:
resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==}
dev: false
/fastq/1.13.0:
resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
dependencies:
@@ -11727,6 +11808,10 @@ packages:
engines: {node: '>=10.17.0'}
dev: true
/hyphenate-style-name/1.0.4:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
dev: false
/iconv-lite/0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -11852,6 +11937,13 @@ packages:
/inline-style-parser/0.1.1:
resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
/inline-style-prefixer/6.0.4:
resolution: {integrity: sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==}
dependencies:
css-in-js-utils: 3.1.0
fast-loops: 1.1.3
dev: false
/inquirer/7.3.3:
resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==}
engines: {node: '>=8.0.0'}
@@ -12542,6 +12634,10 @@ packages:
engines: {node: '>=10'}
dev: true
/js-cookie/2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
dev: false
/js-sdsl/4.2.0:
resolution: {integrity: sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==}
@@ -13323,7 +13419,6 @@ packages:
/mdn-data/2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
dev: true
/mdurl/1.0.1:
resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==}
@@ -14164,6 +14259,24 @@ packages:
dev: true
optional: true
/nano-css/5.3.5_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-vSB9X12bbNu4ALBu7nigJgRViZ6ja3OU7CeuiV1zMIbXOdmkLahgtPmh3GBOlDxbKY0CitqlPdOReGlBLSp+yg==}
peerDependencies:
react: '*'
react-dom: '*'
dependencies:
css-tree: 1.1.3
csstype: 3.1.1
fastest-stable-stringify: 2.0.2
inline-style-prefixer: 6.0.4
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
rtl-css-js: 1.16.1
sourcemap-codec: 1.4.8
stacktrace-js: 2.0.2
stylis: 4.1.3
dev: false
/nanoid/3.3.4:
resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -14264,6 +14377,13 @@ packages:
next: 13.1.6_biqbaboplfbrettd7655fr4n2y
dev: false
/next-transpile-modules/10.0.0:
resolution: {integrity: sha512-FyeJ++Lm2Fq31gbThiRCrJlYpIY9QaI7A3TjuhQLzOix8ChQrvn5ny4MhfIthS5cy6+uK1AhDRvxVdW17y3Xdw==}
deprecated: All features of next-transpile-modules are now natively built-in Next.js 13.1. Please use Next's transpilePackages option :)
dependencies:
enhanced-resolve: 5.12.0
dev: false
/next/13.0.5_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-awpc3DkphyKydwCotcBnuKwh6hMqkT5xdiBK4OatJtOZurDPBYLP62jtM2be/4OunpmwIbsS0Eyv+ZGU97ciEg==}
engines: {node: '>=14.6.0'}
@@ -16051,7 +16171,6 @@ packages:
engines: {node: '>=0.6'}
dependencies:
side-channel: 1.0.4
dev: true
/querystring-es3/0.2.1:
resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==}
@@ -16132,6 +16251,16 @@ packages:
minimist: 1.2.7
strip-json-comments: 2.0.1
/react-confetti/6.1.0_react@18.2.0:
resolution: {integrity: sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==}
engines: {node: '>=10.18'}
peerDependencies:
react: ^16.3.0 || ^17.0.1 || ^18.0.0
dependencies:
react: 18.2.0
tween-functions: 1.2.0
dev: false
/react-docgen-typescript/2.2.2_typescript@4.9.4:
resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==}
peerDependencies:
@@ -16336,6 +16465,40 @@ packages:
react-lifecycles-compat: 3.0.4
dev: false
/react-universal-interface/0.6.2_react@18.2.0+tslib@2.4.1:
resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==}
peerDependencies:
react: '*'
tslib: '*'
dependencies:
react: 18.2.0
tslib: 2.4.1
dev: false
/react-use/17.4.0_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-TgbNTCA33Wl7xzIJegn1HndB4qTS9u03QUwyNycUnXaweZkE4Kq2SB+Yoxx8qbshkZGYBDvUXbXWRUmQDcZZ/Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@types/js-cookie': 2.2.7
'@xobotyi/scrollbar-width': 1.9.5
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 2.2.1
nano-css: 5.3.5_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-universal-interface: 0.6.2_react@18.2.0+tslib@2.4.1
resize-observer-polyfill: 1.5.1
screenfull: 5.2.0
set-harmonic-interval: 1.0.1
throttle-debounce: 3.0.1
ts-easing: 0.2.0
tslib: 2.4.1
dev: false
/react/18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}
@@ -16773,6 +16936,10 @@ packages:
/require-main-filename/2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
/resize-observer-polyfill/1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
dev: false
/resolve-from/4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -16982,6 +17149,12 @@ packages:
engines: {node: 6.* || >= 7.*}
dev: true
/rtl-css-js/1.16.1:
resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==}
dependencies:
'@babel/runtime': 7.20.6
dev: false
/run-async/2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'}
@@ -17106,6 +17279,11 @@ packages:
ajv: 6.12.6
ajv-keywords: 3.5.2_ajv@6.12.6
/screenfull/5.2.0:
resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==}
engines: {node: '>=0.10.0'}
dev: false
/semver-diff/2.1.0:
resolution: {integrity: sha512-gL8F8L4ORwsS0+iQ34yCYv///jsOq0ZL7WP55d1HnJ32o7tyFYEFQZQA22mrLIacZdU6xecaBBZ+uEiffGNyXw==}
engines: {node: '>=0.10.0'}
@@ -17236,6 +17414,11 @@ packages:
/set-blocking/2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
/set-harmonic-interval/1.0.1:
resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==}
engines: {node: '>=6.9'}
dev: false
/set-value/2.0.1:
resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==}
engines: {node: '>=0.10.0'}
@@ -17453,6 +17636,11 @@ packages:
deprecated: See https://github.com/lydell/source-map-url#deprecated
dev: true
/source-map/0.5.6:
resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==}
engines: {node: '>=0.10.0'}
dev: false
/source-map/0.5.7:
resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
engines: {node: '>=0.10.0'}
@@ -17476,7 +17664,6 @@ packages:
/sourcemap-codec/1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
deprecated: Please use @jridgewell/sourcemap-codec instead
dev: true
/space-separated-tokens/1.1.5:
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
@@ -17552,9 +17739,29 @@ packages:
deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
dev: true
/stack-generator/2.0.10:
resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==}
dependencies:
stackframe: 1.3.4
dev: false
/stackframe/1.3.4:
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
dev: true
/stacktrace-gps/3.1.2:
resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==}
dependencies:
source-map: 0.5.6
stackframe: 1.3.4
dev: false
/stacktrace-js/2.0.2:
resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==}
dependencies:
error-stack-parser: 2.1.4
stack-generator: 2.0.10
stacktrace-gps: 3.1.2
dev: false
/state-toggle/1.0.3:
resolution: {integrity: sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==}
@@ -17807,6 +18014,14 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
/stripe/11.8.0:
resolution: {integrity: sha512-aGwrJDqYzpjQj0ejt7oN7BE7kUjZFxhUz/gDeyDCS7CBpZhDb26Eb6z9sS8KdbsbmuS8rkkn2lBY4koK7L1ZCw==}
engines: {node: '>=12.*'}
dependencies:
'@types/node': 18.11.18
qs: 6.11.0
dev: false
/style-inject/0.3.0:
resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==}
dev: true
@@ -17918,6 +18133,10 @@ packages:
postcss-selector-parser: 6.0.11
dev: true
/stylis/4.1.3:
resolution: {integrity: sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==}
dev: false
/sucrase/3.29.0:
resolution: {integrity: sha512-bZPAuGA5SdFHuzqIhTAqt9fvNEo9rESqXIG3oiKdF8K4UmkQxC4KlNL3lVyAErXp+mPvUqZ5l13qx6TrDIGf3A==}
engines: {node: '>=8'}
@@ -18274,6 +18493,11 @@ packages:
any-promise: 1.3.0
dev: true
/throttle-debounce/3.0.1:
resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
engines: {node: '>=10'}
dev: false
/through/2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: false
@@ -18364,6 +18588,10 @@ packages:
safe-regex: 1.1.0
dev: true
/toggle-selection/1.0.6:
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
dev: false
/toidentifier/1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -18419,6 +18647,10 @@ packages:
engines: {node: '>=6.10'}
dev: true
/ts-easing/0.2.0:
resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==}
dev: false
/ts-interface-checker/0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
dev: true
@@ -18662,67 +18894,71 @@ packages:
safe-buffer: 5.2.1
dev: false
/turbo-darwin-64/1.7.0:
resolution: {integrity: sha512-hSGAueSf5Ko8J67mpqjpt9FsP6ePn1nMcl7IVPoJq5dHsgX3anCP/BPlexJ502bNK+87DDyhQhJ/LPSJXKrSYQ==}
/turbo-darwin-64/1.7.2:
resolution: {integrity: sha512-Sml3WR8MSu80W+gS8SnoKNImcDOlIX7zlvezzds65mW11yGniIFfZ18aKWGOm92Nj2SvXCQ2+UmyGghbFaHNmQ==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-darwin-arm64/1.7.0:
resolution: {integrity: sha512-BLLOW5W6VZxk5+0ZOj5AO1qjM0P5isIgjbEuyAl8lHZ4s9antUbY4CtFrspT32XxPTYoDl4UjviPMcSsbcl3WQ==}
/turbo-darwin-arm64/1.7.2:
resolution: {integrity: sha512-JnlgGLScboUJGJxvmSsF+5xkImEDTMPg2FHzX4n8AMB9az9ZlPQAMtc+xu4p6Xp9eaykKiV2RG81YS3H0fxDLA==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-linux-64/1.7.0:
resolution: {integrity: sha512-aw2qxmfZa+kT87SB3GNUoFimqEPzTlzlRqhPgHuAAT6Uf0JHnmebPt4K+ZPtDNl5yfVmtB05bhHPqw+5QV97Yg==}
/turbo-linux-64/1.7.2:
resolution: {integrity: sha512-vbLJw6ovG+lpiPqxniscBjljKJ2jbsHuKp8uK4j/wqgp68wAVKeAZW77GGDAUgDb88XH6Kvhh2hcizL+iWduww==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-linux-arm64/1.7.0:
resolution: {integrity: sha512-AJEx2jX+zO5fQtJpO3r6uhTabj4oSA5ZhB7zTs/rwu/XqoydsvStA4X8NDW4poTbOjF7DcSHizqwi04tSMzpJw==}
/turbo-linux-arm64/1.7.2:
resolution: {integrity: sha512-zLnuS8WdHonKL74KqOopOH/leBOWumlVGF8/8hldbDPq0mwY+6myRR5/5LdveB51rkG4UJh/sQ94xV67tjBoyw==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-windows-64/1.7.0:
resolution: {integrity: sha512-ewj7PPv2uxqv0r31hgnBa3E5qwUu7eyVRP5M1gB/TJXfSHduU79gbxpKCyxIZv2fL/N2/3U7EPOQPSZxBAoljA==}
/turbo-windows-64/1.7.2:
resolution: {integrity: sha512-oE5PMoXjmR09okvVzteFb6FjA6yo+nMsacsgKH2yLNq4sOrVo9tG98JkRurOv5+L6ZQ3yGXPxWHiqeH7hLkAVQ==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo-windows-arm64/1.7.0:
resolution: {integrity: sha512-LzjOUzveWkvTD0jP8DBMYiAnYemmydsvqxdSmsUapHHJkl6wKZIOQNSO7pxsy+9XM/1/+0f9Y9F9ZNl5lePTEA==}
/turbo-windows-arm64/1.7.2:
resolution: {integrity: sha512-mdTUJk23acRv5qxA/yEstYhM1VFenVE3FDrssxGRFq7S80smtCGK1xUd4BEDDzDlVXOqBohmM5jRh9516rcjPQ==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo/1.7.0:
resolution: {integrity: sha512-cwympNwQNnQZ/TffBd8yT0i0O10Cf/hlxccCYgUcwhcGEb9rDjE5thDbHoHw1hlJQUF/5ua7ERJe7Zr0lNE/ww==}
/turbo/1.7.2:
resolution: {integrity: sha512-YR/x3GZEx0C1RV6Yvuw/HB1Ixx3upM6ZTTa4WqKz9WtLWN8u2g+u2h5KpG5YtjCS3wl/8zVXgHf2WiMK6KIghg==}
hasBin: true
requiresBuild: true
optionalDependencies:
turbo-darwin-64: 1.7.0
turbo-darwin-arm64: 1.7.0
turbo-linux-64: 1.7.0
turbo-linux-arm64: 1.7.0
turbo-windows-64: 1.7.0
turbo-windows-arm64: 1.7.0
turbo-darwin-64: 1.7.2
turbo-darwin-arm64: 1.7.2
turbo-linux-64: 1.7.2
turbo-linux-arm64: 1.7.2
turbo-windows-64: 1.7.2
turbo-windows-arm64: 1.7.2
dev: true
/tween-functions/1.2.0:
resolution: {integrity: sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==}
dev: false
/type-check/0.3.2:
resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
engines: {node: '>= 0.8.0'}
+3
View File
@@ -10,10 +10,13 @@
"MAIL_FROM",
"NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED",
"NEXT_PUBLIC_GITHUB_AUTH_ENABLED",
"NEXT_PUBLIC_IS_FORMBRICKS_CLOUD",
"NEXT_PUBLIC_PASSWORD_RESET_DISABLED",
"NEXT_PUBLIC_PRIVACY_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_SIGNUP_DISABLED",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
"NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"NEXT_PUBLIC_TERMS_URL",
"NEXTAUTH_SECRET",
"NEXTAUTH_URL",