mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 06:30:51 -06:00
Compare commits
32 Commits
@formbrick
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cc6232c2 | ||
|
|
7331d1dd5a | ||
|
|
3f8bf4c34c | ||
|
|
91ceffba01 | ||
|
|
8c38495812 | ||
|
|
c8c98499ed | ||
|
|
af181eabdc | ||
|
|
822c48ff52 | ||
|
|
70d211a038 | ||
|
|
a77ce55a1d | ||
|
|
a376eb9b51 | ||
|
|
f11c47d4ca | ||
|
|
4baea07471 | ||
|
|
ff87be717c | ||
|
|
e3e595af9a | ||
|
|
3dae10d665 | ||
|
|
6727ccf1cd | ||
|
|
9242ab3a7d | ||
|
|
e9d8de3574 | ||
|
|
0a252e5827 | ||
|
|
632f6068c4 | ||
|
|
4d280e04d1 | ||
|
|
73bde4fda6 | ||
|
|
9d4e21f8a7 | ||
|
|
3eeea7d1b2 | ||
|
|
32268a8ec3 | ||
|
|
888dbbcfd2 | ||
|
|
73711b4631 | ||
|
|
bd0b8ecd66 | ||
|
|
fefc27aadd | ||
|
|
8eb0cf3207 | ||
|
|
6a40ed705d |
5
.github/workflows/release-docker-github.yml
vendored
5
.github/workflows/release-docker-github.yml
vendored
@@ -16,10 +16,8 @@ env:
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,7 +36,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
|
||||
with:
|
||||
cosign-release: 'v2.1.1'
|
||||
cosign-release: "v2.1.1"
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
@@ -71,6 +69,7 @@ jobs:
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<div id="top"></div>
|
||||
|
||||
[<img src="ph.png">](https://www.producthunt.com/posts/formbricks)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://formbricks.com">
|
||||
<img width="120" alt="Open Source Experience Management Solution Qualtrics Alternative Logo" src="https://github.com/formbricks/formbricks/assets/72809645/0086704f-bee7-4d38-9cc8-fa42ee59e004">
|
||||
@@ -18,7 +15,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://formbricks.com/discord"><img src="https://img.shields.io/discord/979077669410979880?label=Discord&logo=discord&logoColor=%23fff" alt="Join Formbricks Discord"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
||||
<a href="https://www.producthunt.com/products/snoopforms"><img src="https://img.shields.io/badge/Product%20Hunt-%232%20Product%20of%20the%20Day-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
||||
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
||||
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
|
||||
<a href="https://github.com/formbricks/formbricks/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||
</p>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import formbricks from "@formbricks/js";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent } from "react";
|
||||
|
||||
export default function SiginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const submitAction = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
||||
formbricks.setEmail("matti@example.com");
|
||||
formbricks.setUserId("123456");
|
||||
formbricks.setAttribute("Plan", "Premium");
|
||||
}
|
||||
router.push("/app");
|
||||
};
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Or{" "}
|
||||
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
start your 14-day free trial
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
|
||||
<form className="space-y-6" onSubmit={submitAction}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium leading-6 text-gray-900">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium leading-6 text-gray-900">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-md bg-indigo-500 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
|
||||
<span className="sr-only">Sign in with Facebook</span>
|
||||
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M20 10c0-5.523-4.477-10-10-10S0 4.477 0 10c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V10h2.54V7.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V10h2.773l-.443 2.89h-2.33v6.988C16.343 19.128 20 14.991 20 10z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
|
||||
<span className="sr-only">Sign in with Twitter</span>
|
||||
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
|
||||
<span className="sr-only">Sign in with GitHub</span>
|
||||
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import formbricks from "@formbricks/js";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import fbsetup from "../../public/fb-setup.png";
|
||||
|
||||
export default function AppPage({}) {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.body.classList.add("dark");
|
||||
} else {
|
||||
document.body.classList.remove("dark");
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
Formbricks In-product Survey Demo App
|
||||
</h1>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
This app helps you test your in-app surveys. You can create and test user actions, create and
|
||||
update user attributes, etc.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
|
||||
onClick={() => setDarkMode(!darkMode)}>
|
||||
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Copy the environment ID of your Formbricks app to the env variable in demo/.env
|
||||
</p>
|
||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
|
||||
|
||||
<div className="mt-4 flex-col items-start text-sm text-slate-700 dark:text-slate-300 sm:flex sm:items-center sm:text-base">
|
||||
<p className="mb-1 sm:mb-0 sm:mr-2">You're connected with env:</p>
|
||||
<div className="flex items-center">
|
||||
<strong className="w-32 truncate sm:w-auto">
|
||||
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
|
||||
</strong>
|
||||
<span className="relative ml-2 flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Look at the logs to understand how the widget works.{" "}
|
||||
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
|
||||
</p>
|
||||
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
|
||||
<LogsContainer />
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-gray-600 dark:bg-gray-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
<p className="text-slate-700 dark:text-gray-300">
|
||||
On formbricks.reset() a few things happen: <strong>New person is created</strong> and{" "}
|
||||
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
|
||||
</p>
|
||||
<button
|
||||
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
formbricks.reset();
|
||||
}}>
|
||||
Reset
|
||||
</button>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">
|
||||
If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and
|
||||
try again.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
console.log("Inner Text");
|
||||
}}>
|
||||
Inner Text
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text only</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
id="css-id"
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
console.log("Inner Text + CSS ID");
|
||||
}}>
|
||||
Inner Text
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + Css ID</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
console.log("Inner Text + CSS Class");
|
||||
}}>
|
||||
Inner Text
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + CSS Class</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
id="css-id"
|
||||
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
console.log("ID + Class");
|
||||
}}>
|
||||
ID and Class
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">ID + Class</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
id="css-id"
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
console.log("ID + Class");
|
||||
}}>
|
||||
ID only
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">ID only</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
console.log("Class only");
|
||||
}}>
|
||||
Class only
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">Class only</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
className="css-1 css-2 mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => {
|
||||
console.log("Class + Class");
|
||||
}}>
|
||||
Class + Class
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-gray-300">Class + Class</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,26 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Example on overriding packages/js colors */
|
||||
.dark {
|
||||
--fb-brand-color: red;
|
||||
--fb-brand-text-color: white;
|
||||
--fb-border-color: green;
|
||||
--fb-border-color-highlight: var(--slate-500);
|
||||
--fb-focus-color: red;
|
||||
--fb-heading-color: yellow;
|
||||
--fb-subheading-color: green;
|
||||
--fb-info-text-color: orange;
|
||||
--fb-signature-text-color: blue;
|
||||
--fb-survey-background-color: black;
|
||||
--fb-accent-background-color: rgb(13, 13, 12);
|
||||
--fb-accent-background-color-selected: red;
|
||||
--fb-placeholder-color: white;
|
||||
--fb-shadow-color: yellow;
|
||||
--fb-rating-fill: var(--yellow-300);
|
||||
--fb-rating-hover: var(--yellow-500);
|
||||
--fb-back-btn-border: currentColor;
|
||||
--fb-submit-btn-border: transparent;
|
||||
--fb-rating-selected: black;
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ This set of API can be used to
|
||||
{
|
||||
"id": "lkjaxb73ulydzeumhd51sx9g",
|
||||
"type": "openText",
|
||||
"headline": "What is the main benefit your receive from My Product?",
|
||||
"headline": "What is the main benefit you receive from My Product?",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ To enable the User identification feature you need to set the `userId` in the in
|
||||
formbricks.init({
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userID: "<user_id>",
|
||||
userId: "<user_id>",
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "What is the main benefit your receive from Formbricks?",
|
||||
headline: "What is the main benefit you receive from Formbricks?",
|
||||
inputType: "text",
|
||||
longAnswer: true,
|
||||
required: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function MetaInformation({
|
||||
<meta name="image" content={`https://${BASE_URL}/favicon.ico`} />
|
||||
<meta property="og:image" content={`https://${BASE_URL}/social-image.png`} />
|
||||
<link rel="icon" type="image/x-icon" href={`https://${BASE_URL}/favicon.ico`} />
|
||||
<link rel="canonical" href="https://formbricks.com/" />
|
||||
<meta name="msapplication-TileColor" content="#00C4B8" />
|
||||
<meta name="msapplication-TileImage" content={`https://${BASE_URL}/favicon.ico`} />
|
||||
<meta property="og:image:alt" content="Open Source Experience Management, Privacy-first" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { formbricksEnabled } from "@/app/lib/formbricks";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import type { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -9,7 +9,11 @@ interface WidgetStatusIndicatorProps {
|
||||
}
|
||||
|
||||
export default async function WidgetStatusIndicator({ environmentId, type }: WidgetStatusIndicatorProps) {
|
||||
const [environment] = await Promise.all([getEnvironment(environmentId)]);
|
||||
const environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const stati = {
|
||||
notImplemented: {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
import { Alert, AlertDescription } from "@formbricks/ui/Alert";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
interface EditFormbricksBrandingProps {
|
||||
type: "linkSurvey" | "inAppSurvey";
|
||||
product: TProduct;
|
||||
canRemoveBranding: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function EditFormbricksBranding({
|
||||
type,
|
||||
product,
|
||||
canRemoveBranding,
|
||||
environmentId,
|
||||
}: EditFormbricksBrandingProps) {
|
||||
const [isBrandingEnabled, setIsBrandingEnabled] = useState(
|
||||
type === "linkSurvey" ? product.linkSurveyBranding : product.inAppSurveyBranding
|
||||
);
|
||||
const [updatingBranding, setUpdatingBranding] = useState(false);
|
||||
|
||||
const getTextFromType = (type) => {
|
||||
if (type === "linkSurvey") return "Link Surveys";
|
||||
if (type === "inAppSurvey") return "In App Surveys";
|
||||
};
|
||||
|
||||
const toggleBranding = async () => {
|
||||
try {
|
||||
setUpdatingBranding(true);
|
||||
const newBrandingState = !isBrandingEnabled;
|
||||
setIsBrandingEnabled(newBrandingState);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
[type === "linkSurvey" ? "linkSurveyBranding" : "inAppSurveyBranding"]: newBrandingState,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success(
|
||||
newBrandingState ? "Formbricks branding will be shown." : "Formbricks branding will now be hidden."
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingBranding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
{!canRemoveBranding && (
|
||||
<div className="mb-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
To remove the Formbricks branding from the
|
||||
<span className="font-semibold">{getTextFromType(type)}</span>, please
|
||||
{type === "linkSurvey" ? (
|
||||
<span className="underline">
|
||||
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade your plan.</Link>
|
||||
</span>
|
||||
) : (
|
||||
<span className="underline">
|
||||
<Link href={`/environments/${environmentId}/settings/billing`}>add your creditcard.</Link>
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6 flex items-center space-x-2">
|
||||
<Switch
|
||||
id={`branding-${type}`}
|
||||
checked={isBrandingEnabled}
|
||||
onCheckedChange={toggleBranding}
|
||||
disabled={!canRemoveBranding || updatingBranding}
|
||||
/>
|
||||
<Label htmlFor={`branding-${type}`}>
|
||||
Show Formbricks Branding in {type === "linkSurvey" ? "Link" : "In-App"} Surveys
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@formbricks/ui/Alert";
|
||||
import { updateProductAction } from "../actions";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
|
||||
interface EditSignatureProps {
|
||||
product: TProduct;
|
||||
canRemoveSignature: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function EditFormbricksSignature({ product, canRemoveSignature, environmentId }: EditSignatureProps) {
|
||||
const [formbricksSignature, setFormbricksSignature] = useState(product.formbricksSignature);
|
||||
const [updatingSignature, setUpdatingSignature] = useState(false);
|
||||
|
||||
const toggleSignature = async () => {
|
||||
try {
|
||||
setUpdatingSignature(true);
|
||||
const newSignatureState = !formbricksSignature;
|
||||
setFormbricksSignature(newSignatureState);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
formbricksSignature: newSignatureState,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success(
|
||||
newSignatureState ? "Formbricks signature will be shown." : "Formbricks signature will now be hidden."
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingSignature(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
{!canRemoveSignature && (
|
||||
<div className="mb-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
To remove the Formbricks branding from the link surveys, please{" "}
|
||||
<span className="underline">
|
||||
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade</Link>
|
||||
</span>{" "}
|
||||
your plan.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="signature"
|
||||
checked={formbricksSignature}
|
||||
onCheckedChange={toggleSignature}
|
||||
disabled={!canRemoveSignature || updatingSignature}
|
||||
/>
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature in Link Surveys</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import SettingsCard from "../components/SettingsCard";
|
||||
import SettingsTitle from "../components/SettingsTitle";
|
||||
import { EditFormbricksSignature } from "./components/EditSignature";
|
||||
import { EditBrandColor } from "./components/EditBrandColor";
|
||||
import { EditPlacement } from "./components/EditPlacement";
|
||||
import { EditHighlightBorder } from "./components/EditHighlightBorder";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { DEFAULT_BRAND_COLOR, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { getServerSession } from "next-auth";
|
||||
import SettingsCard from "../components/SettingsCard";
|
||||
import SettingsTitle from "../components/SettingsTitle";
|
||||
import { EditBrandColor } from "./components/EditBrandColor";
|
||||
import { EditHighlightBorder } from "./components/EditHighlightBorder";
|
||||
import { EditPlacement } from "./components/EditPlacement";
|
||||
import { EditFormbricksBranding } from "./components/EditBranding";
|
||||
|
||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
const [session, team, product] = await Promise.all([
|
||||
@@ -33,8 +32,10 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const canRemoveLinkBranding = team.billing.features.linkSurvey.status !== "inactive";
|
||||
const canRemoveInAppBranding = team.billing.features.inAppSurvey.status !== "inactive";
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const canRemoveSignature = team.billing.features.linkSurvey.status !== "inactive";
|
||||
const { isDeveloper, isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const isBrandColorEditDisabled = isDeveloper ? true : isViewer;
|
||||
|
||||
@@ -68,11 +69,18 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
title="Formbricks Branding"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<EditFormbricksSignature
|
||||
<EditFormbricksBranding
|
||||
type="linkSurvey"
|
||||
product={product}
|
||||
canRemoveSignature={canRemoveSignature}
|
||||
canRemoveBranding={canRemoveLinkBranding}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
<EditFormbricksBranding
|
||||
type="inAppSurvey"
|
||||
product={product}
|
||||
canRemoveBranding={canRemoveInAppBranding}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
|
||||
@@ -32,3 +33,13 @@ export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArg
|
||||
}
|
||||
return await sendEmbedSurveyPreviewEmail(to, subject, html);
|
||||
};
|
||||
|
||||
export const getEmailHtmlAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getEmailTemplateHtml(surveyId);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import LinkTab from "./shareEmbedTabs/LinkTab";
|
||||
import EmailTab from "./shareEmbedTabs/EmailTab";
|
||||
import WebpageTab from "./shareEmbedTabs/WebpageTab";
|
||||
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { DialogContent, Dialog } from "@formbricks/ui/Dialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { LinkIcon, EnvelopeIcon, CodeBracketIcon } from "@heroicons/react/24/outline";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TProfile } from "@formbricks/types/profile";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
|
||||
import { CodeBracketIcon, EnvelopeIcon, LinkIcon } from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import EmailTab from "./shareEmbedTabs/EmailTab";
|
||||
import LinkTab from "./shareEmbedTabs/LinkTab";
|
||||
import WebpageTab from "./shareEmbedTabs/WebpageTab";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
survey: TSurvey;
|
||||
@@ -43,16 +43,6 @@ export default function ShareEmbedSurvey({
|
||||
|
||||
const [activeId, setActiveId] = useState(tabs[0].id);
|
||||
|
||||
const componentMap = {
|
||||
link: isSingleUseLinkSurvey ? (
|
||||
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
|
||||
) : (
|
||||
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
|
||||
),
|
||||
email: <EmailTab survey={survey} surveyUrl={surveyUrl} email={email} brandColor={surveyBrandColor} />,
|
||||
webpage: <WebpageTab surveyUrl={surveyUrl} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -87,7 +77,15 @@ export default function ShareEmbedSurvey({
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col gap-6 bg-gray-50 px-4 py-6 lg:p-6">
|
||||
<div className="flex h-full overflow-y-scroll lg:h-[590px] lg:overflow-y-visible">
|
||||
{componentMap[activeId]}
|
||||
{isSingleUseLinkSurvey ? (
|
||||
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
|
||||
) : activeId === "link" ? (
|
||||
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
|
||||
) : activeId === "email" ? (
|
||||
<EmailTab surveyId={survey.id} email={email} />
|
||||
) : activeId === "webpage" ? (
|
||||
<WebpageTab surveyUrl={surveyUrl} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-max rounded-md bg-slate-100 p-1 lg:hidden">
|
||||
{tabs.slice(0, 2).map((tab) => (
|
||||
|
||||
@@ -1,59 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { sendEmailAction } from "../../actions";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import CodeBlock from "@formbricks/ui/CodeBlock";
|
||||
import { CodeBracketIcon, DocumentDuplicateIcon, EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
Column,
|
||||
Container,
|
||||
Button as EmailButton,
|
||||
Link,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
render,
|
||||
Img,
|
||||
} from "@react-email/components";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { isLight } from "@/app/lib/utils";
|
||||
import { getEmailHtmlAction, sendEmailAction } from "../../actions";
|
||||
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
|
||||
|
||||
interface EmailTabProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
surveyId: string;
|
||||
email: string;
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
export default function EmailTab({ survey, surveyUrl, email, brandColor }: EmailTabProps) {
|
||||
export default function EmailTab({ surveyId, email }: EmailTabProps) {
|
||||
const [showEmbed, setShowEmbed] = useState(false);
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
|
||||
const emailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return emailHtmlPreview
|
||||
.replaceAll("?preview=true&", "?")
|
||||
.replaceAll("?preview=true&;", "?")
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
getData();
|
||||
|
||||
async function getData() {
|
||||
const emailHtml = await getEmailHtmlAction(surveyId);
|
||||
setEmailHtmlPreview(emailHtml);
|
||||
}
|
||||
});
|
||||
|
||||
const subject = "Formbricks Email Survey Preview";
|
||||
|
||||
const emailValues = useMemo(() => {
|
||||
return getEmailValues({ brandColor, survey, surveyUrl, preview: false });
|
||||
}, []);
|
||||
|
||||
const previewEmailValues = useMemo(() => {
|
||||
return getEmailValues({ brandColor, survey, surveyUrl, preview: true });
|
||||
}, []);
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
const sendPreviewEmail = async (html) => {
|
||||
try {
|
||||
await sendEmailAction({ html: previewEmailValues.html, subject, to: email });
|
||||
await sendEmailAction({
|
||||
html,
|
||||
subject,
|
||||
to: email,
|
||||
});
|
||||
toast.success("Email sent!");
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
toast.error("You are not authenticated to perform this action.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong. Please try again later.");
|
||||
}
|
||||
};
|
||||
@@ -68,7 +64,7 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
|
||||
aria-label="Embed survey in your website"
|
||||
onClick={() => {
|
||||
toast.success("Embed code copied to clipboard!");
|
||||
navigator.clipboard.writeText(emailValues.html);
|
||||
navigator.clipboard.writeText(emailHtml);
|
||||
}}
|
||||
className="shrink-0"
|
||||
EndIcon={DocumentDuplicateIcon}>
|
||||
@@ -76,12 +72,11 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Input type="email" placeholder="user@mail.com" className="h-11 grow bg-white" value={email} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="send preview email"
|
||||
aria-label="send preview email"
|
||||
onClick={sendPreviewEmail}
|
||||
onClick={() => sendPreviewEmail(emailHtmlPreview)}
|
||||
EndIcon={EnvelopeIcon}
|
||||
className="shrink-0">
|
||||
Send Preview
|
||||
@@ -92,7 +87,9 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
|
||||
variant="darkCTA"
|
||||
title="view embed code for email"
|
||||
aria-label="view embed code for email"
|
||||
onClick={() => setShowEmbed(!showEmbed)}
|
||||
onClick={() => {
|
||||
setShowEmbed(!showEmbed);
|
||||
}}
|
||||
EndIcon={CodeBracketIcon}
|
||||
className="shrink-0">
|
||||
{showEmbed ? "Hide Embed Code" : "View Embed Code"}
|
||||
@@ -104,10 +101,10 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
|
||||
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
|
||||
language="html"
|
||||
showCopyToClipboard={false}>
|
||||
{emailValues.html}
|
||||
{emailHtml}
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<div className="">
|
||||
<div>
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
@@ -118,7 +115,13 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
|
||||
To : {email || "user@mail.com"}
|
||||
</div>
|
||||
<div className="border-b border-slate-200 pb-2 text-sm">Subject : {subject}</div>
|
||||
<div className="p-4">{previewEmailValues.Component}</div>
|
||||
<div className="p-4">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -126,308 +129,3 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => {
|
||||
const doctype =
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||
|
||||
const Template = getEmailTemplate(survey, surveyUrl, brandColor, preview);
|
||||
const html = render(Template, { pretty: true });
|
||||
const htmlWithoutDoctype = html.replace(doctype, "");
|
||||
|
||||
return { Component: Template, html: htmlWithoutDoctype };
|
||||
};
|
||||
|
||||
const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => {
|
||||
const url = preview ? `${surveyUrl}?preview=true` : surveyUrl;
|
||||
const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`;
|
||||
|
||||
const firstQuestion = survey.questions[0];
|
||||
switch (firstQuestion.type) {
|
||||
case TSurveyQuestionType.OpenText:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
|
||||
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
|
||||
</Container>
|
||||
<Container className="mx-0 mt-4 flex max-w-none justify-end">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
Reject
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
|
||||
className={cn(
|
||||
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
Accept
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.NPS:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex w-max flex-col">
|
||||
<Section className="block overflow-hidden rounded-md border border-gray-200">
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
|
||||
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
|
||||
{i}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
{/* {!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className={cn(
|
||||
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
{firstQuestion.buttonLabel || "Skip"}
|
||||
</EmailButton>
|
||||
)} */}
|
||||
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
{firstQuestion.dismissButtonLabel || "Skip"}
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
|
||||
className={cn(
|
||||
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
{firstQuestion.buttonLabel}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex">
|
||||
<Section
|
||||
className={cn("inline-block w-max overflow-hidden rounded-md", {
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
})}>
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
className={cn(
|
||||
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
|
||||
{
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
}
|
||||
)}>
|
||||
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
|
||||
{firstQuestion.scale === "number" && i + 1}
|
||||
{firstQuestion.scale === "star" && <Text className="text-3xl">⭐</Text>}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
{/* {!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className={cn(
|
||||
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
{firstQuestion.buttonLabel || "Skip"}
|
||||
</EmailButton>
|
||||
)} */}
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.MultipleChoiceMulti:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices.map((choice) => (
|
||||
<Section
|
||||
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
|
||||
key={choice.id}>
|
||||
{choice.label}
|
||||
</Section>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices
|
||||
.filter((choice) => choice.id !== "other")
|
||||
.map((choice) => (
|
||||
<Link
|
||||
key={choice.id}
|
||||
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
|
||||
{choice.label}
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mx-0">
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
src={choice.imageUrl}
|
||||
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
target="_blank"
|
||||
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
|
||||
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"brand-color": brandColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}>
|
||||
<Link
|
||||
href={surveyUrl}
|
||||
target="_blank"
|
||||
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
|
||||
{children}
|
||||
</Link>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailFooter = () => {
|
||||
return (
|
||||
<Container className="m-auto mt-8 text-center ">
|
||||
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
|
||||
Powered by Formbricks
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function LinkTab({ surveyUrl, survey, brandColor }: EmailTabProps
|
||||
<SurveyInline
|
||||
brandColor={brandColor}
|
||||
survey={survey}
|
||||
formbricksSignature={false}
|
||||
isBrandingEnabled={false}
|
||||
autoFocus={false}
|
||||
isRedirectDisabled={false}
|
||||
key={survey.id}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
|
||||
import { isLight } from "@/app/lib/utils";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import {
|
||||
Column,
|
||||
Container,
|
||||
Button as EmailButton,
|
||||
Img,
|
||||
Link,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
export const getEmailTemplateHtml = async (surveyId) => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
const product = await getProductByEnvironmentId(survey.environmentId);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
const brandColor = product.brandColor;
|
||||
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
|
||||
const html = render(<EmailTemplate survey={survey} surveyUrl={surveyUrl} brandColor={brandColor} />, {
|
||||
pretty: true,
|
||||
});
|
||||
const doctype =
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||
const htmlCleaned = html.toString().replace(doctype, "");
|
||||
|
||||
return htmlCleaned;
|
||||
};
|
||||
|
||||
const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => {
|
||||
const url = `${surveyUrl}?preview=true`;
|
||||
const urlWithPrefilling = `${surveyUrl}?preview=true&`;
|
||||
|
||||
const firstQuestion = survey.questions[0];
|
||||
switch (firstQuestion.type) {
|
||||
case TSurveyQuestionType.OpenText:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
|
||||
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
|
||||
</Container>
|
||||
<Container className="mx-0 mt-4 flex max-w-none justify-end">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
Reject
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
|
||||
className={cn(
|
||||
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
Accept
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.NPS:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex w-max flex-col">
|
||||
<Section className="block overflow-hidden rounded-md border border-gray-200">
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
|
||||
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
|
||||
{i}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
{firstQuestion.dismissButtonLabel || "Skip"}
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
|
||||
className={cn(
|
||||
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
{firstQuestion.buttonLabel}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex">
|
||||
<Section
|
||||
className={cn("inline-block w-max overflow-hidden rounded-md", {
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
})}>
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
className={cn(
|
||||
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
|
||||
{
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
}
|
||||
)}>
|
||||
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
|
||||
{firstQuestion.scale === "number" && i + 1}
|
||||
{firstQuestion.scale === "star" && <Text className="text-3xl">⭐</Text>}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.MultipleChoiceMulti:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices.map((choice) => (
|
||||
<Section
|
||||
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
|
||||
key={choice.id}>
|
||||
{choice.label}
|
||||
</Section>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.MultipleChoiceSingle:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices
|
||||
.filter((choice) => choice.id !== "other")
|
||||
.map((choice) => (
|
||||
<Link
|
||||
key={choice.id}
|
||||
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
|
||||
{choice.label}
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mx-0">
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
src={choice.imageUrl}
|
||||
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
target="_blank"
|
||||
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
|
||||
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"brand-color": brandColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}>
|
||||
<Link
|
||||
href={surveyUrl}
|
||||
target="_blank"
|
||||
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
|
||||
{children}
|
||||
</Link>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailFooter = () => {
|
||||
return (
|
||||
<Container className="m-auto mt-8 text-center ">
|
||||
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
|
||||
Powered by Formbricks
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { getPlacementStyle } from "@/app/lib/preview";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
@@ -18,8 +18,8 @@ type TPlacementProps = {
|
||||
setCurrentPlacement: (placement: TPlacement) => void;
|
||||
setOverlay: (overlay: string) => void;
|
||||
overlay: string;
|
||||
setClickOutside: (clickOutside: boolean) => void;
|
||||
clickOutside: boolean;
|
||||
setClickOutsideClose: (clickOutside: boolean) => void;
|
||||
clickOutsideClose: boolean;
|
||||
};
|
||||
|
||||
export default function Placement({
|
||||
@@ -27,8 +27,8 @@ export default function Placement({
|
||||
currentPlacement,
|
||||
setOverlay,
|
||||
overlay,
|
||||
setClickOutside,
|
||||
clickOutside,
|
||||
setClickOutsideClose,
|
||||
clickOutsideClose,
|
||||
}: TPlacementProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -78,8 +78,8 @@ export default function Placement({
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => setClickOutside(value === "allow")}
|
||||
value={clickOutside ? "allow" : "disallow"}
|
||||
onValueChange={(value) => setClickOutsideClose(value === "allow")}
|
||||
value={clickOutsideClose ? "allow" : "disallow"}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
|
||||
@@ -19,7 +19,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { type, productOverwrites } = localSurvey;
|
||||
const { brandColor, clickOutside, darkOverlay, placement, highlightBorderColor } = productOverwrites ?? {};
|
||||
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
|
||||
productOverwrites ?? {};
|
||||
|
||||
const togglePlacement = () => {
|
||||
setLocalSurvey({
|
||||
@@ -93,12 +94,12 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickOutside = (clickOutside: boolean) => {
|
||||
const handleClickOutsideClose = (clickOutsideClose: boolean) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
productOverwrites: {
|
||||
...localSurvey.productOverwrites,
|
||||
clickOutside,
|
||||
clickOutsideClose,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -163,8 +164,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
|
||||
setCurrentPlacement={handlePlacementChange}
|
||||
setOverlay={handleOverlay}
|
||||
overlay={darkOverlay ? "dark" : "light"}
|
||||
setClickOutside={handleClickOutside}
|
||||
clickOutside={!!clickOutside}
|
||||
setClickOutsideClose={handleClickOutsideClose}
|
||||
clickOutsideClose={!!clickOutsideClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import Loading from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/loading";
|
||||
|
||||
interface SurveyEditorProps {
|
||||
survey: TSurvey;
|
||||
@@ -41,6 +41,7 @@ export default function SurveyEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (survey) {
|
||||
if (localSurvey) return;
|
||||
setLocalSurvey(JSON.parse(JSON.stringify(survey)));
|
||||
|
||||
if (survey.questions.length > 0) {
|
||||
@@ -59,7 +60,7 @@ export default function SurveyEditor({
|
||||
}, [localSurvey?.type]);
|
||||
|
||||
if (!localSurvey) {
|
||||
return <ErrorComponent />;
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function WhenToSendCard({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddEventModalOpen) return;
|
||||
if (activeIndex !== null) {
|
||||
const newActionClass = actionClassArray[actionClassArray.length - 1].name;
|
||||
const currentActionClass = localSurvey.triggers[activeIndex];
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function PreviewSurvey({
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
formbricksSignature={product.formbricksSignature}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
isRedirectDisabled={true}
|
||||
/>
|
||||
@@ -219,7 +219,7 @@ export default function PreviewSurvey({
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
formbricksSignature={product.formbricksSignature}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
/>
|
||||
</div>
|
||||
@@ -274,7 +274,7 @@ export default function PreviewSurvey({
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
formbricksSignature={product.formbricksSignature}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
isRedirectDisabled={true}
|
||||
/>
|
||||
@@ -287,7 +287,7 @@ export default function PreviewSurvey({
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
formbricksSignature={product.formbricksSignature}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
isRedirectDisabled={true}
|
||||
/>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient";
|
||||
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
|
||||
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
|
||||
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SplitIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createSurveyAction } from "../actions";
|
||||
import { customSurvey, templates } from "./templates";
|
||||
import { customSurvey, templates, testTemplate } from "./templates";
|
||||
|
||||
type TemplateList = {
|
||||
environmentId: string;
|
||||
@@ -147,7 +147,10 @@ export default function TemplateList({
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{filteredTemplates.map((template: TTemplate) => (
|
||||
{(process.env.NODE_ENV === "development"
|
||||
? [...filteredTemplates, testTemplate]
|
||||
: filteredTemplates
|
||||
).map((template: TTemplate) => (
|
||||
<div
|
||||
onClick={() => {
|
||||
const newTemplate = replacePresetPlaceholders(template, product);
|
||||
|
||||
@@ -21,6 +21,308 @@ const welcomeCardDefault: TSurveyWelcomeCard = {
|
||||
timeToFinish: true,
|
||||
};
|
||||
|
||||
export const testTemplate: TTemplate = {
|
||||
name: "Test template",
|
||||
description: "Test template consisting of all questions",
|
||||
preset: {
|
||||
name: "Test template",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter some text:",
|
||||
required: true,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter some text:",
|
||||
required: false,
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter an email",
|
||||
required: true,
|
||||
inputType: "email",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter an email",
|
||||
required: false,
|
||||
inputType: "email",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter a number",
|
||||
required: true,
|
||||
inputType: "number",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter a number",
|
||||
required: false,
|
||||
inputType: "number",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter a phone number",
|
||||
required: true,
|
||||
inputType: "phone",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter a phone number",
|
||||
required: false,
|
||||
inputType: "phone",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter a url",
|
||||
required: true,
|
||||
inputType: "url",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "This is an open text question",
|
||||
subheader: "Please enter a url",
|
||||
required: false,
|
||||
inputType: "url",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.MultipleChoiceSingle,
|
||||
headline: "This ia a Multiple choice Single question",
|
||||
subheader: "Please select one of the following",
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option1",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.MultipleChoiceSingle,
|
||||
headline: "This ia a Multiple choice Single question",
|
||||
subheader: "Please select one of the following",
|
||||
required: false,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option 1",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.MultipleChoiceMulti,
|
||||
headline: "This ia a Multiple choice Multiple question",
|
||||
subheader: "Please select some from the following",
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option1",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.MultipleChoiceMulti,
|
||||
headline: "This ia a Multiple choice Multiple question",
|
||||
subheader: "Please select some from the following",
|
||||
required: false,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option1",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: "Option2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Rating,
|
||||
headline: "This is a rating question",
|
||||
required: true,
|
||||
lowerLabel: "Low",
|
||||
upperLabel: "High",
|
||||
range: 5,
|
||||
scale: "number",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Rating,
|
||||
headline: "This is a rating question",
|
||||
required: false,
|
||||
lowerLabel: "Low",
|
||||
upperLabel: "High",
|
||||
range: 5,
|
||||
scale: "number",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Rating,
|
||||
headline: "This is a rating question",
|
||||
required: true,
|
||||
lowerLabel: "Low",
|
||||
upperLabel: "High",
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Rating,
|
||||
headline: "This is a rating question",
|
||||
required: false,
|
||||
lowerLabel: "Low",
|
||||
upperLabel: "High",
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Rating,
|
||||
headline: "This is a rating question",
|
||||
required: true,
|
||||
lowerLabel: "Low",
|
||||
upperLabel: "High",
|
||||
range: 5,
|
||||
scale: "star",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Rating,
|
||||
headline: "This is a rating question",
|
||||
required: false,
|
||||
lowerLabel: "Low",
|
||||
upperLabel: "High",
|
||||
range: 5,
|
||||
scale: "star",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.CTA,
|
||||
headline: "This is a CTA question",
|
||||
html: "This is a test CTA",
|
||||
buttonLabel: "Click",
|
||||
buttonUrl: "https://formbricks.com",
|
||||
buttonExternal: true,
|
||||
required: true,
|
||||
dismissButtonLabel: "Maybe later",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.CTA,
|
||||
headline: "This is a CTA question",
|
||||
html: "This is a test CTA",
|
||||
buttonLabel: "Click",
|
||||
buttonUrl: "https://formbricks.com",
|
||||
buttonExternal: true,
|
||||
required: false,
|
||||
dismissButtonLabel: "Maybe later",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.PictureSelection,
|
||||
headline: "This is a Picture select",
|
||||
allowMulti: true,
|
||||
required: true,
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.PictureSelection,
|
||||
headline: "This is a Picture select",
|
||||
allowMulti: true,
|
||||
required: false,
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Consent,
|
||||
headline: "This is a Consent question",
|
||||
required: true,
|
||||
label: "I agree to the terms and conditions",
|
||||
dismissButtonLabel: "Skip",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Consent,
|
||||
headline: "This is a Consent question",
|
||||
required: false,
|
||||
label: "I agree to the terms and conditions",
|
||||
dismissButtonLabel: "Skip",
|
||||
},
|
||||
],
|
||||
thankYouCard: thankYouCardDefault,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: false,
|
||||
},
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const templates: TTemplate[] = [
|
||||
{
|
||||
name: "Product Market Fit (Superhuman)",
|
||||
@@ -104,7 +406,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
headline: "What is the main benefit your receive from {{productName}}?",
|
||||
headline: "What is the main benefit you receive from {{productName}}?",
|
||||
required: true,
|
||||
inputType: "text",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { formbricksEnabled, updateResponse } from "@/app/lib/formbricks";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProfileObjective } from "@formbricks/types/profile";
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Label } from "@formbricks/ui/Label";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { isLight } from "@/app/lib/utils";
|
||||
|
||||
type Product = {
|
||||
done: () => void;
|
||||
@@ -73,6 +74,10 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
|
||||
if (!product) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
const buttonStyle = {
|
||||
backgroundColor: color,
|
||||
color: isLight(color) ? "black" : "white",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
|
||||
@@ -140,7 +145,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full justify-end">
|
||||
<Button className="pointer-events-none" style={{ backgroundColor: color }}>
|
||||
<Button className="pointer-events-none" style={buttonStyle}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { createResponse, formbricksEnabled } from "@/app/lib/formbricks";
|
||||
import { env } from "@/env.mjs";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UsedContent,
|
||||
RightAccountContent,
|
||||
} from "./components/InviteContentComponents";
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
|
||||
export default async function JoinTeam({ searchParams }) {
|
||||
const currentUser = await getServerSession(authOptions);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
|
||||
import { personCache } from "@formbricks/lib/person/cache";
|
||||
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { getSyncSurveys } from "@formbricks/lib/survey/service";
|
||||
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface Context {
|
||||
params: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(req: Request, context: Context): Promise<NextResponse> {
|
||||
try {
|
||||
const { userId, environmentId } = context.params;
|
||||
const personId = userId; // legacy workaround for formbricks-js 1.2.0 & 1.2.1
|
||||
const jsonInput = await req.json();
|
||||
|
||||
// validate using zod
|
||||
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { key, value } = inputValidation.data;
|
||||
|
||||
const person = await getPerson(personId);
|
||||
|
||||
if (!person) {
|
||||
return responses.notFoundResponse("Person", personId, true);
|
||||
}
|
||||
|
||||
let attributeClass = await getAttributeClassByName(environmentId, key);
|
||||
|
||||
// create new attribute class if not found
|
||||
if (attributeClass === null) {
|
||||
attributeClass = await createAttributeClass(environmentId, key, "code");
|
||||
}
|
||||
|
||||
if (!attributeClass) {
|
||||
return responses.internalServerErrorResponse("Unable to create attribute class", true);
|
||||
}
|
||||
|
||||
// upsert attribute (update or create)
|
||||
await updatePersonAttribute(personId, attributeClass.id, value);
|
||||
|
||||
personCache.revalidate({
|
||||
id: personId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
surveyCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
getSyncSurveys(environmentId, person),
|
||||
getActionClasses(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
// return state
|
||||
const state: TJsState = {
|
||||
person,
|
||||
surveys,
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
product,
|
||||
};
|
||||
|
||||
return responses.successResponse({ ...state }, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { markDisplayResponded } from "@formbricks/lib/display/service";
|
||||
import { markDisplayRespondedLegacy } from "@formbricks/lib/display/service";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
@@ -14,7 +14,7 @@ export async function POST(_: Request, { params }: { params: { displayId: string
|
||||
}
|
||||
|
||||
try {
|
||||
const display = await markDisplayResponded(displayId);
|
||||
const display = await markDisplayRespondedLegacy(displayId);
|
||||
return responses.successResponse(
|
||||
{
|
||||
...display,
|
||||
@@ -1,8 +1,8 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { updateDisplay } from "@formbricks/lib/display/service";
|
||||
import { TDisplayCreateInput, ZDisplayUpdateInput } from "@formbricks/types/displays";
|
||||
import { NextResponse } from "next/server";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { updateDisplayLegacy } from "@formbricks/lib/display/service";
|
||||
import { ZDisplayLegacyUpdateInput } from "@formbricks/types/displays";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
@@ -16,8 +16,8 @@ export async function PUT(
|
||||
if (!displayId) {
|
||||
return responses.badRequestResponse("Missing displayId", undefined, true);
|
||||
}
|
||||
const displayInput: TDisplayCreateInput = await request.json();
|
||||
const inputValidation = ZDisplayUpdateInput.safeParse(displayInput);
|
||||
const displayInput = await request.json();
|
||||
const inputValidation = ZDisplayLegacyUpdateInput.safeParse(displayInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
@@ -27,7 +27,7 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
try {
|
||||
const display = await updateDisplay(displayId, inputValidation.data);
|
||||
const display = await updateDisplayLegacy(displayId, inputValidation.data);
|
||||
return responses.successResponse(display, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -1,11 +1,13 @@
|
||||
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
|
||||
import { personCache } from "@formbricks/lib/person/cache";
|
||||
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
|
||||
import { getSyncSurveys } from "@formbricks/lib/survey/service";
|
||||
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface Context {
|
||||
@@ -37,9 +39,9 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
|
||||
|
||||
const { key, value } = inputValidation.data;
|
||||
|
||||
const existingPerson = await getPerson(personId);
|
||||
const person = await getPerson(personId);
|
||||
|
||||
if (!existingPerson) {
|
||||
if (!person) {
|
||||
return responses.notFoundResponse("Person", personId, true);
|
||||
}
|
||||
|
||||
@@ -66,7 +68,23 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const state = await getUpdatedState(environmentId, personId);
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
getSyncSurveys(environmentId, person),
|
||||
getActionClasses(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
// return state
|
||||
const state: TJsState = {
|
||||
person,
|
||||
surveys,
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
product,
|
||||
};
|
||||
|
||||
return responses.successResponse({ ...state }, true);
|
||||
} catch (error) {
|
||||
@@ -1,27 +1,27 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { createResponseLegacy } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { createResponse } from "@formbricks/lib/response/service";
|
||||
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseLegacyInput } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { NextResponse } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const responseInput: TResponseInput = await request.json();
|
||||
const responseInput = await request.json();
|
||||
if (responseInput.personId === "legacy") {
|
||||
responseInput.personId = null;
|
||||
}
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const inputValidation = ZResponseInput.safeParse(responseInput);
|
||||
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
@@ -67,7 +67,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
responseInput.personId = null;
|
||||
}
|
||||
|
||||
response = await createResponse({
|
||||
response = await createResponseLegacy({
|
||||
...responseInput,
|
||||
meta,
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { SERVICES_REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { getDisplaysByPersonId } from "@formbricks/lib/display/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { productCache } from "@formbricks/lib/product/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { TSurveyWithTriggers } from "@formbricks/types/js";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
// Helper function to calculate difference in days between two dates
|
||||
const diffInDays = (date1: Date, date2: Date) => {
|
||||
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
return await getSyncSurveys(environmentId, person);
|
||||
},
|
||||
[`getSyncSurveysCached-${environmentId}`],
|
||||
{
|
||||
tags: [
|
||||
displayCache.tag.byPersonId(person.id),
|
||||
surveyCache.tag.byEnvironmentId(environmentId),
|
||||
productCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
export const getSyncSurveys = async (
|
||||
environmentId: string,
|
||||
person: TPerson
|
||||
): Promise<TSurveyWithTriggers[]> => {
|
||||
// get recontactDays from product
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
let surveys = await getSurveys(environmentId);
|
||||
|
||||
// filtered surveys for running and web
|
||||
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
|
||||
|
||||
const displays = await getDisplaysByPersonId(person.id);
|
||||
|
||||
// filter surveys that meet the displayOption criteria
|
||||
surveys = surveys.filter((survey) => {
|
||||
if (survey.displayOption === "respondMultiple") {
|
||||
return true;
|
||||
} else if (survey.displayOption === "displayOnce") {
|
||||
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
||||
} else if (survey.displayOption === "displayMultiple") {
|
||||
return (
|
||||
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
|
||||
0
|
||||
);
|
||||
} else {
|
||||
throw Error("Invalid displayOption");
|
||||
}
|
||||
});
|
||||
|
||||
const attributeClasses = await getAttributeClasses(environmentId);
|
||||
|
||||
// filter surveys that meet the attributeFilters criteria
|
||||
const potentialSurveysWithAttributes = surveys.filter((survey) => {
|
||||
const attributeFilters = survey.attributeFilters;
|
||||
if (attributeFilters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
// check if meets all attribute filters criterias
|
||||
return attributeFilters.every((attributeFilter) => {
|
||||
const attributeClassName = attributeClasses.find(
|
||||
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
|
||||
)?.name;
|
||||
if (!attributeClassName) {
|
||||
throw Error("Invalid attribute filter class");
|
||||
}
|
||||
const personAttributeValue = person.attributes[attributeClassName];
|
||||
if (attributeFilter.condition === "equals") {
|
||||
return personAttributeValue === attributeFilter.value;
|
||||
} else if (attributeFilter.condition === "notEquals") {
|
||||
return personAttributeValue !== attributeFilter.value;
|
||||
} else {
|
||||
throw Error("Invalid attribute filter condition");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const latestDisplay = displays[0];
|
||||
|
||||
// filter surveys that meet the recontactDays criteria
|
||||
surveys = potentialSurveysWithAttributes.filter((survey) => {
|
||||
if (!latestDisplay) {
|
||||
return true;
|
||||
} else if (survey.recontactDays !== null) {
|
||||
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
|
||||
if (!lastDisplaySurvey) {
|
||||
return true;
|
||||
}
|
||||
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
|
||||
} else if (product.recontactDays !== null) {
|
||||
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return surveys;
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import { getSyncSurveysCached } from "@/app/api/v1/(legacy)/js/lib/surveys";
|
||||
import { IS_FORMBRICKS_CLOUD, MAU_LIMIT, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getPerson } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TJsLegacyState } from "@formbricks/types/js";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getMonthlyActiveTeamPeopleCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
|
||||
const captureNewSessionTelemetry = async (jsVersion?: string): Promise<void> => {
|
||||
await captureTelemetry("state update", { jsVersion: jsVersion ?? "unknown" });
|
||||
};
|
||||
|
||||
export const getUpdatedState = async (
|
||||
environmentId: string,
|
||||
personId: string,
|
||||
jsVersion?: string
|
||||
): Promise<TJsLegacyState> => {
|
||||
let environment: TEnvironment | null;
|
||||
|
||||
if (jsVersion) {
|
||||
captureNewSessionTelemetry(jsVersion);
|
||||
}
|
||||
|
||||
// check if environment exists
|
||||
environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
if (!environment?.widgetSetupCompleted) {
|
||||
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
|
||||
}
|
||||
|
||||
// check team subscriptons
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team does not exist");
|
||||
}
|
||||
|
||||
// check if Monthly Active Users limit is reached
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const hasUserTargetingSubscription =
|
||||
team?.billing?.features.userTargeting.status &&
|
||||
team?.billing?.features.userTargeting.status in ["active", "canceled"];
|
||||
const currentMau = await getMonthlyActiveTeamPeopleCount(team.id);
|
||||
const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
|
||||
|
||||
if (isMauLimitReached) {
|
||||
const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`;
|
||||
throw new Error(errorMessage);
|
||||
|
||||
// if (!personId) {
|
||||
// // don't allow new people
|
||||
// throw new Error(errorMessage);
|
||||
// }
|
||||
// const session = await getSession(sessionId);
|
||||
// if (!session) {
|
||||
// // don't allow new sessions
|
||||
// throw new Error(errorMessage);
|
||||
// }
|
||||
// // check if session was created this month (user already active this month)
|
||||
// const now = new Date();
|
||||
// const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
// if (new Date(session.createdAt) < firstDayOfMonth) {
|
||||
// throw new Error(errorMessage);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
const person = await getPerson(personId);
|
||||
|
||||
if (!person) {
|
||||
throw new Error("Person not found");
|
||||
}
|
||||
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
getSyncSurveysCached(environmentId, person),
|
||||
getActionClasses(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
// return state
|
||||
const state: TJsLegacyState = {
|
||||
person: person!,
|
||||
session: {},
|
||||
surveys,
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
product,
|
||||
};
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const getPublicUpdatedState = async (environmentId: string) => {
|
||||
// check if environment exists
|
||||
const environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
// TODO: check if Monthly Active Users limit is reached
|
||||
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
getSurveys(environmentId),
|
||||
getActionClasses(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
const state: TJsLegacyState = {
|
||||
surveys,
|
||||
session: {},
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
product,
|
||||
person: null,
|
||||
};
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
|
||||
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
|
||||
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
@@ -26,10 +26,12 @@ export async function POST(req: Request): Promise<NextResponse> {
|
||||
|
||||
const { environmentId, userId } = inputValidation.data;
|
||||
|
||||
const personWithUserId = await getOrCreatePersonByUserId(userId, environmentId);
|
||||
|
||||
const state = await getUpdatedState(environmentId, personWithUserId.id);
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
if (!person) {
|
||||
person = await createPerson(environmentId, userId);
|
||||
}
|
||||
|
||||
const state = await getUpdatedState(environmentId, person.id);
|
||||
return responses.successResponse({ ...state }, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
11
apps/web/app/api/v1/(legacy)/js/sync/lib/legacy.ts
Normal file
11
apps/web/app/api/v1/(legacy)/js/sync/lib/legacy.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { TJsLegacyState, TJsState } from "@formbricks/types/js";
|
||||
|
||||
export const transformLegacySurveys = (state: TJsState): TJsLegacyState => {
|
||||
const updatedState: any = { ...state };
|
||||
updatedState.surveys = updatedState.surveys.map((survey) => {
|
||||
const updatedSurvey = { ...survey };
|
||||
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
|
||||
return updatedSurvey;
|
||||
});
|
||||
return { ...updatedState, session: {} };
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { SERVICES_REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { getDisplaysByPersonId } from "@formbricks/lib/display/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { productCache } from "@formbricks/lib/product/cache";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { TSurveyWithTriggers } from "@formbricks/types/js";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
// Helper function to calculate difference in days between two dates
|
||||
const diffInDays = (date1: Date, date2: Date) => {
|
||||
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
return await getSyncSurveys(environmentId, person);
|
||||
},
|
||||
[`getSyncSurveysCached-${environmentId}-${person.id}`],
|
||||
{
|
||||
tags: [
|
||||
displayCache.tag.byPersonId(person.id),
|
||||
surveyCache.tag.byEnvironmentId(environmentId),
|
||||
productCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
export const getSyncSurveys = async (
|
||||
environmentId: string,
|
||||
person: TPerson
|
||||
): Promise<TSurveyWithTriggers[]> => {
|
||||
// get recontactDays from product
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
let surveys = await getSurveys(environmentId);
|
||||
|
||||
// filtered surveys for running and web
|
||||
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
|
||||
|
||||
const displays = await getDisplaysByPersonId(person.id);
|
||||
|
||||
// filter surveys that meet the displayOption criteria
|
||||
surveys = surveys.filter((survey) => {
|
||||
if (survey.displayOption === "respondMultiple") {
|
||||
return true;
|
||||
} else if (survey.displayOption === "displayOnce") {
|
||||
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
||||
} else if (survey.displayOption === "displayMultiple") {
|
||||
return (
|
||||
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
|
||||
0
|
||||
);
|
||||
} else {
|
||||
throw Error("Invalid displayOption");
|
||||
}
|
||||
});
|
||||
|
||||
const attributeClasses = await getAttributeClasses(environmentId);
|
||||
|
||||
// filter surveys that meet the attributeFilters criteria
|
||||
const potentialSurveysWithAttributes = surveys.filter((survey) => {
|
||||
const attributeFilters = survey.attributeFilters;
|
||||
if (attributeFilters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
// check if meets all attribute filters criterias
|
||||
return attributeFilters.every((attributeFilter) => {
|
||||
const attributeClassName = attributeClasses.find(
|
||||
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
|
||||
)?.name;
|
||||
if (!attributeClassName) {
|
||||
throw Error("Invalid attribute filter class");
|
||||
}
|
||||
const personAttributeValue = person.attributes[attributeClassName];
|
||||
if (attributeFilter.condition === "equals") {
|
||||
return personAttributeValue === attributeFilter.value;
|
||||
} else if (attributeFilter.condition === "notEquals") {
|
||||
return personAttributeValue !== attributeFilter.value;
|
||||
} else {
|
||||
throw Error("Invalid attribute filter condition");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const latestDisplay = displays[0];
|
||||
|
||||
// filter surveys that meet the recontactDays criteria
|
||||
surveys = potentialSurveysWithAttributes.filter((survey) => {
|
||||
if (!latestDisplay) {
|
||||
return true;
|
||||
} else if (survey.recontactDays !== null) {
|
||||
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
|
||||
if (!lastDisplaySurvey) {
|
||||
return true;
|
||||
}
|
||||
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
|
||||
} else if (product.recontactDays !== null) {
|
||||
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return surveys;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getSyncSurveysCached } from "@/app/api/v1/(legacy)/js/sync/lib/surveys";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import {
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
@@ -9,15 +8,25 @@ import {
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getPerson } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getSurveys, getSyncSurveys } from "@formbricks/lib/survey/service";
|
||||
import {
|
||||
getMonthlyActiveTeamPeopleCount,
|
||||
getMonthlyTeamResponseCount,
|
||||
getTeamByEnvironmentId,
|
||||
} from "@formbricks/lib/team/service";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TJsLegacyState } from "@formbricks/types/js";
|
||||
import { TJsLegacyState, TSurveyWithTriggers } from "@formbricks/types/js";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
export const transformLegacySurveys = (surveys: TSurvey[]): TSurveyWithTriggers[] => {
|
||||
const updatedSurveys = surveys.map((survey) => {
|
||||
const updatedSurvey: any = { ...survey };
|
||||
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
|
||||
return updatedSurvey;
|
||||
});
|
||||
return updatedSurveys;
|
||||
};
|
||||
|
||||
export const getUpdatedState = async (environmentId: string, personId?: string): Promise<TJsLegacyState> => {
|
||||
let environment: TEnvironment | null;
|
||||
@@ -85,12 +94,14 @@ export const getUpdatedState = async (environmentId: string, personId?: string):
|
||||
if (isAppSurveyLimitReached) {
|
||||
surveys = [];
|
||||
} else if (isPerson) {
|
||||
surveys = await getSyncSurveysCached(environmentId, person as TPerson);
|
||||
surveys = await getSyncSurveys(environmentId, person as TPerson);
|
||||
} else {
|
||||
surveys = await getSurveys(environmentId);
|
||||
surveys = surveys.filter((survey) => survey.type === "web");
|
||||
surveys = surveys.filter((survey) => survey.type === "web" && survey.status === "inProgress");
|
||||
}
|
||||
|
||||
surveys = transformLegacySurveys(surveys);
|
||||
|
||||
// get/create rest of the state
|
||||
const [noCodeActionClasses, product] = await Promise.all([
|
||||
getActionClasses(environmentId),
|
||||
|
||||
@@ -16,10 +16,11 @@ export async function OPTIONS(): Promise<NextResponse> {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: Context): Promise<NextResponse> {
|
||||
const { displayId } = context.params;
|
||||
const { displayId, environmentId } = context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayUpdateInput.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getLatestActionByPersonId } from "@formbricks/lib/action/service";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD, MAU_LIMIT, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSyncSurveysCached } from "@formbricks/lib/survey/service";
|
||||
import { getSyncSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getMonthlyActiveTeamPeopleCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
@@ -43,52 +44,54 @@ export async function GET(
|
||||
|
||||
const { environmentId, userId } = inputValidation.data;
|
||||
|
||||
// check if person exists
|
||||
const person = await getOrCreatePersonByUserId(userId, environmentId);
|
||||
|
||||
if (!person) {
|
||||
return responses.badRequestResponse(`Person with userId ${userId} not found`);
|
||||
}
|
||||
|
||||
let environment: TEnvironment | null;
|
||||
|
||||
// check if environment exists
|
||||
environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
if (!environment?.widgetSetupCompleted) {
|
||||
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
|
||||
}
|
||||
|
||||
// check team subscriptons
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team does not exist");
|
||||
}
|
||||
|
||||
// check if Monthly Active Users limit is reached
|
||||
// check if MAU limit is reached
|
||||
let isMauLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
// check team subscriptons
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team does not exist");
|
||||
}
|
||||
const hasUserTargetingSubscription =
|
||||
team?.billing?.features.userTargeting.status &&
|
||||
team?.billing?.features.userTargeting.status in ["active", "canceled"];
|
||||
const currentMau = await getMonthlyActiveTeamPeopleCount(team.id);
|
||||
const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
|
||||
isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
|
||||
}
|
||||
|
||||
// TODO: Problem is that if isMauLimitReached, all sync request will fail
|
||||
// But what we essentially want, is to fail only for new people syncing for the first time
|
||||
|
||||
if (isMauLimitReached) {
|
||||
const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`;
|
||||
let person = await getPersonByUserId(environmentId, userId);
|
||||
if (!isMauLimitReached) {
|
||||
if (!person) {
|
||||
person = await createPerson(environmentId, userId);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
|
||||
if (!person) {
|
||||
// if it's a new person and MAU limit is reached, throw an error
|
||||
throw new Error(errorMessage);
|
||||
} else {
|
||||
// check if person has been active this month
|
||||
const latestAction = await getLatestActionByPersonId(person.id);
|
||||
if (!latestAction || new Date(latestAction.createdAt).getMonth() !== new Date().getMonth()) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [surveys, noCodeActionClasses, product] = await Promise.all([
|
||||
getSyncSurveysCached(environmentId, person),
|
||||
getSyncSurveys(environmentId, person),
|
||||
getActionClasses(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getPersonByUserId, updatePerson } from "@formbricks/lib/person/service";
|
||||
import { ZPersonUpdateInput } from "@formbricks/types/people";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface Context {
|
||||
params: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(req: Request, context: Context): Promise<NextResponse> {
|
||||
try {
|
||||
const { userId, environmentId } = context.params;
|
||||
const jsonInput = await req.json();
|
||||
|
||||
// validate using zod
|
||||
const inputValidation = ZPersonUpdateInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (!person) {
|
||||
return responses.notFoundResponse("PersonByUserId", userId, true);
|
||||
}
|
||||
|
||||
const updatedPerson = await updatePerson(person.id, inputValidation.data);
|
||||
|
||||
return responses.successResponse(updatedPerson, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { updateResponse } from "@formbricks/lib/response/service";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPerson } from "@formbricks/lib/person/service";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
@@ -23,6 +24,13 @@ export async function PUT(
|
||||
|
||||
const responseUpdate = await request.json();
|
||||
|
||||
// legacy workaround for formbricks-js 1.2.0 & 1.2.1
|
||||
if (responseUpdate.personId && typeof responseUpdate.personId === "string") {
|
||||
const person = await getPerson(responseUpdate.personId);
|
||||
responseUpdate.userId = person?.userId;
|
||||
delete responseUpdate.personId;
|
||||
}
|
||||
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { getPerson } from "@formbricks/lib/person/service";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { createResponse } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { NextResponse } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
interface Context {
|
||||
params: {
|
||||
environmentId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const responseInput: TResponseInput = await request.json();
|
||||
export async function POST(request: Request, context: Context): Promise<NextResponse> {
|
||||
const { environmentId } = context.params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const responseInput = await request.json();
|
||||
|
||||
// legacy workaround for formbricks-js 1.2.0 & 1.2.1
|
||||
if (responseInput.personId && typeof responseInput.personId === "string") {
|
||||
const person = await getPerson(responseInput.personId);
|
||||
responseInput.userId = person?.userId;
|
||||
delete responseInput.personId;
|
||||
}
|
||||
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const inputValidation = ZResponseInput.safeParse(responseInput);
|
||||
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
@@ -27,17 +54,20 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
);
|
||||
}
|
||||
|
||||
let survey;
|
||||
|
||||
try {
|
||||
survey = await getSurvey(responseInput.surveyId);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInput.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const teamDetails = await getTeamDetails(survey.environmentId);
|
||||
@@ -54,14 +84,8 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
},
|
||||
};
|
||||
|
||||
// check if personId is anonymous
|
||||
if (responseInput.personId === "anonymous") {
|
||||
// remove this from the request
|
||||
responseInput.personId = null;
|
||||
}
|
||||
|
||||
response = await createResponse({
|
||||
...responseInput,
|
||||
...inputValidation.data,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { putFileToLocalStorage } from "@formbricks/lib/storage/service";
|
||||
import { UPLOADS_DIR } from "@formbricks/lib/constants";
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { validateLocalSignedUrl } from "@formbricks/lib/crypto";
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const surveyId = request.nextUrl.searchParams.get("surveyId");
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const responseArray = await getResponsesByEnvironmentId(authentication.environmentId!);
|
||||
return responses.successResponse(responseArray);
|
||||
let environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId!);
|
||||
if (surveyId) {
|
||||
environmentResponses = environmentResponses.filter((response) => response.surveyId === surveyId);
|
||||
}
|
||||
return responses.successResponse(environmentResponses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { UPLOADS_DIR } from "@formbricks/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@formbricks/lib/crypto";
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const accessType = "public"; // public files are accessible by anyone
|
||||
|
||||
@@ -2,6 +2,55 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--formbricks-brand: #038178;
|
||||
|
||||
/* Fill Colors */
|
||||
--formbricks-fill-primary: #fefefe;
|
||||
--formbricks-fill-secondary: #0f172a;
|
||||
--formbricks-fill-disabled: #e0e0e0;
|
||||
|
||||
/* Label Colors */
|
||||
--formbricks-label-primary: #0f172a;
|
||||
--formbricks-label-secondary: #384258;
|
||||
--formbricks-label-disabled: #bdbdbd;
|
||||
|
||||
/* Border Colors */
|
||||
--formbricks-border-primary: #e0e0e0;
|
||||
--formbricks-border-secondary: #0f172a;
|
||||
--formbricks-border-disabled: #ececec;
|
||||
|
||||
/* Functional Colors */
|
||||
--formbricks-focus: #1982fc;
|
||||
--formbricks-error: #d13a3a;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Brand Colors */
|
||||
--formbricks-brand: #038178;
|
||||
|
||||
/* Fill Colors */
|
||||
--formbricks-fill-primary: #0f172a;
|
||||
--formbricks-fill-secondary: #e0e0e0;
|
||||
--formbricks-fill-disabled: #394258;
|
||||
|
||||
/* Label Colors */
|
||||
--formbricks-label-primary: #fefefe;
|
||||
--formbricks-label-secondary: #f2f2f2;
|
||||
--formbricks-label-disabled: #bdbdbd;
|
||||
|
||||
/* Border Colors */
|
||||
--formbricks-border-primary: #394258;
|
||||
--formbricks-border-secondary: #e0e0e0;
|
||||
--formbricks-border-disabled: #394258;
|
||||
|
||||
/* Functional Colors */
|
||||
--formbricks-focus: #1982fc;
|
||||
--formbricks-error: #d13a3a;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
[data-nextjs-scroll-focus-boundary] {
|
||||
display: contents;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import "./globals.css";
|
||||
import { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Formbricks",
|
||||
default: "Formbricks",
|
||||
},
|
||||
description: "Open-Source In-Product Survey Platform",
|
||||
description: "Open-Source Survey Suite",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -208,7 +208,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
|
||||
product: {
|
||||
select: {
|
||||
brandColor: true,
|
||||
formbricksSignature: true,
|
||||
linkSurveyBranding: true,
|
||||
placement: true,
|
||||
darkOverlay: true,
|
||||
clickOutsideClose: true,
|
||||
@@ -217,7 +217,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
|
||||
},
|
||||
});
|
||||
|
||||
const formbricksSignature = environmentProdut?.product.formbricksSignature;
|
||||
const formbricksSignature = environmentProdut?.product.linkSurveyBranding;
|
||||
const brandColor = environmentProdut?.product.brandColor;
|
||||
const placement = environmentProdut?.product.placement;
|
||||
const darkOverlay = environmentProdut?.product.darkOverlay;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import formbricks from "@formbricks/js";
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
|
||||
export const formbricksEnabled =
|
||||
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
|
||||
@@ -10,11 +10,11 @@ export const createResponse = async (
|
||||
finished: boolean = false
|
||||
): Promise<any> => {
|
||||
const api = formbricks.getApi();
|
||||
const personId = formbricks.getPerson()?.id;
|
||||
const userId = formbricks.getPerson()?.userId;
|
||||
|
||||
return await api.client.response.create({
|
||||
surveyId,
|
||||
personId: personId ?? "",
|
||||
userId: userId ?? "",
|
||||
finished,
|
||||
data,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
|
||||
import cuid2 from "@paralleldrive/cuid2";
|
||||
|
||||
|
||||
15
apps/web/app/middleware/bucket.ts
Normal file
15
apps/web/app/middleware/bucket.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import rateLimit from "@/app/middleware/rateLimit";
|
||||
import { CLIENT_SIDE_API_RATE_LIMIT, LOGIN_RATE_LIMIT, SIGNUP_RATE_LIMIT } from "@formbricks/lib/constants";
|
||||
|
||||
export const signUpLimiter = rateLimit({
|
||||
interval: SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const loginLimiter = rateLimit({
|
||||
interval: LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const clientSideApiEndpointsLimiter = rateLimit({
|
||||
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
10
apps/web/app/middleware/endpointValidator.ts
Normal file
10
apps/web/app/middleware/endpointValidator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const loginRoute = (url: string) => url === "/api/auth/callback/credentials";
|
||||
|
||||
export const signupRoute = (url: string) => url === "/api/v1/users";
|
||||
|
||||
export const clientSideApiRoute = (url: string): boolean => {
|
||||
if (url.includes("/api/v1/js/actions")) return true;
|
||||
if (url.includes("/api/v1/client/storage")) return true;
|
||||
const regex = /^\/api\/v\d+\/client\//;
|
||||
return regex.test(url);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { LRUCache } from "lru-cache";
|
||||
|
||||
type Options = {
|
||||
interval: number;
|
||||
allowedPerInterval: number;
|
||||
};
|
||||
|
||||
export default function rateLimit(options: Options) {
|
||||
@@ -20,7 +21,7 @@ export default function rateLimit(options: Options) {
|
||||
tokenCount[0] += 1;
|
||||
|
||||
const currentUsage = tokenCount[0];
|
||||
const isRateLimited = currentUsage >= 5;
|
||||
const isRateLimited = currentUsage >= options.allowedPerInterval;
|
||||
return isRateLimited ? reject() : resolve();
|
||||
}),
|
||||
};
|
||||
@@ -18,7 +18,7 @@ import { FormbricksAPI } from "@formbricks/api";
|
||||
interface LinkSurveyProps {
|
||||
survey: TSurvey;
|
||||
product: TProduct;
|
||||
personId?: string;
|
||||
userId?: string;
|
||||
emailVerificationStatus?: string;
|
||||
prefillAnswer?: string;
|
||||
singleUseId?: string;
|
||||
@@ -29,7 +29,7 @@ interface LinkSurveyProps {
|
||||
export default function LinkSurvey({
|
||||
survey,
|
||||
product,
|
||||
personId,
|
||||
userId,
|
||||
emailVerificationStatus,
|
||||
prefillAnswer,
|
||||
singleUseId,
|
||||
@@ -41,9 +41,7 @@ export default function LinkSurvey({
|
||||
const isPreview = searchParams?.get("preview") === "true";
|
||||
const sourceParam = searchParams?.get("source");
|
||||
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
|
||||
const [surveyState, setSurveyState] = useState(
|
||||
new SurveyState(survey.id, singleUseId, responseId, personId)
|
||||
);
|
||||
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId, userId));
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string>(
|
||||
survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id
|
||||
);
|
||||
@@ -85,21 +83,20 @@ export default function LinkSurvey({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [hiddenFieldsRecord, setHiddenFieldsRecord] = useState<Record<string, string | number | string[]>>();
|
||||
const hiddenFieldsRecord = useMemo<Record<string, string | number | string[]> | null>(() => {
|
||||
const fieldsRecord: Record<string, string | number | string[]> = {};
|
||||
let fieldsSet = false;
|
||||
|
||||
useEffect(() => {
|
||||
survey.hiddenFields?.fieldIds?.forEach((field) => {
|
||||
// set the question and answer to the survey state
|
||||
const answer = searchParams?.get(field);
|
||||
if (answer) {
|
||||
setHiddenFieldsRecord((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[field]: answer,
|
||||
};
|
||||
});
|
||||
fieldsRecord[field] = answer;
|
||||
fieldsSet = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Only return the record if at least one field was set.
|
||||
return fieldsSet ? fieldsRecord : null;
|
||||
}, [searchParams, survey.hiddenFields?.fieldIds]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -136,7 +133,7 @@ export default function LinkSurvey({
|
||||
<SurveyInline
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
formbricksSignature={product.formbricksSignature}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onDisplay={async () => {
|
||||
if (!isPreview) {
|
||||
const api = new FormbricksAPI({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { cn } from "@formbricks/lib/cn";
|
||||
interface LinkSurveyPinScreenProps {
|
||||
surveyId: string;
|
||||
product: TProduct;
|
||||
personId?: string;
|
||||
userId?: string;
|
||||
emailVerificationStatus?: string;
|
||||
prefillAnswer?: string;
|
||||
singleUseId?: string;
|
||||
@@ -28,7 +28,7 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
|
||||
product,
|
||||
webAppUrl,
|
||||
emailVerificationStatus,
|
||||
personId,
|
||||
userId,
|
||||
prefillAnswer,
|
||||
singleUseId,
|
||||
singleUseResponse,
|
||||
@@ -103,7 +103,7 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
product={product}
|
||||
personId={personId}
|
||||
userId={userId}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={prefillAnswer}
|
||||
singleUseId={singleUseId}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
|
||||
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
|
||||
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getEmailVerificationStatus } from "./lib/helpers";
|
||||
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import type { Metadata } from "next";
|
||||
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
|
||||
import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
|
||||
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
|
||||
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
|
||||
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getEmailVerificationStatus } from "./lib/helpers";
|
||||
|
||||
interface LinkSurveyPageProps {
|
||||
params: {
|
||||
@@ -146,9 +146,12 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
|
||||
}
|
||||
|
||||
const userId = searchParams.userId;
|
||||
let person;
|
||||
if (userId) {
|
||||
person = await getOrCreatePersonByUserId(userId, survey.environmentId);
|
||||
// make sure the person exists or get's created
|
||||
const person = await getPersonByUserId(survey.environmentId, userId);
|
||||
if (!person) {
|
||||
await createPerson(survey.environmentId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
const isSurveyPinProtected = Boolean(!!survey && survey.pin);
|
||||
@@ -158,7 +161,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
|
||||
<PinScreen
|
||||
surveyId={survey.id}
|
||||
product={product}
|
||||
personId={person?.id}
|
||||
userId={userId}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
|
||||
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
|
||||
@@ -172,7 +175,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
product={product}
|
||||
personId={person?.id}
|
||||
userId={userId}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
|
||||
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { UPLOADS_DIR } from "@formbricks/lib/constants";
|
||||
import { getLocalFile, getS3File } from "@formbricks/lib/storage/service";
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import rateLimit from "@/app/(auth)/auth/rate-limit";
|
||||
import { signUpLimiter, loginLimiter, clientSideApiEndpointsLimiter } from "@/app/middleware/bucket";
|
||||
import { clientSideApiRoute, loginRoute, signupRoute } from "@/app/middleware/endpointValidator";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const signUpLimiter = rateLimit({ interval: 60 * 60 * 1000 }); // 60 minutes
|
||||
const loginLimiter = rateLimit({ interval: 15 * 60 * 1000 }); // 15 minutes
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return NextResponse.next();
|
||||
@@ -19,10 +17,12 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
if (ip) {
|
||||
try {
|
||||
if (request.nextUrl.pathname === "/api/auth/callback/credentials") {
|
||||
if (loginRoute(request.nextUrl.pathname)) {
|
||||
await loginLimiter.check(ip);
|
||||
} else if (request.nextUrl.pathname === "/api/v1/users") {
|
||||
} else if (signupRoute(request.nextUrl.pathname)) {
|
||||
await signUpLimiter.check(ip);
|
||||
} else if (clientSideApiRoute(request.nextUrl.pathname)) {
|
||||
await clientSideApiEndpointsLimiter.check(ip);
|
||||
}
|
||||
return res;
|
||||
} catch (_e) {
|
||||
@@ -35,5 +35,11 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/api/auth/callback/credentials", "/api/v1/users"],
|
||||
matcher: [
|
||||
"/api/auth/callback/credentials",
|
||||
"/api/v1/users",
|
||||
"/api/(.*)/client/:path*",
|
||||
"/api/v1/js/actions",
|
||||
"/api/v1/client/storage",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import "./env.mjs";
|
||||
import "@formbricks/lib/env.mjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
@@ -47,6 +47,11 @@ const nextConfig = {
|
||||
destination: "/api/v1/management/surveys",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/api/v1/responses",
|
||||
destination: "/api/v1/management/responses",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/api/v1/me",
|
||||
destination: "/api/v1/management/me",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -11,7 +11,6 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/s3-presigned-post": "^3.438.0",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/ee": "workspace:*",
|
||||
@@ -26,42 +25,42 @@
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-email/components": "^0.0.9",
|
||||
"@sentry/nextjs": "^7.77.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"@sentry/nextjs": "^7.80.1",
|
||||
"@vercel/og": "^0.5.20",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"encoding": "^0.1.13",
|
||||
"framer-motion": "10.16.4",
|
||||
"framer-motion": "10.16.5",
|
||||
"googleapis": "^128.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^10.0.1",
|
||||
"lucide-react": "^0.290.0",
|
||||
"lru-cache": "^10.0.2",
|
||||
"lucide-react": "^0.292.0",
|
||||
"mime": "^3.0.0",
|
||||
"next": "13.5.6",
|
||||
"nodemailer": "^6.9.7",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.87.3",
|
||||
"posthog-js": "^1.91.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-email": "^1.9.5",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"webpack": "^5.89.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@types/bcryptjs": "^2.4.5",
|
||||
"@types/lodash": "^4.14.200",
|
||||
"@types/markdown-it": "^13.0.5",
|
||||
"@types/qrcode": "^1.5.4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/lodash": "^4.14.201",
|
||||
"@types/markdown-it": "^13.0.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"eslint-config-formbricks": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
select: {
|
||||
brandColor: true,
|
||||
formbricksSignature: true,
|
||||
linkSurveyBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
message: "Survey not running",
|
||||
reason: survey.status,
|
||||
brandColor: product?.brandColor,
|
||||
formbricksSignature: product?.formbricksSignature,
|
||||
formbricksSignature: product?.linkSurveyBranding,
|
||||
surveyClosedMessage: survey?.surveyClosedMessage,
|
||||
});
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(200).json({
|
||||
...survey,
|
||||
brandColor: product?.brandColor,
|
||||
formbricksSignature: product?.formbricksSignature,
|
||||
formbricksSignature: product?.linkSurveyBranding,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
"@changesets/cli": "^2.26.2",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.0.1",
|
||||
"lint-staged": "^15.1.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsx": "^3.13.0",
|
||||
"tsx": "^4.2.0",
|
||||
"turbo": "^1.10.16"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/api",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Formbricks-api is an api wrapper for the Formbricks client API",
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
@@ -34,8 +34,8 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"terser": "^5.22.0",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "^3.6.0"
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-dts": "^3.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
18
packages/api/src/api/client/action.ts
Normal file
18
packages/api/src/api/client/action.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Result } from "@formbricks/types/errorHandlers";
|
||||
import { NetworkError } from "@formbricks/types/errors";
|
||||
import { TActionInput } from "@formbricks/types/actions";
|
||||
import { makeRequest } from "../../utils/makeRequest";
|
||||
|
||||
export class ActionAPI {
|
||||
private apiHost: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(apiHost: string, environmentId: string) {
|
||||
this.apiHost = apiHost;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async create(actionInput: Omit<TActionInput, "environmentId">): Promise<Result<{}, NetworkError | Error>> {
|
||||
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/actions`, "POST", actionInput);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export class DisplayAPI {
|
||||
|
||||
async update(
|
||||
displayId: string,
|
||||
displayInput: TDisplayUpdateInput
|
||||
displayInput: Omit<TDisplayUpdateInput, "environmentId">
|
||||
): Promise<Result<TDisplay, NetworkError | Error>> {
|
||||
return makeRequest(
|
||||
this.apiHost,
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { ResponseAPI } from "./response";
|
||||
import { DisplayAPI } from "./display";
|
||||
import { ApiConfig } from "../../types";
|
||||
import { ActionAPI } from "./action";
|
||||
import { PeopleAPI } from "./people";
|
||||
|
||||
export class Client {
|
||||
response: ResponseAPI;
|
||||
display: DisplayAPI;
|
||||
action: ActionAPI;
|
||||
people: PeopleAPI;
|
||||
|
||||
constructor(options: ApiConfig) {
|
||||
const { apiHost, environmentId } = options;
|
||||
|
||||
this.response = new ResponseAPI(apiHost, environmentId);
|
||||
this.display = new DisplayAPI(apiHost, environmentId);
|
||||
this.action = new ActionAPI(apiHost, environmentId);
|
||||
this.people = new PeopleAPI(apiHost, environmentId);
|
||||
}
|
||||
}
|
||||
|
||||
33
packages/api/src/api/client/people.ts
Normal file
33
packages/api/src/api/client/people.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Result } from "@formbricks/types/errorHandlers";
|
||||
import { NetworkError } from "@formbricks/types/errors";
|
||||
import { makeRequest } from "../../utils/makeRequest";
|
||||
import { TPerson, TPersonUpdateInput } from "@formbricks/types/people";
|
||||
|
||||
export class PeopleAPI {
|
||||
private apiHost: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(apiHost: string, environmentId: string) {
|
||||
this.apiHost = apiHost;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async create(userId: string): Promise<Result<TPerson, NetworkError | Error>> {
|
||||
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/people`, "POST", {
|
||||
environmentId: this.environmentId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
personInput: TPersonUpdateInput
|
||||
): Promise<Result<TPerson, NetworkError | Error>> {
|
||||
return makeRequest(
|
||||
this.apiHost,
|
||||
`/api/v1/client/${this.environmentId}/people/${userId}`,
|
||||
"POST",
|
||||
personInput
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { makeRequest } from "../../utils/makeRequest";
|
||||
import { NetworkError } from "@formbricks/types/errors";
|
||||
import { Result } from "@formbricks/types/errorHandlers";
|
||||
import { NetworkError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { makeRequest } from "../../utils/makeRequest";
|
||||
|
||||
type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string };
|
||||
|
||||
@@ -14,7 +14,9 @@ export class ResponseAPI {
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async create(responseInput: TResponseInput): Promise<Result<TResponse, NetworkError | Error>> {
|
||||
async create(
|
||||
responseInput: Omit<TResponseInput, "environmentId">
|
||||
): Promise<Result<TResponse, NetworkError | Error>> {
|
||||
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `formbricksSignature` on the `Product` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" RENAME COLUMN "formbricksSignature" TO "linkSurveyBranding";
|
||||
ALTER TABLE "Product" ADD COLUMN "inAppSurveyBranding" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -25,7 +25,7 @@
|
||||
"predev": "pnpm generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@prisma/extension-accelerate": "^0.6.2",
|
||||
"dotenv-cli": "^7.3.0"
|
||||
},
|
||||
@@ -33,9 +33,9 @@
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"prisma": "^5.4.2",
|
||||
"prisma": "^5.6.0",
|
||||
"prisma-dbml-generator": "^0.10.0",
|
||||
"prisma-json-types-generator": "^3.0.2",
|
||||
"prisma-json-types-generator": "^3.0.3",
|
||||
"zod": "^3.22.4",
|
||||
"zod-prisma": "^0.5.4"
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@ model Product {
|
||||
brandColor String @default("#64748b")
|
||||
highlightBorderColor String?
|
||||
recontactDays Int @default(7)
|
||||
formbricksSignature Boolean @default(true)
|
||||
linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys
|
||||
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys
|
||||
placement WidgetPlacement @default(bottomRight)
|
||||
clickOutsideClose Boolean @default(true)
|
||||
darkOverlay Boolean @default(false)
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"stripe": "^14.0.0"
|
||||
"stripe": "^14.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-next": "^14.0.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
@@ -34,17 +34,17 @@
|
||||
},
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.2",
|
||||
"@babel/preset-env": "^7.23.2",
|
||||
"@babel/preset-typescript": "^7.23.2",
|
||||
"@babel/core": "^7.23.3",
|
||||
"@babel/preset-env": "^7.23.3",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||
"@typescript-eslint/parser": "^6.8.0",
|
||||
"@types/jest": "^29.5.9",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
@@ -52,9 +52,9 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"terser": "^5.22.0",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "^3.6.0"
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-dts": "^3.6.3"
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { TJsActionInput, TSurveyWithTriggers } from "@formbricks/types/js";
|
||||
import { TJsActionInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { Config } from "./config";
|
||||
import { NetworkError, Result, err, okVoid } from "./errors";
|
||||
import { Logger } from "./logger";
|
||||
import { renderWidget } from "./widget";
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
const logger = Logger.getInstance();
|
||||
const config = Config.getInstance();
|
||||
|
||||
@@ -22,24 +24,23 @@ export const trackAction = async (
|
||||
// don't send actions to the backend if the person is not identified
|
||||
if (config.get().state?.person?.userId && !intentsToNotCreateOnApp.includes(name)) {
|
||||
logger.debug(`Sending action "${name}" to backend`);
|
||||
const res = await fetch(`${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
body: JSON.stringify(input),
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
});
|
||||
const res = await api.client.action.create({
|
||||
...input,
|
||||
userId: config.get().state.person!.userId,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: `Error tracking action: ${JSON.stringify(error)}`,
|
||||
status: res.status,
|
||||
url: res.url,
|
||||
responseMessage: error.message,
|
||||
message: `Error tracking action ${name}`,
|
||||
status: 500,
|
||||
url: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`,
|
||||
responseMessage: res.error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -58,10 +59,10 @@ export const trackAction = async (
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const triggerSurvey = (actionName: string, activeSurveys: TSurveyWithTriggers[]): void => {
|
||||
export const triggerSurvey = (actionName: string, activeSurveys: TSurvey[]): void => {
|
||||
for (const survey of activeSurveys) {
|
||||
for (const trigger of survey.triggers) {
|
||||
if (typeof trigger === "string" ? trigger === actionName : trigger.name === actionName) {
|
||||
if (trigger === actionName) {
|
||||
logger.debug(`Formbricks: survey ${survey.id} triggered by action "${actionName}"`);
|
||||
renderWidget(survey);
|
||||
return;
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { TJsPeopleAttributeInput, TJsState } from "@formbricks/types/js";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { TPerson, TPersonUpdateInput } from "@formbricks/types/people";
|
||||
import { Config } from "./config";
|
||||
import {
|
||||
AttributeAlreadyExistsError,
|
||||
MissingPersonError,
|
||||
NetworkError,
|
||||
Result,
|
||||
err,
|
||||
ok,
|
||||
okVoid,
|
||||
} from "./errors";
|
||||
import { AttributeAlreadyExistsError, MissingPersonError, NetworkError, Result, err, okVoid } from "./errors";
|
||||
import { deinitalize, initialize } from "./initialize";
|
||||
import { Logger } from "./logger";
|
||||
import { sync } from "./sync";
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
@@ -19,7 +12,7 @@ const logger = Logger.getInstance();
|
||||
export const updatePersonAttribute = async (
|
||||
key: string,
|
||||
value: string
|
||||
): Promise<Result<TJsState, NetworkError | MissingPersonError>> => {
|
||||
): Promise<Result<void, NetworkError | MissingPersonError>> => {
|
||||
if (!config.get().state.person || !config.get().state.person?.id) {
|
||||
return err({
|
||||
code: "missing_person",
|
||||
@@ -27,37 +20,39 @@ export const updatePersonAttribute = async (
|
||||
});
|
||||
}
|
||||
|
||||
const input: TJsPeopleAttributeInput = {
|
||||
key,
|
||||
value,
|
||||
const input: TPersonUpdateInput = {
|
||||
attributes: {
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
`${config.get().apiHost}/api/v1/client/${config.get().environmentId}/people/${
|
||||
config.get().state.person?.id
|
||||
}/set-attribute`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
);
|
||||
|
||||
const resJson = await res.json();
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
});
|
||||
const res = await api.client.people.update(config.get().state.person!.userId, input);
|
||||
|
||||
if (!res.ok) {
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: res.status,
|
||||
message: "Error updating person",
|
||||
url: res.url,
|
||||
responseMessage: resJson.message,
|
||||
status: 500,
|
||||
message: `Error updating person with userId ${config.get().state.person?.userId}`,
|
||||
url: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/people/${
|
||||
config.get().state.person?.userId
|
||||
}`,
|
||||
responseMessage: res.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(resJson.data as TJsState);
|
||||
logger.debug("Attribute updated. Syncing...");
|
||||
|
||||
await sync({
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: config.get().apiHost,
|
||||
userId: config.get().state.person?.userId,
|
||||
});
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const hasAttributeValue = (key: string, value: string): boolean => {
|
||||
@@ -95,14 +90,6 @@ export const setPersonAttribute = async (
|
||||
const result = await updatePersonAttribute(key, value.toString());
|
||||
|
||||
if (result.ok) {
|
||||
const state = result.value;
|
||||
|
||||
config.update({
|
||||
apiHost: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
state,
|
||||
});
|
||||
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import SurveyState from "@formbricks/lib/surveyState";
|
||||
import { renderSurveyModal } from "@formbricks/surveys";
|
||||
import { TJSStateDisplay, TSurveyWithTriggers } from "@formbricks/types/js";
|
||||
import { TJSStateDisplay } from "@formbricks/types/js";
|
||||
import { TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { Config } from "./config";
|
||||
import { ErrorHandler } from "./errors";
|
||||
import { Logger } from "./logger";
|
||||
import { filterPublicSurveys, sync } from "./sync";
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
|
||||
const containerId = "formbricks-web-container";
|
||||
const config = Config.getInstance();
|
||||
@@ -15,7 +16,7 @@ const logger = Logger.getInstance();
|
||||
const errorHandler = ErrorHandler.getInstance();
|
||||
let surveyRunning = false;
|
||||
|
||||
export const renderWidget = (survey: TSurveyWithTriggers) => {
|
||||
export const renderWidget = (survey: TSurvey) => {
|
||||
if (surveyRunning) {
|
||||
logger.debug("A survey is already running. Skipping.");
|
||||
return;
|
||||
@@ -45,15 +46,16 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
|
||||
const productOverwrites = survey.productOverwrites ?? {};
|
||||
const brandColor = productOverwrites.brandColor ?? product.brandColor;
|
||||
const highlightBorderColor = productOverwrites.highlightBorderColor ?? product.highlightBorderColor;
|
||||
const clickOutside = productOverwrites.clickOutside ?? product.clickOutsideClose;
|
||||
const clickOutside = productOverwrites.clickOutsideClose ?? product.clickOutsideClose;
|
||||
const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay;
|
||||
const placement = productOverwrites.placement ?? product.placement;
|
||||
const isBrandingEnabled = product.inAppSurveyBranding;
|
||||
|
||||
setTimeout(() => {
|
||||
renderSurveyModal({
|
||||
survey: survey,
|
||||
brandColor,
|
||||
formbricksSignature: true,
|
||||
isBrandingEnabled: isBrandingEnabled,
|
||||
clickOutside,
|
||||
darkOverlay,
|
||||
highlightBorderColor,
|
||||
@@ -70,12 +72,13 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
|
||||
const existingDisplays = config.get().state.displays;
|
||||
const displays = existingDisplays ? [...existingDisplays, localDisplay] : [localDisplay];
|
||||
const previousConfig = config.get();
|
||||
let state = filterPublicSurveys({
|
||||
...previousConfig.state,
|
||||
displays,
|
||||
});
|
||||
config.update({
|
||||
...previousConfig,
|
||||
state: {
|
||||
...previousConfig.state,
|
||||
displays,
|
||||
},
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,18 +109,19 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
|
||||
if (!lastDisplay.responded) {
|
||||
lastDisplay.responded = true;
|
||||
const previousConfig = config.get();
|
||||
let state = filterPublicSurveys({
|
||||
...previousConfig.state,
|
||||
displays,
|
||||
});
|
||||
config.update({
|
||||
...previousConfig,
|
||||
state: {
|
||||
...previousConfig.state,
|
||||
displays,
|
||||
},
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.get().state.person && config.get().state.person?.id) {
|
||||
surveyState.updatePersonId(config.get().state.person?.id!);
|
||||
if (config.get().state.person && config.get().state.person?.userId) {
|
||||
surveyState.updateUserId(config.get().state.person?.userId!);
|
||||
}
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
responseQueue.add({
|
||||
|
||||
@@ -79,7 +79,8 @@ export const mockInitResponse = () => {
|
||||
product: {
|
||||
noCodeEvents: [],
|
||||
brandColor: "#20b398",
|
||||
formbricksSignature: true,
|
||||
linkSurveyBranding: true,
|
||||
inAppBranding: true,
|
||||
placement: "bottomRight",
|
||||
darkOverlay: false,
|
||||
clickOutsideClose: true,
|
||||
@@ -115,19 +116,9 @@ export const mockSetEmailIdResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: { userId: initialUserId, email: initialUserEmail },
|
||||
},
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: { userId: initialUserId, email: initialUserEmail },
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -137,22 +128,12 @@ export const mockSetCustomAttributeResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
surveys: [],
|
||||
session: {
|
||||
id: sessionId,
|
||||
createdAt: "2021-03-09T15:00:00.000Z",
|
||||
updatedAt: "2021-03-09T15:00:00.000Z",
|
||||
expiresAt: expiryTime,
|
||||
},
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: initialUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
},
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: initialUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -163,16 +144,12 @@ export const mockUpdateEmailResponse = () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
surveys: [],
|
||||
noCodeActionClasses: [],
|
||||
person: {
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: updatedUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
},
|
||||
id: initialPersonUid,
|
||||
environmentId,
|
||||
attributes: {
|
||||
userId: initialUserId,
|
||||
email: updatedUserEmail,
|
||||
[customAttributeKey]: customAttributeValue,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ test("Formbricks should get the current person with no attributes", () => {
|
||||
expect(Object.keys(currentStatePersonAttributes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Formbricks should set email", async () => {
|
||||
/* test("Formbricks should set email", async () => {
|
||||
mockSetEmailIdResponse();
|
||||
await formbricks.setEmail(initialUserEmail);
|
||||
|
||||
@@ -112,7 +112,7 @@ test("Formbricks should update attribute", async () => {
|
||||
expect(email).toStrictEqual(updatedUserEmail);
|
||||
const customAttribute = currentStatePersonAttributes[customAttributeKey];
|
||||
expect(customAttribute).toStrictEqual(customAttributeValue);
|
||||
});
|
||||
}); */
|
||||
|
||||
test("Formbricks should track event", async () => {
|
||||
mockEventTrackResponse();
|
||||
|
||||
@@ -40,7 +40,6 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
|
||||
const action: TAction = {
|
||||
id: actionPrisma.id,
|
||||
createdAt: actionPrisma.createdAt,
|
||||
// sessionId: actionPrisma.sessionId,
|
||||
personId: actionPrisma.personId,
|
||||
properties: actionPrisma.properties,
|
||||
actionClass: actionPrisma.actionClass,
|
||||
@@ -71,6 +70,60 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
|
||||
: action;
|
||||
};
|
||||
|
||||
export const getLatestActionByPersonId = async (personId: string): Promise<TAction | null> => {
|
||||
const action = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([personId, ZId]);
|
||||
|
||||
try {
|
||||
const actionPrisma = await prisma.action.findFirst({
|
||||
where: {
|
||||
personId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!actionPrisma) {
|
||||
return null;
|
||||
}
|
||||
const action: TAction = {
|
||||
id: actionPrisma.id,
|
||||
createdAt: actionPrisma.createdAt,
|
||||
personId: actionPrisma.personId,
|
||||
properties: actionPrisma.properties,
|
||||
actionClass: actionPrisma.actionClass,
|
||||
};
|
||||
return action;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getLastestActionByPersonId-${personId}`],
|
||||
{
|
||||
tags: [actionCache.tag.byPersonId(personId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
// since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them
|
||||
// https://github.com/vercel/next.js/issues/51613
|
||||
return action
|
||||
? {
|
||||
...action,
|
||||
createdAt: new Date(action.createdAt),
|
||||
}
|
||||
: action;
|
||||
};
|
||||
|
||||
export const getActionsByPersonId = async (personId: string, page?: number): Promise<TAction[]> => {
|
||||
const actions = await unstable_cache(
|
||||
async () => {
|
||||
@@ -187,7 +240,7 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
|
||||
actionType = "automatic";
|
||||
}
|
||||
|
||||
const person = await getPersonByUserId(userId, environmentId);
|
||||
const person = await getPersonByUserId(environmentId, userId);
|
||||
|
||||
if (!person) {
|
||||
throw new Error("Person not found");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "../../apps/web/env.mjs";
|
||||
import { env } from "./env.mjs";
|
||||
import { verifyPassword } from "@/app/lib/auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "./constants";
|
||||
@@ -233,7 +233,7 @@ export const authOptions: NextAuthOptions = {
|
||||
create: [
|
||||
{
|
||||
type: "production",
|
||||
eventClasses: {
|
||||
actionClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
@@ -269,7 +269,7 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
{
|
||||
type: "development",
|
||||
eventClasses: {
|
||||
actionClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
|
||||
@@ -1,79 +1,71 @@
|
||||
import "server-only";
|
||||
import path from "path";
|
||||
|
||||
export const IS_FORMBRICKS_CLOUD = process.env.IS_FORMBRICKS_CLOUD === "1";
|
||||
import { env } from "./env.mjs";
|
||||
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
|
||||
export const REVALIDATION_INTERVAL = 0; //TODO: find a good way to cache and revalidate data when it changes
|
||||
export const SERVICES_REVALIDATION_INTERVAL = 60 * 30; // 30 minutes
|
||||
export const MAU_LIMIT = IS_FORMBRICKS_CLOUD ? 9000 : 1000000;
|
||||
|
||||
// URLs
|
||||
export const WEBAPP_URL =
|
||||
process.env.WEBAPP_URL ||
|
||||
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : false) ||
|
||||
"http://localhost:3000";
|
||||
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
|
||||
|
||||
export const SHORT_URL_BASE = process.env.SHORT_URL_BASE ? process.env.SHORT_URL_BASE : WEBAPP_URL;
|
||||
export const SHORT_URL_BASE = env.SHORT_URL_BASE ? env.SHORT_URL_BASE : WEBAPP_URL;
|
||||
|
||||
// encryption keys
|
||||
export const FORMBRICKS_ENCRYPTION_KEY = process.env.FORMBRICKS_ENCRYPTION_KEY || undefined;
|
||||
export const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
|
||||
export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined;
|
||||
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||
|
||||
// Other
|
||||
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || "";
|
||||
export const CRON_SECRET = process.env.CRON_SECRET;
|
||||
export const CRON_SECRET = env.CRON_SECRET;
|
||||
export const DEFAULT_BRAND_COLOR = "#64748b";
|
||||
|
||||
export const PRIVACY_URL = process.env.PRIVACY_URL;
|
||||
export const TERMS_URL = process.env.TERMS_URL;
|
||||
export const IMPRINT_URL = process.env.IMPRINT_URL;
|
||||
export const PRIVACY_URL = env.PRIVACY_URL;
|
||||
export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
|
||||
export const PASSWORD_RESET_DISABLED = process.env.PASSWORD_RESET_DISABLED === "1";
|
||||
export const EMAIL_VERIFICATION_DISABLED = process.env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
export const GOOGLE_OAUTH_ENABLED = process.env.GOOGLE_AUTH_ENABLED === "1";
|
||||
export const GITHUB_OAUTH_ENABLED = process.env.GITHUB_AUTH_ENABLED === "1";
|
||||
export const AZURE_OAUTH_ENABLED = process.env.AZUREAD_AUTH_ENABLED === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
export const GOOGLE_OAUTH_ENABLED = env.GOOGLE_AUTH_ENABLED === "1";
|
||||
export const GITHUB_OAUTH_ENABLED = env.GITHUB_AUTH_ENABLED === "1";
|
||||
export const AZURE_OAUTH_ENABLED = env.AZUREAD_AUTH_ENABLED === "1";
|
||||
|
||||
export const GITHUB_ID = process.env.GITHUB_ID;
|
||||
export const GITHUB_SECRET = process.env.GITHUB_SECRET;
|
||||
export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
||||
export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
|
||||
export const GITHUB_ID = env.GITHUB_ID;
|
||||
export const GITHUB_SECRET = env.GITHUB_SECRET;
|
||||
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
|
||||
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
export const SIGNUP_ENABLED = process.env.SIGNUP_DISABLED !== "1";
|
||||
export const INVITE_DISABLED = process.env.INVITE_DISABLED === "1";
|
||||
export const SIGNUP_ENABLED = env.SIGNUP_DISABLED !== "1";
|
||||
export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_SHEETS_CLIENT_ID = process.env.GOOGLE_SHEETS_CLIENT_ID;
|
||||
export const GOOGLE_SHEETS_CLIENT_SECRET = process.env.GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
export const GOOGLE_SHEETS_REDIRECT_URL = process.env.GOOGLE_SHEETS_REDIRECT_URL;
|
||||
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
|
||||
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
export const GOOGLE_SHEETS_REDIRECT_URL = env.GOOGLE_SHEETS_REDIRECT_URL;
|
||||
|
||||
export const AIRTABLE_CLIENT_ID = process.env.AIRTABLE_CLIENT_ID;
|
||||
export const AIRTABLE_CLIENT_ID = env.AIRTABLE_CLIENT_ID;
|
||||
|
||||
export const SMTP_HOST = process.env.SMTP_HOST;
|
||||
export const SMTP_PORT = process.env.SMTP_PORT;
|
||||
export const SMTP_SECURE_ENABLED = process.env.SMTP_SECURE_ENABLED === "1";
|
||||
export const SMTP_USER = process.env.SMTP_USER;
|
||||
export const SMTP_PASSWORD = process.env.SMTP_PASSWORD;
|
||||
export const MAIL_FROM = process.env.MAIL_FROM;
|
||||
export const SMTP_HOST = env.SMTP_HOST;
|
||||
export const SMTP_PORT = env.SMTP_PORT;
|
||||
export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1";
|
||||
export const SMTP_USER = env.SMTP_USER;
|
||||
export const SMTP_PASSWORD = env.SMTP_PASSWORD;
|
||||
export const MAIL_FROM = env.MAIL_FROM;
|
||||
|
||||
export const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
|
||||
export const NEXTAUTH_URL = process.env.NEXTAUTH_URL;
|
||||
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
|
||||
export const NEXTAUTH_URL = env.NEXTAUTH_URL;
|
||||
export const ITEMS_PER_PAGE = 50;
|
||||
export const RESPONSES_PER_PAGE = 10;
|
||||
export const TEXT_RESPONSES_PER_PAGE = 5;
|
||||
|
||||
// Storage constants
|
||||
export const UPLOADS_DIR = path.resolve("./uploads");
|
||||
export const UPLOADS_DIR = "./uploads";
|
||||
export const MAX_SIZES = {
|
||||
public: 1024 * 1024 * 10, // 10MB
|
||||
free: 1024 * 1024 * 10, // 10MB
|
||||
pro: 1024 * 1024 * 1024, // 1GB
|
||||
} as const;
|
||||
export const IS_S3_CONFIGURED: boolean =
|
||||
process.env.S3_ACCESS_KEY &&
|
||||
process.env.S3_SECRET_KEY &&
|
||||
process.env.S3_REGION &&
|
||||
process.env.S3_BUCKET_NAME
|
||||
? true
|
||||
: false;
|
||||
env.S3_ACCESS_KEY && env.S3_SECRET_KEY && env.S3_REGION && env.S3_BUCKET_NAME ? true : false;
|
||||
export const LOCAL_UPLOAD_URL = {
|
||||
public: new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href,
|
||||
private: new URL(`${WEBAPP_URL}/api/v1/client/storage/local`).href,
|
||||
@@ -82,5 +74,20 @@ export const LOCAL_UPLOAD_URL = {
|
||||
// Pricing
|
||||
export const PRICING_USERTARGETING_FREE_MTU = 2500;
|
||||
export const PRICING_APPSURVEYS_FREE_RESPONSES = 250;
|
||||
|
||||
// Rate Limiting
|
||||
export const SIGNUP_RATE_LIMIT = {
|
||||
interval: 60 * 60 * 1000, // 60 minutes
|
||||
allowedPerInterval: 5,
|
||||
};
|
||||
export const LOGIN_RATE_LIMIT = {
|
||||
interval: 15 * 60 * 1000, // 15 minutes
|
||||
allowedPerInterval: 5,
|
||||
};
|
||||
export const CLIENT_SIDE_API_RATE_LIMIT = {
|
||||
interval: 10 * 60 * 1000, // 60 minutes
|
||||
allowedPerInterval: 50,
|
||||
};
|
||||
|
||||
// Enterprise License constant
|
||||
export const ENTERPRISE_LICENSE_KEY = process.env.ENTERPRISE_LICENSE_KEY;
|
||||
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
TDisplay,
|
||||
TDisplayCreateInput,
|
||||
TDisplayLegacyCreateInput,
|
||||
TDisplayLegacyUpdateInput,
|
||||
TDisplayUpdateInput,
|
||||
ZDisplayCreateInput,
|
||||
ZDisplayLegacyCreateInput,
|
||||
ZDisplayLegacyUpdateInput,
|
||||
ZDisplayUpdateInput,
|
||||
} from "@formbricks/types/displays";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
@@ -20,6 +22,7 @@ import { getPersonByUserId } from "../person/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { displayCache } from "./cache";
|
||||
import { formatDisplaysDateFields } from "./util";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
|
||||
const selectDisplay = {
|
||||
id: true,
|
||||
@@ -30,11 +33,91 @@ const selectDisplay = {
|
||||
personId: true,
|
||||
};
|
||||
|
||||
export const getDisplay = async (displayId: string): Promise<TDisplay | null> =>
|
||||
await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([displayId, ZId]);
|
||||
|
||||
try {
|
||||
const responsePrisma = await prisma.response.findUnique({
|
||||
where: {
|
||||
id: displayId,
|
||||
},
|
||||
select: selectDisplay,
|
||||
});
|
||||
|
||||
return responsePrisma;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getDisplay-${displayId}`],
|
||||
{
|
||||
tags: [displayCache.tag.byId(displayId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
export const updateDisplay = async (
|
||||
displayId: string,
|
||||
displayInput: Partial<TDisplayUpdateInput>
|
||||
displayInput: TDisplayUpdateInput
|
||||
): Promise<TDisplay> => {
|
||||
validateInputs([displayInput, ZDisplayUpdateInput.partial()]);
|
||||
|
||||
let person: TPerson | null = null;
|
||||
if (displayInput.userId) {
|
||||
person = await getPersonByUserId(displayInput.environmentId, displayInput.userId);
|
||||
if (!person) {
|
||||
throw new ResourceNotFoundError("Person", displayInput.userId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
...(person?.id && {
|
||||
person: {
|
||||
connect: {
|
||||
id: person.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(displayInput.responseId && {
|
||||
responseId: displayInput.responseId,
|
||||
}),
|
||||
};
|
||||
const display = await prisma.display.update({
|
||||
where: {
|
||||
id: displayId,
|
||||
},
|
||||
data,
|
||||
select: selectDisplay,
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
surveyId: display.surveyId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDisplayLegacy = async (
|
||||
displayId: string,
|
||||
displayInput: TDisplayLegacyUpdateInput
|
||||
): Promise<TDisplay> => {
|
||||
validateInputs([displayInput, ZDisplayLegacyUpdateInput]);
|
||||
try {
|
||||
const data = {
|
||||
...(displayInput.personId && {
|
||||
@@ -77,7 +160,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
|
||||
try {
|
||||
let person;
|
||||
if (displayInput.userId) {
|
||||
person = await getPersonByUserId(displayInput.userId, displayInput.environmentId);
|
||||
person = await getPersonByUserId(displayInput.environmentId, displayInput.userId);
|
||||
}
|
||||
const display = await prisma.display.create({
|
||||
data: {
|
||||
@@ -152,7 +235,7 @@ export const createDisplayLegacy = async (displayInput: TDisplayLegacyCreateInpu
|
||||
}
|
||||
};
|
||||
|
||||
export const markDisplayResponded = async (displayId: string): Promise<TDisplay> => {
|
||||
export const markDisplayRespondedLegacy = async (displayId: string): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
/* import { config } from 'dotenv';
|
||||
config({ path: '../../.env' }); */
|
||||
|
||||
export const env = createEnv({
|
||||
/*
|
||||
@@ -22,14 +22,13 @@ import { validateInputs } from "../utils/validate";
|
||||
import { environmentCache } from "./cache";
|
||||
import { formatEnvironmentDateFields } from "./util";
|
||||
|
||||
export const getEnvironment = (environmentId: string) =>
|
||||
export const getEnvironment = (environmentId: string): Promise<TEnvironment | null> =>
|
||||
unstable_cache(
|
||||
async (): Promise<TEnvironment> => {
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
let environmentPrisma;
|
||||
|
||||
try {
|
||||
environmentPrisma = await prisma.environment.findUnique({
|
||||
return await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
@@ -42,16 +41,6 @@ export const getEnvironment = (environmentId: string) =>
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const environment = ZEnvironment.parse(environmentPrisma);
|
||||
return environment;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of environment failed");
|
||||
}
|
||||
},
|
||||
[`getEnvironment-${environmentId}`],
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "./env.mjs";
|
||||
|
||||
export function createToken(userId: string, userEmail: string, options = {}): string {
|
||||
return jwt.sign({ id: userId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
|
||||
@@ -12,28 +12,30 @@
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/api": "*",
|
||||
"@aws-sdk/client-s3": "3.433.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.433.0",
|
||||
"@aws-sdk/s3-presigned-post": "^3.454.0",
|
||||
"@aws-sdk/client-s3": "3.454.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.454.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"mime": "3.0.0",
|
||||
"@formbricks/api": "*",
|
||||
"@formbricks/database": "*",
|
||||
"@formbricks/types": "*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"aws-crt": "^1.18.1",
|
||||
"aws-crt": "^1.19.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"markdown-it": "^13.0.2",
|
||||
"nanoid": "^5.0.2",
|
||||
"next-auth": "^4.23.2",
|
||||
"nodemailer": "^6.9.6",
|
||||
"posthog-node": "^3.1.2",
|
||||
"nanoid": "^5.0.3",
|
||||
"next-auth": "^4.24.5",
|
||||
"nodemailer": "^6.9.7",
|
||||
"posthog-node": "^3.1.3",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwind-merge": "^1.14.0"
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "*",
|
||||
"@types/jsonwebtoken": "^9.0.3",
|
||||
"@types/mime": "3.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/mime": "3.0.4",
|
||||
"eslint-config-formbricks": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ export const canUserAccessPerson = async (userId: string, personId: string): Pro
|
||||
[`canUserAccessPerson-${userId}-people-${personId}`],
|
||||
{
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
tags: [personCache.tag.byId(personId), personCache.tag.byUserId(userId)],
|
||||
tags: [personCache.tag.byId(personId)],
|
||||
}
|
||||
)();
|
||||
|
||||
@@ -14,11 +14,8 @@ export const personCache = {
|
||||
byEnvironmentId(environmentId: string): string {
|
||||
return `environments-${environmentId}-people`;
|
||||
},
|
||||
byUserId(userId: string): string {
|
||||
return `users-${userId}-people`;
|
||||
},
|
||||
byEnvironmentIdAndUserId(environmentId: string, userId: string): string {
|
||||
return `environments-${environmentId}-users-${userId}-people`;
|
||||
return `environments-${environmentId}-personByUserId-${userId}`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId, userId }: RevalidateProps): void {
|
||||
@@ -26,16 +23,12 @@ export const personCache = {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
revalidateTag(this.tag.byUserId(userId));
|
||||
}
|
||||
|
||||
if (environmentId && userId) {
|
||||
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { unstable_cache } from "next/cache";
|
||||
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { personCache } from "./cache";
|
||||
import { createAttributeClass, getAttributeClassByName } from "../attributeClass/service";
|
||||
|
||||
export const selectPerson = {
|
||||
id: true,
|
||||
@@ -180,7 +181,8 @@ export const createPerson = async (environmentId: string, userId: string): Promi
|
||||
|
||||
personCache.revalidate({
|
||||
id: transformedPerson.id,
|
||||
environmentId: transformedPerson.environmentId,
|
||||
environmentId,
|
||||
userId,
|
||||
});
|
||||
|
||||
return transformedPerson;
|
||||
@@ -224,20 +226,62 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
|
||||
validateInputs([personId, ZId], [personInput, ZPersonUpdateInput]);
|
||||
|
||||
try {
|
||||
const person = await prisma.person.update({
|
||||
where: {
|
||||
id: personId,
|
||||
},
|
||||
data: personInput,
|
||||
select: selectPerson,
|
||||
const person = await getPerson(personId);
|
||||
if (!person) {
|
||||
throw new Error(`Person ${personId} not found`);
|
||||
}
|
||||
|
||||
// Process each attribute
|
||||
const attributeUpdates = Object.entries(personInput.attributes).map(async ([attributeName, value]) => {
|
||||
let attributeClass = await getAttributeClassByName(person.environmentId, attributeName);
|
||||
|
||||
// Create new attribute class if not found
|
||||
if (attributeClass === null) {
|
||||
attributeClass = await createAttributeClass(person.environmentId, attributeName, "code");
|
||||
}
|
||||
|
||||
// Now perform the upsert for the attribute with the found or created attributeClassId
|
||||
await prisma.attribute.upsert({
|
||||
where: {
|
||||
attributeClassId_personId: {
|
||||
attributeClassId: attributeClass!.id,
|
||||
personId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: value.toString(),
|
||||
},
|
||||
create: {
|
||||
attributeClass: {
|
||||
connect: {
|
||||
id: attributeClass!.id,
|
||||
},
|
||||
},
|
||||
person: {
|
||||
connect: {
|
||||
id: personId,
|
||||
},
|
||||
},
|
||||
value: value.toString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Execute all attribute updates
|
||||
await Promise.all(attributeUpdates);
|
||||
|
||||
personCache.revalidate({
|
||||
id: personId,
|
||||
environmentId: person.environmentId,
|
||||
});
|
||||
|
||||
return transformPrismaPerson(person);
|
||||
const updatedPerson = await getPerson(personId);
|
||||
|
||||
if (!updatedPerson) {
|
||||
throw new Error(`Person ${personId} not found`);
|
||||
}
|
||||
|
||||
return updatedPerson;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -247,10 +291,10 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
|
||||
}
|
||||
};
|
||||
|
||||
export const getPersonByUserId = async (userId: string, environmentId: string): Promise<TPerson | null> => {
|
||||
const personPrisma = await unstable_cache(
|
||||
export const getPersonByUserId = async (environmentId: string, userId: string): Promise<TPerson | null> =>
|
||||
await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZString], [environmentId, ZId]);
|
||||
validateInputs([environmentId, ZId], [userId, ZString]);
|
||||
|
||||
// check if userId exists as a column
|
||||
const personWithUserId = await prisma.person.findFirst({
|
||||
@@ -262,7 +306,7 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
|
||||
});
|
||||
|
||||
if (personWithUserId) {
|
||||
return personWithUserId;
|
||||
return transformPrismaPerson(personWithUserId);
|
||||
}
|
||||
|
||||
// Check if a person with the userId attribute exists
|
||||
@@ -304,57 +348,13 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
|
||||
|
||||
personCache.revalidate({
|
||||
id: personWithUserIdAttribute.id,
|
||||
environmentId: personWithUserIdAttribute.environmentId,
|
||||
environmentId,
|
||||
userId,
|
||||
});
|
||||
|
||||
return personWithUserIdAttribute;
|
||||
return transformPrismaPerson(personWithUserIdAttribute);
|
||||
},
|
||||
[`getPersonByUserId-${userId}-${environmentId}`],
|
||||
{
|
||||
tags: [personCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
if (!personPrisma) {
|
||||
return null;
|
||||
}
|
||||
return transformPrismaPerson(personPrisma);
|
||||
};
|
||||
|
||||
export const getOrCreatePersonByUserId = async (userId: string, environmentId: string): Promise<TPerson> =>
|
||||
await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZString], [environmentId, ZId]);
|
||||
|
||||
let person = await getPersonByUserId(userId, environmentId);
|
||||
|
||||
if (person) {
|
||||
return person;
|
||||
}
|
||||
|
||||
// create a new person
|
||||
const personPrisma = await prisma.person.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
userId,
|
||||
},
|
||||
select: selectPerson,
|
||||
});
|
||||
|
||||
personCache.revalidate({
|
||||
id: personPrisma.id,
|
||||
environmentId: personPrisma.environmentId,
|
||||
userId,
|
||||
});
|
||||
|
||||
return transformPrismaPerson(personPrisma);
|
||||
},
|
||||
[`getOrCreatePersonByUserId-${userId}-${environmentId}`],
|
||||
[`getPersonByUserId-${environmentId}-${userId}`],
|
||||
{
|
||||
tags: [personCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
|
||||
export const getPersonIdentifier = (person: TPerson): string | number | null => {
|
||||
return person?.attributes?.userId || person?.attributes?.email || person?.id || null;
|
||||
return person?.userId || person?.attributes?.userId || person?.attributes?.email || person?.id || null;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user