mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
Merge branch 'main' of github.com:formbricks/formbricks into feature/integrations
This commit is contained in:
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -17,3 +17,10 @@ A clear and concise description of any alternative solutions or features you've
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
### How we code at Formbricks 🤓
|
||||
|
||||
- Everything is type-safe
|
||||
- All UI components are in the package `formbricks/ui`
|
||||
- Run `pnpm dev` to find a demo app to test in-app surveys at `localhost:3002`
|
||||
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right context before you write your prompt.
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -31,6 +31,8 @@ Fixes # (issue)
|
||||
|
||||
<!-- We're starting to get more and more contributions. Please help us making this efficient for all of us and go through this checklist. Please tick off what you did -->
|
||||
|
||||
- [ ] Added a screen recording or screenshots to this PR
|
||||
- [ ] Filled out the "How to test" section in this PR
|
||||
- [ ] Read the [contributing guide](https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md)
|
||||
- [ ] Self-reviewed my own code
|
||||
- [ ] Commented on my code in hard-to-understand bits
|
||||
|
||||
4
LICENSE
4
LICENSE
@@ -3,7 +3,7 @@ Copyright (c) 2023 Matthias Nannt, Johannes Dancker
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "packages/ee/" directory of this repository, if that directory exists, is licensed under the license defined in "packages/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/" directory of this repository, if that directory exists, is licensed under the "MIT" license as defined in "packages/js/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/errors/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
@@ -67,7 +67,7 @@ modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
1. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
|
||||
94
README.md
94
README.md
@@ -12,67 +12,89 @@
|
||||
</p>
|
||||
|
||||
<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://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://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>
|
||||
|
||||
<br/>
|
||||
|
||||
## About Formbricks
|
||||
<p align="center">
|
||||
<i>Trusted by</i>
|
||||
<a href="https://github.com/calcom/cal.com/"><img src="https://github.com/formbricks/formbricks/assets/675065/1a8763cf-f47e-4960-90f6-334f6dc12a17#gh-light-mode-only" height="20px"></a><a href="https://github.com/calcom/cal.com/"><img src="https://github.com/formbricks/formbricks/assets/72809645/9a031e8d-538f-4fdc-9338-b77e9a57d6ac#gh-dark-mode-only" height="20px"></a>
|
||||
<a href="https://github.com/CrowdDotDev/crowd.dev"><img src="https://github.com/formbricks/formbricks/assets/675065/59b1a4d4-25e4-4ef3-b0bf-4426446fbfd0#gh-light-mode-only" height="20px"></a><a href="https://github.com/CrowdDotDev/crowd.dev"><img src="https://github.com/formbricks/formbricks/assets/72809645/4bb4caf7-4b64-44c8-94bd-850606d181c1#gh-dark-mode-only" height="20px"></a>
|
||||
<a href="https://clovyr.io/"><img src="https://github.com/formbricks/formbricks/assets/675065/9291c8df-9aac-423a-a430-a9a581240075" height="20px"></a>
|
||||
<a href="https://neverinstall.com/"><img src="https://github.com/formbricks/formbricks/assets/675065/72e5e37b-8ef7-4340-b06e-f1d12a05330f#gh-light-mode-only" height="20px"></a><a href="https://neverinstall.com/"><img src="https://github.com/formbricks/formbricks/assets/72809645/9d9711dc-75e5-4084-b7fa-bbaf621064a8#gh-dark-mode-only" height="20px">
|
||||
</p>
|
||||
|
||||
<img width="1527" alt="formbricks-sneak" src="https://user-images.githubusercontent.com/675065/227726212-6ebf930e-6a20-4ffa-b966-56cd41bdf363.png">
|
||||
## ✨ About Formbricks
|
||||
|
||||
Formbricks productizes best practices for qualitative in-app user discovery. Use micro-surveys to target the right users at the right time without making surveys annoying.
|
||||
<img width="1527" alt="formbricks-sneak" src="https://github-production-user-asset-6210df.s3.amazonaws.com/675065/249441967-ccb89ea3-82b4-4bf2-8d2c-528721ec313b.png">
|
||||
|
||||
Formbricks is your go-to solution for in-product micro-surveys that will supercharge your product experience. Use micro-surveys to target the right users at the right time without making surveys annoying.
|
||||
|
||||
**Try it out in the cloud at [formbricks.com](https://formbricks.com)**
|
||||
|
||||
### Mission: Base your decisions on qualitative data.
|
||||
## 💪 Mission: Make customer-centric decisions based on data.
|
||||
|
||||
Formbricks helps you apply best practices from data-driven work and experience management to make better business decisions. Use Formbricks to collect and manage insights from your users; run a product market fit survey to know which audience to focus on and whether your value proposition is being recognized.
|
||||
Formbricks helps you apply best practices from data-driven work and experience management to make better business decisions. Ask users as they experience your product - and leverage a significantly higher conversion rate. Gather all insights you can - including partial submissions and build conviction for the next product decision. Better data, better business.
|
||||
|
||||
### Features
|
||||
|
||||
- 📲 Create in-product surveys with our no code editor with multiple question types
|
||||
- 📚 Choose from a variety of best-practice templates
|
||||
- 👩🏻 Launch and target your surveys to specific user groups without changing your application code
|
||||
- 🔗 Create shareable link surveys
|
||||
- 👨👩👦 Invite your team members to collaborate on your surveys
|
||||
- 🔌 Integrate Formbricks with Slack, Posthog, Zapier and more
|
||||
- 🔒 All open source, transparent and self-hostable
|
||||
- 📲 Create **in-product surveys** with our no code editor with multiple question types
|
||||
- 📚 Choose from a variety of best-practice **templates**
|
||||
- 👩🏻 Launch and **target your surveys to specific user groups** without changing your application code
|
||||
- 🔗 Create shareable **link surveys**
|
||||
- 👨👩👦 Invite your team members to **collaborate** on your surveys
|
||||
- 🔌 Integrate Formbricks with **Slack, Posthog, Zapier and more**
|
||||
- 🔒 All **open source**, transparent and self-hostable
|
||||
|
||||
### Built With
|
||||
### Built on Open Source
|
||||
|
||||
- [Typescript](https://www.typescriptlang.org/)
|
||||
- [Next.js](https://nextjs.org/)
|
||||
- [React](https://reactjs.org/)
|
||||
- [TailwindCSS](https://tailwindcss.com/)
|
||||
- [Prisma](https://prisma.io/)
|
||||
- 💻 [Typescript](https://www.typescriptlang.org/)
|
||||
- 🚀 [Next.js](https://nextjs.org/)
|
||||
- ⚛️ [React](https://reactjs.org/)
|
||||
- 🎨 [TailwindCSS](https://tailwindcss.com/)
|
||||
- 📚 [Prisma](https://prisma.io/)
|
||||
- 🔒 [Auth.js](https://authjs.dev/)
|
||||
- 🧘♂️ [Zod](https://zod.dev/)
|
||||
|
||||
### Upcoming Features
|
||||
## 🚀 Getting started
|
||||
|
||||
| | Feature |
|
||||
| --- | ------------------------------------------ |
|
||||
| 👷 | Zapier, Slack & Posthog Integration |
|
||||
| 👷 | Webhooks |
|
||||
| 🗒️ | Filtering Options in Survey Analysis |
|
||||
| 🗒️ | Multi-Language Functionality |
|
||||
| 🗒️ | Auto-complete Surveys after at x responses |
|
||||
| 🗒️ | Pre-Fill Link-Surveys |
|
||||
| 🗒️ | E-Mail Surveys |
|
||||
### ☁️ Cloud Version
|
||||
|
||||
_👷 In Progress | 🗒️ Up Next_
|
||||
Formbricks has a hosted cloud offering with a generous free plan to get you up and running as quickly as possible. To get started, please visit [formbricks.com](https://formbricks.com)
|
||||
|
||||
## Cloud vs. self-hosted
|
||||
### 🐳 Self-hosted version
|
||||
|
||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers without a subscription. Check out our [docs](https://formbricks.com/docs/self-hosting/deployment) to see how to self-host Formbricks.
|
||||
|
||||
We also have a hosted cloud offering with a generous free plan to get you up and running as quickly as possible. For more information, please visit [formbricks.com](https://formbricks.com)
|
||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription. To get started with self-hosting, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||
|
||||
(In the future we may develop additional features that aren't in the free Open-Source version)
|
||||
|
||||
## Contributing
|
||||
## ✍️ Contribution
|
||||
|
||||
We are very happy if you are interested in contributing to Formbricks 🤗
|
||||
|
||||
There are many ways to contribute to Formbricks with writing Issues, fixing bugs, building new features or updating the docs. Please check out [our contribution guide](https://formbricks.com/docs/contributing/introduction) for more information.
|
||||
Here are a few options:
|
||||
|
||||
- Star this repo
|
||||
- Create issues every time you feel something is missing or goes wrong
|
||||
- Upvote issues with 👍 reaction so we know what's the demand for particular issue to prioritize it within roadmap
|
||||
|
||||
Please check out [our contribution guide](https://formbricks.com/docs/contributing/introduction) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
|
||||
|
||||
## 📆 Contact us
|
||||
|
||||
Let's have a chat about your survey needs and get you started.
|
||||
|
||||
<a href="https://cal.com/johannes/onboarding?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
Distributed under the AGPLv3 License. See `LICENSE` for more information.
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
We take security very seriously. If you come across any security vulnerabilities, please disclose them by sending an email to security@formbricks.com. We appreciate your help in making our platform as secure as possible and are committed to working with you to resolve any issues quickly and efficiently. See `SECURITY.md` for more information.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Console, Hook, Unhook } from "console-feed";
|
||||
|
||||
const LogsContainer = () => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
// run once!
|
||||
useEffect(() => {
|
||||
const hookedConsole = Hook(window.console, (log) => setLogs((currLogs) => [...currLogs, log]), false);
|
||||
return () => Unhook(hookedConsole);
|
||||
}, []);
|
||||
|
||||
useEffect(scrollToBottom, [logs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Console logs={logs} variant="light" />
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { LogsContainer };
|
||||
@@ -12,22 +12,20 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@heroicons/react": "^2.0.17",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.0.33",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"console-feed": "^3.5.0",
|
||||
"eslint": "8.37.0",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"next": "13.2.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"typescript": "5.0.3"
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.0.33",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.21",
|
||||
"rimraf": "^5.0.0",
|
||||
"tailwindcss": "^3.3.1"
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fbsetup from "../../public/fb-setup.png";
|
||||
import formbricks from "@formbricks/js";
|
||||
import Image from "next/image";
|
||||
import { LogsContainer } from "../../components/ConsoleFeed";
|
||||
|
||||
export default function AppPage({}) {
|
||||
return (
|
||||
@@ -22,13 +21,13 @@ export default function AppPage({}) {
|
||||
</p>
|
||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6">
|
||||
{/* <div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6">
|
||||
<h3 className="text-lg font-semibold">Console</h3>
|
||||
<p className="text-slate-700">You can also open your browser console to logs:</p>
|
||||
<div className="max-h-[40vh] overflow-y-auto py-4">
|
||||
<LogsContainer />
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
|
||||
@@ -45,6 +45,7 @@ export const customSurvey: Template = {
|
||||
headline: "Custom Survey",
|
||||
subheader: "This is an example survey.",
|
||||
placeholder: "Type your answer here...",
|
||||
longAnswer: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
@@ -116,12 +117,14 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "What type of people do you think would most benefit from Formbricks?",
|
||||
longAnswer: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "What is the main benefit your receive from Formbricks?",
|
||||
longAnswer: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -129,6 +132,7 @@ export const templates: Template[] = [
|
||||
type: QuestionType.OpenText,
|
||||
headline: "How can we improve our service for you?",
|
||||
subheader: "Please be as specific as possible.",
|
||||
longAnswer: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
@@ -298,6 +302,7 @@ export const templates: Template[] = [
|
||||
type: QuestionType.OpenText,
|
||||
headline: "Would you like to add something?",
|
||||
subheader: "Feel free to speak your mind, we do too.",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -387,6 +392,7 @@ export const templates: Template[] = [
|
||||
type: QuestionType.OpenText,
|
||||
headline: "How can we win you back?",
|
||||
subheader: "Feel free to speak your mind, we do too.",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -434,6 +440,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "Any details to share?",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
@@ -441,6 +448,7 @@ export const templates: Template[] = [
|
||||
type: QuestionType.OpenText,
|
||||
headline: "How are you solving your problem instead?",
|
||||
subheader: "Please name alternative tools:",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -549,6 +557,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "What did you come here to do today?",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -615,6 +624,7 @@ export const templates: Template[] = [
|
||||
type: QuestionType.OpenText,
|
||||
headline: "Wanna add something?",
|
||||
subheader: "This really helps us do better!",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -678,6 +688,7 @@ export const templates: Template[] = [
|
||||
type: QuestionType.OpenText,
|
||||
headline: "How can we improve our service for you?",
|
||||
subheader: "Please be as specific as possible.",
|
||||
longAnswer: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
@@ -713,6 +724,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "Give us the juicy details:",
|
||||
longAnswer: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
@@ -760,6 +772,7 @@ export const templates: Template[] = [
|
||||
type: QuestionType.OpenText,
|
||||
headline: "Which product would you like to integrate next?",
|
||||
subheader: "We keep building integrations. Yours can be next:",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -806,6 +819,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "If you chose other, please clarify:",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -840,12 +854,14 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "Please elaborate:",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "Page URL",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -974,6 +990,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "What's the #1 thing you'd like to change in Formbricks?",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -1002,6 +1019,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "What is one thing we could do better?",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -1040,6 +1058,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
type: QuestionType.OpenText,
|
||||
headline: "What’s missing or unclear to you about Formbricks?",
|
||||
longAnswer: true,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,10 +23,10 @@ export const GitHubSponsorship: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-800 dark:text-slate-200 lg:text-2xl">
|
||||
Sponsored by GitHub
|
||||
Proudly Open-Source 🤍
|
||||
</h2>
|
||||
<p className="lg:text-md mt-4 max-w-3xl text-slate-500 dark:text-slate-400">
|
||||
We're proud to join the first accelerator program by GitHub!{" "}
|
||||
We're proud to to be supported by GitHubs Open-Source Program!{" "}
|
||||
<span>
|
||||
<Link
|
||||
href="/blog/inaugural-batch-github-accelerator"
|
||||
|
||||
@@ -21,20 +21,15 @@ export const Hero: React.FC = ({}) => {
|
||||
<div className="relative">
|
||||
<div className="px-4 py-20 text-center sm:px-6 lg:px-8 lg:py-28">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
|
||||
<span className="xl:inline">Survey any segment.</span>{" "}
|
||||
<span
|
||||
className="font-extralight" /* className="from-brand-light to-brand-dark bg-gradient-to-b bg-clip-text text-transparent xl:inline" */
|
||||
>
|
||||
No coding required.
|
||||
</span>
|
||||
<span className="xl:inline">Create Products People Remember</span>
|
||||
</h1>
|
||||
|
||||
<p className="xs:max-w-none mx-auto mt-3 max-w-xs text-base text-slate-500 dark:text-slate-400 sm:text-lg md:mt-5 md:text-xl">
|
||||
Survey granular user segments at any point in the user journey.
|
||||
Understand what customers think & feel about your product.
|
||||
<br />
|
||||
<span className="hidden md:block">
|
||||
Gather up to 6x more insights with targeted micro-surveys.{" "}
|
||||
<span className="decoration-brand-dark underline underline-offset-4">All open-source.</span>
|
||||
Continuously gather deep user insights,{" "}
|
||||
<span className="decoration-brand-dark underline underline-offset-4">all privacy-first.</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -48,9 +48,7 @@ export default function Footer() {
|
||||
<span className="sr-only">Formbricks</span>
|
||||
<FooterLogo className="mx-auto h-8 w-auto sm:h-10" />
|
||||
</Link>
|
||||
<p className="text-base text-slate-500 dark:text-slate-400">
|
||||
Make customer-centric decisions based on data.
|
||||
</p>
|
||||
<p className="text-base text-slate-500 dark:text-slate-400">Privacy-first Experience Management</p>
|
||||
<div className="border-slate-500">
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">
|
||||
© 2022. All rights reserved.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import GitHubMarkWhite from "@/images/github-mark-white.svg";
|
||||
import GitHubMarkDark from "@/images/github-mark.svg";
|
||||
import {
|
||||
BaseballIcon,
|
||||
Button,
|
||||
@@ -11,9 +13,9 @@ import {
|
||||
} from "@formbricks/ui";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Bars3Icon, ChevronDownIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { StarIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment, useState } from "react";
|
||||
@@ -269,9 +271,9 @@ export default function Header() {
|
||||
</Link> */}
|
||||
|
||||
<Link
|
||||
href="/community"
|
||||
href="/concierge"
|
||||
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Community
|
||||
Concierge
|
||||
</Link>
|
||||
</Popover.Group>
|
||||
<div className="hidden flex-1 items-center justify-end md:flex">
|
||||
@@ -281,21 +283,24 @@ export default function Header() {
|
||||
className="group px-2"
|
||||
href="https://formbricks.com/github"
|
||||
target="_blank">
|
||||
<StarIcon className="h-6 w-6 text-amber-500 group-hover:text-amber-400" />
|
||||
<Image
|
||||
src={GitHubMarkDark}
|
||||
alt="GitHub Sponsors Formbricks badge"
|
||||
width={24}
|
||||
className="block dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
src={GitHubMarkWhite}
|
||||
alt="GitHub Sponsors Formbricks badge"
|
||||
width={24}
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
</Button>
|
||||
{/* <Button variant="secondary" className="ml-2 px-2" onClick={() => setVideoModal(true)}>
|
||||
<VideoWalkThrough open={videoModal} setOpen={() => setVideoModal(false)} />
|
||||
<PlayCircleIcon className="h-6 w-6" />
|
||||
</Button> */}
|
||||
|
||||
{/* <Button
|
||||
variant="secondary"
|
||||
EndIcon={GitHubIcon}
|
||||
endIconClassName="fill-slate-800 ml-2 dark:fill-slate-200"
|
||||
href="https://github.com/formbricks/formbricks"
|
||||
target="_blank">
|
||||
View on Github
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="highlight"
|
||||
className="ml-2"
|
||||
@@ -363,7 +368,7 @@ export default function Header() {
|
||||
<hr className="mx-20 my-6 opacity-25" />
|
||||
</div>
|
||||
)}
|
||||
<Link href="/community">Community</Link>
|
||||
<Link href="/concierge">Concierge</Link>
|
||||
<Link href="#pricing">Pricing</Link>
|
||||
<Link href="/docs">Docs</Link>
|
||||
<Link href="/blog">Blog</Link>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from "@formbricks/ui";
|
||||
import clsx from "clsx";
|
||||
import EarlyBirdDeal from "./EarlyBirdDeal";
|
||||
import HeadingCentered from "./HeadingCentered";
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { usePlausible } from "next-plausible";
|
||||
@@ -55,7 +54,7 @@ const tiers = [
|
||||
priceMonthly: "$99",
|
||||
paymentRythm: "/month",
|
||||
button: "secondary",
|
||||
discounted: true,
|
||||
discounted: false,
|
||||
highlight: false,
|
||||
description: "All features included. Unlimited usage.",
|
||||
features: ["All features of Free plan", "Unlimited responses", "Remove branding"],
|
||||
@@ -154,9 +153,6 @@ export default function Pricing() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<EarlyBirdDeal />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -26,6 +26,11 @@ const nextConfig = {
|
||||
destination: "https://github.com/formbricks/formbricks",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/deal",
|
||||
destination: "/concierge",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/privacy",
|
||||
destination: "/privacy-policy",
|
||||
|
||||
@@ -11,44 +11,45 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docsearch/react": "^3.3.3",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@calcom/embed-react": "^1.1.1",
|
||||
"@docsearch/react": "^3.5.1",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@headlessui/react": "^1.7.14",
|
||||
"@heroicons/react": "^2.0.17",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@mapbox/rehype-prism": "^0.8.0",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "^13.3.0",
|
||||
"add": "^2.0.6",
|
||||
"@next/mdx": "^13.4.7",
|
||||
"@paralleldrive/cuid2": "^2.2.0",
|
||||
"clsx": "^1.2.1",
|
||||
"lottie-web": "^5.11.0",
|
||||
"next": "13.3.0",
|
||||
"next-plausible": "^3.7.2",
|
||||
"next-sitemap": "^4.0.7",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"lottie-web": "^5.12.2",
|
||||
"next": "13.4.7",
|
||||
"next-plausible": "^3.8.0",
|
||||
"next-sitemap": "^4.1.3",
|
||||
"prism-react-renderer": "^2.0.6",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-responsive-embed": "^2.1.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.32.0"
|
||||
"sharp": "^0.32.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/node": "20.3.2",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/react": "^18.0.35",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react": "18.2.7",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"postcss": "^8.4.22",
|
||||
"rimraf": "^5.0.0",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "^5.0.4"
|
||||
"postcss": "^8.4.24",
|
||||
"rimraf": "^5.0.1",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
102
apps/formbricks-com/pages/concierge.tsx
Normal file
102
apps/formbricks-com/pages/concierge.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import HeroTitle from "@/components/shared/HeroTitle";
|
||||
import Layout from "@/components/shared/Layout";
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
import { useEffect } from "react";
|
||||
import { CheckBadgeIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
const XMOffer = [
|
||||
{
|
||||
step: "1",
|
||||
header: "Kick-off call",
|
||||
description: "You share with our seasoned PMs which areas of your customer experience need improvement.",
|
||||
},
|
||||
{
|
||||
step: "2",
|
||||
header: "In-depth analysis",
|
||||
description: "With a fresh pair of eyes, we analyze your customer experience to uncover potential.",
|
||||
},
|
||||
{
|
||||
step: "3",
|
||||
header: "Research design",
|
||||
description: "We set up systems for continuous discovery. Benefit from an ongoing stream of insights.",
|
||||
},
|
||||
{
|
||||
step: "4",
|
||||
header: "Setup assistance",
|
||||
description: "Our core developers help you get Formbricks up and running in no more than 60 minutes.",
|
||||
},
|
||||
{
|
||||
step: "5",
|
||||
header: "Actionable insights",
|
||||
description:
|
||||
"Once the results are in, we perform a thorough analysis and derive concrete Next Action Steps to retain your customers better.",
|
||||
},
|
||||
];
|
||||
|
||||
const ConciergePage = () => {
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
cal("ui", {
|
||||
theme: "light",
|
||||
styles: { branding: { brandColor: "#000000" } },
|
||||
hideEventTypeDetails: false,
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Community | Formbricks Open Source Forms & Surveys"
|
||||
description="You're building open source forms and surveys? So are we! Get support for anything your building - or just say hi!">
|
||||
<HeroTitle
|
||||
headingPt1="XM"
|
||||
headingTeal="Concierge"
|
||||
headingPt2="Service"
|
||||
subheading="Let's set up your system for continuous user discovery together."
|
||||
/>
|
||||
<div className="-mt-16 grid grid-cols-1 space-y-4 px-4 md:grid-cols-2 md:gap-8 md:px-16">
|
||||
<div className="rounded-xl bg-slate-100 p-12">
|
||||
{XMOffer.map((offer) => (
|
||||
<div key={offer.step} className="mb-8 flex items-center gap-x-4">
|
||||
<div className=" flex items-center justify-center rounded-full bg-emerald-50 p-4 text-2xl font-bold text-emerald-700">
|
||||
{offer.step}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-700">{offer.header}</h4>
|
||||
<p className="text-sm text-slate-800">{offer.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-b border-t border-slate-300 p-6 text-4xl font-semibold text-slate-800">
|
||||
<p className="mr-2 font-light">$2.290</p>
|
||||
</div>
|
||||
<div className="p-6 text-sm text-slate-800">
|
||||
<p>
|
||||
<CheckBadgeIcon className="mr-1 inline h-5 w-5 text-slate-800" />
|
||||
100% Risk-free: Pay after the kick-off call.
|
||||
</p>
|
||||
<p>
|
||||
<CheckBadgeIcon className="mr-1 inline h-5 w-5 text-slate-800" />
|
||||
Money-back: If you're not happy, get a full refund.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl">
|
||||
<Cal
|
||||
calLink="johannes/kick-off"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "scroll",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
config={{ layout: "month_view" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConciergePage;
|
||||
@@ -10,7 +10,7 @@ import BestPractices from "@/components/shared/BestPractices";
|
||||
|
||||
const IndexPage = () => (
|
||||
<Layout
|
||||
title="Formbricks | Embedded User Research"
|
||||
title="Formbricks | Privacy-first user research"
|
||||
description="Build qualitative user research into your product. Leverage Best practices to increase Product-Market Fit.">
|
||||
<Hero />
|
||||
<div className="hidden lg:block">
|
||||
|
||||
@@ -50,7 +50,7 @@ To get the project running locally on your machine you need to have the followin
|
||||
1. Make sure your PostgreSQL Database Server is running. Then let prisma set up the database for you:
|
||||
|
||||
```sh
|
||||
pnpm dlx prisma migrate dev
|
||||
pnpm prisma migrate dev
|
||||
```
|
||||
|
||||
1. Start the development server:
|
||||
|
||||
11
apps/web/app/ClientLogout.tsx
Normal file
11
apps/web/app/ClientLogout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ClientLogout() {
|
||||
useEffect(() => {
|
||||
signOut();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const { sessionId, environmentId, eventName, properties } = await request.json();
|
||||
|
||||
if (!sessionId) {
|
||||
return responses.missingFieldResponse("sessionId", true);
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.missingFieldResponse("environmentId", true);
|
||||
}
|
||||
|
||||
if (!eventName) {
|
||||
return responses.missingFieldResponse("eventName", true);
|
||||
}
|
||||
|
||||
const action = await prisma.event.create({
|
||||
data: {
|
||||
properties,
|
||||
session: {
|
||||
connect: {
|
||||
id: sessionId,
|
||||
},
|
||||
},
|
||||
eventClass: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
name_environmentId: {
|
||||
name: eventName,
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: eventName,
|
||||
type: "code",
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return responses.successResponse(action, true);
|
||||
}
|
||||
44
apps/web/app/api/v1/client/people/getOrCreate/route.ts
Normal file
44
apps/web/app/api/v1/client/people/getOrCreate/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { createPersonWithUser } from "@/lib/api/clientPerson";
|
||||
|
||||
export async function GET(req: Request): Promise<NextResponse> {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const userId = searchParams.get("userId");
|
||||
if (!userId) {
|
||||
return responses.badRequestResponse("Fields are missing or incorrectly formatted", { userId: "" }, true);
|
||||
}
|
||||
const environmentId = searchParams.get("environmentId");
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
{ environmentId: "" },
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const person = await prisma.person.findFirst({
|
||||
where: {
|
||||
environmentId,
|
||||
attributes: {
|
||||
some: {
|
||||
attributeClass: {
|
||||
name: "userId",
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!person) {
|
||||
const newPerson = await createPersonWithUser(environmentId, userId);
|
||||
return responses.successResponse({ person: newPerson }, true);
|
||||
}
|
||||
return responses.successResponse({ person }, true);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { createSession } from "@/lib/api/clientSession";
|
||||
import { getSettings } from "@/lib/api/clientSettings";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const { personId, environmentId } = await request.json();
|
||||
|
||||
if (!personId) {
|
||||
return responses.missingFieldResponse("sessionId", true);
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.missingFieldResponse("environmentId", true);
|
||||
}
|
||||
|
||||
const session = await createSession(personId);
|
||||
const { surveys, noCodeEvents, brandColor } = await getSettings(environmentId, personId);
|
||||
|
||||
captureTelemetry("session created");
|
||||
|
||||
return responses.successResponse({ session, surveys, noCodeEvents, brandColor }, true);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { getSettings } from "@/lib/api/clientSettings";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const { userCuid } = await request.json();
|
||||
|
||||
if (!userCuid) {
|
||||
return responses.missingFieldResponse("userCuid", true);
|
||||
}
|
||||
|
||||
// get user
|
||||
const user = await prisma.person.findUnique({
|
||||
where: {
|
||||
id: userCuid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return responses.notFoundResponse("User", userCuid, true);
|
||||
}
|
||||
|
||||
const { surveys, noCodeEvents, brandColor } = await getSettings(user.environmentId, user.id);
|
||||
|
||||
captureTelemetry("session created");
|
||||
|
||||
return responses.successResponse({ surveys, noCodeEvents, brandColor }, true);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { getSettings } from "@/lib/api/clientSettings";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, params: { userCuid: string }): Promise<NextResponse> {
|
||||
const { userCuid } = params;
|
||||
const { key, value } = await request.json();
|
||||
|
||||
if (!key) {
|
||||
return responses.missingFieldResponse("key", true);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return responses.missingFieldResponse("value", true);
|
||||
}
|
||||
|
||||
const currentPerson = await prisma.person.findUnique({
|
||||
where: {
|
||||
id: userCuid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentPerson) {
|
||||
return responses.notFoundResponse("User", userCuid, true);
|
||||
}
|
||||
|
||||
const environmentId = currentPerson.environmentId;
|
||||
|
||||
// find attribute class
|
||||
let attributeClass = await prisma.attributeClass.findUnique({
|
||||
where: {
|
||||
name_environmentId: {
|
||||
name: key,
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
// create new attribute class if not found
|
||||
if (attributeClass === null) {
|
||||
attributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: key,
|
||||
type: "code",
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// upsert attribute (update or create)
|
||||
const attribute = await prisma.attribute.upsert({
|
||||
where: {
|
||||
attributeClassId_personId: {
|
||||
attributeClassId: attributeClass.id,
|
||||
personId: userCuid,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value,
|
||||
},
|
||||
create: {
|
||||
attributeClass: {
|
||||
connect: {
|
||||
id: attributeClass.id,
|
||||
},
|
||||
},
|
||||
person: {
|
||||
connect: {
|
||||
id: userCuid,
|
||||
},
|
||||
},
|
||||
value,
|
||||
},
|
||||
select: {
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = attribute.person;
|
||||
|
||||
const { surveys, noCodeEvents, brandColor } = await getSettings(environmentId, user.id);
|
||||
|
||||
return responses.successResponse({ user, surveys, noCodeEvents, brandColor }, true);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { getSettings } from "@/lib/api/clientSettings";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, params: { userCuid: string }): Promise<NextResponse> {
|
||||
const { userCuid } = params;
|
||||
const { userId, sessionId } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return responses.missingFieldResponse("userId", true);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return responses.missingFieldResponse("sessionId", true);
|
||||
}
|
||||
|
||||
let returnedUser;
|
||||
|
||||
// find person
|
||||
const person = await prisma.person.findUnique({
|
||||
where: {
|
||||
id: userCuid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!person) {
|
||||
return responses.notFoundResponse("User", userCuid, true);
|
||||
}
|
||||
|
||||
const environmentId = person.environmentId;
|
||||
|
||||
// check if person with this userId already exists
|
||||
const existingPerson = await prisma.person.findFirst({
|
||||
where: {
|
||||
environmentId,
|
||||
attributes: {
|
||||
some: {
|
||||
attributeClass: {
|
||||
name: "userId",
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// if person exists, reconnect ression and delete old user
|
||||
if (existingPerson) {
|
||||
// reconnect session to new person
|
||||
await prisma.session.update({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
data: {
|
||||
person: {
|
||||
connect: {
|
||||
id: existingPerson.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// delete old person
|
||||
await prisma.person.delete({
|
||||
where: {
|
||||
id: userCuid,
|
||||
},
|
||||
});
|
||||
returnedUser = existingPerson;
|
||||
} else {
|
||||
// update person
|
||||
returnedUser = await prisma.person.update({
|
||||
where: {
|
||||
id: userCuid,
|
||||
},
|
||||
data: {
|
||||
attributes: {
|
||||
create: {
|
||||
value: userId,
|
||||
attributeClass: {
|
||||
connect: {
|
||||
name_environmentId: {
|
||||
name: "userId",
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { surveys, noCodeEvents, brandColor } = await getSettings(environmentId, returnedUser.id);
|
||||
|
||||
return responses.successResponse({ user: returnedUser, surveys, noCodeEvents, brandColor }, true);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { createPerson } from "@/lib/api/clientPerson";
|
||||
import { createSession } from "@/lib/api/clientSession";
|
||||
import { getSettings } from "@/lib/api/clientSettings";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const { environmentId } = await request.json();
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.missingFieldResponse("environmentId", true);
|
||||
}
|
||||
|
||||
const user = await createPerson(environmentId);
|
||||
const session = await createSession(user.id);
|
||||
const { surveys, noCodeEvents, brandColor } = await getSettings(environmentId, user.id);
|
||||
|
||||
return responses.successResponse({ user, session, surveys, noCodeEvents, brandColor }, true);
|
||||
}
|
||||
32
apps/web/app/api/v1/responses/route.ts
Normal file
32
apps/web/app/api/v1/responses/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
|
||||
import { getEnvironmentResponses } from "@formbricks/lib/services/response";
|
||||
import { headers } from "next/headers";
|
||||
import { DatabaseError } from "@formbricks/errors";
|
||||
|
||||
export async function GET() {
|
||||
const apiKey = headers().get("x-api-key");
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
let apiKeyData;
|
||||
try {
|
||||
apiKeyData = await getApiKeyFromKey(apiKey);
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
} catch (error) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
// get webhooks from database
|
||||
try {
|
||||
const environmentResponses = await getEnvironmentResponses(apiKeyData.environmentId);
|
||||
return responses.successResponse(environmentResponses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
PlusIcon,
|
||||
UserCircleIcon,
|
||||
UsersIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -62,6 +63,8 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddProductModal from "./AddProductModal";
|
||||
import { formbricksLogout } from "@/lib/formbricks";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
|
||||
interface EnvironmentsNavbarProps {
|
||||
environmentId: string;
|
||||
@@ -163,7 +166,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
icon: CreditCardIcon,
|
||||
label: "Billing & Plan",
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1",
|
||||
hidden: IS_FORMBRICKS_CLOUD,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -421,6 +424,19 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
onClick={() => {
|
||||
formbricks.track("Top Menu: Product Feedback");
|
||||
}}>
|
||||
<div className="flex items-center">
|
||||
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
|
||||
<span>Product Feedback</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -49,16 +49,18 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
|
||||
const freeFeatures = [
|
||||
"Unlimited surveys",
|
||||
"Unlimited team members",
|
||||
"Remove branding",
|
||||
"100 responses per survey",
|
||||
"Granular targeting",
|
||||
"In-product surveys",
|
||||
"Link surveys",
|
||||
"30+ templates",
|
||||
"API access",
|
||||
"Integrations (Slack, PostHog, Zapier)",
|
||||
"Webhooks",
|
||||
"Integrations (Zapier)",
|
||||
];
|
||||
|
||||
const proFeatures = ["All features of Free plan", "Unlimited responses", "Remove branding"];
|
||||
const proFeatures = ["All features of Free plan", "Unlimited responses"];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -105,9 +107,6 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="float-right -mt-2 mr-6 rounded-full bg-slate-700 px-3 py-1 text-xs font-semibold text-slate-50">
|
||||
Limited Early Bird Deal
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
|
||||
<div className="p-8">
|
||||
<h2 className="inline-flex text-3xl font-bold text-slate-700">Pro</h2>
|
||||
@@ -126,9 +125,7 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-6">
|
||||
<span className="text-3xl font-bold text-slate-800">
|
||||
<span className="mr-2 font-light line-through">$99</span>49$
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-slate-800">$99</span>
|
||||
|
||||
<span className="text-base font-medium text-slate-400">/ month</span>
|
||||
</p>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from "@formbricks/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
|
||||
export function EditBrandColor({ environmentId }) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
@@ -59,35 +61,43 @@ export function EditBrandColor({ environmentId }) {
|
||||
}
|
||||
|
||||
export function EditPlacement({ environmentId }) {
|
||||
const { isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
if (isLoadingEnvironment) {
|
||||
const [currentPlacement, setCurrentPlacement] = useState<PlacementType>("bottomRight");
|
||||
const [overlay, setOverlay] = useState("");
|
||||
const [clickOutside, setClickOutside] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setCurrentPlacement(product.placement);
|
||||
setOverlay(product.darkOverlay ? "darkOverlay" : "lightOverlay");
|
||||
setClickOutside(product.clickOutsideClose ? "allow" : "disallow");
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorEnvironment) {
|
||||
if (isErrorProduct) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", default: true, disabled: false },
|
||||
{ name: "Top Right", value: "topRight", default: false, disabled: true },
|
||||
{ name: "Top Left", value: "topLeft", default: false, disabled: true },
|
||||
{ name: "Bottom Left", value: "bottomLeft", default: false, disabled: true },
|
||||
{ name: "Centered Modal", value: "centered", default: false, disabled: true },
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex">
|
||||
<RadioGroup>
|
||||
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem
|
||||
id={placement.value}
|
||||
value={placement.value}
|
||||
checked={placement.default}
|
||||
disabled={placement.disabled}
|
||||
/>
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
|
||||
@@ -97,10 +107,73 @@ export function EditPlacement({ environmentId }) {
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div className="absolute bottom-3 right-3 h-16 w-16 rounded bg-slate-700"></div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" variant="darkCTA" className="mt-4" disabled>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Centered modal overlay color</Label>
|
||||
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setClickOutside(e)}
|
||||
value={clickOutside}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProduct}
|
||||
onClick={() => {
|
||||
triggerProductMutate({
|
||||
placement: currentPlacement,
|
||||
darkOverlay: overlay === "darkOverlay",
|
||||
clickOutsideClose: clickOutside === "allow",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Placement updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ export default function ProfileSettingsPage({ params }: { params: { environmentI
|
||||
<EditBrandColor environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
soon
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<EditPlacement environmentId={params.environmentId} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteInvite,
|
||||
removeMember,
|
||||
resendInvite,
|
||||
shareInvite,
|
||||
updateInviteeRole,
|
||||
updateMemberRole,
|
||||
useMembers,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import { PaperAirplaneIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import AddMemberModal from "./AddMemberModal";
|
||||
@@ -35,6 +36,7 @@ import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import ShareInviteModal from "@/app/environments/[environmentId]/settings/members/ShareInviteModal";
|
||||
|
||||
type EditMembershipsProps = {
|
||||
environmentId: string;
|
||||
@@ -128,6 +130,8 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
|
||||
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
|
||||
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
|
||||
const [shareInviteToken, setShareInviteToken] = useState<string>("");
|
||||
|
||||
const [activeMember, setActiveMember] = useState({} as any);
|
||||
const { profile } = useProfile();
|
||||
@@ -160,6 +164,12 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
mutateTeam();
|
||||
};
|
||||
|
||||
const handleShareInvite = async (member) => {
|
||||
const { inviteToken } = await shareInvite(team.teamId, member.inviteId);
|
||||
setShareInviteToken(inviteToken);
|
||||
setShowShareInviteModal(true);
|
||||
};
|
||||
|
||||
const handleResendInvite = async (inviteId) => {
|
||||
await resendInvite(team.teamId, inviteId);
|
||||
};
|
||||
@@ -192,28 +202,28 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="px-6"></div>
|
||||
<div className="col-span-2">Fullname</div>
|
||||
<div className="col-span-2">Email</div>
|
||||
<div className="">Role</div>
|
||||
<div className=""></div>
|
||||
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2"></div>
|
||||
<div className="col-span-5">Fullname</div>
|
||||
<div className="col-span-5">Email</div>
|
||||
<div className="col-span-3">Role</div>
|
||||
<div className="col-span-5"></div>
|
||||
</div>
|
||||
<div className="grid-cols-8">
|
||||
<div className="grid-cols-20">
|
||||
{[...team.members, ...team.invitees].map((member) => (
|
||||
<div
|
||||
className="grid h-auto w-full grid-cols-8 content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
|
||||
className="grid-cols-20 grid h-auto w-full content-center rounded-lg p-0.5 py-2 text-left text-sm text-slate-900"
|
||||
key={member.email}>
|
||||
<div className="h-58 px-6 ">
|
||||
<div className="h-58 col-span-2 pl-4">
|
||||
<ProfileAvatar userId={member.userId || member.email} />
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 flex flex-col justify-center break-all">
|
||||
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
|
||||
<p>{member.name}</p>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 flex flex-col justify-center break-all">
|
||||
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
|
||||
{member.email}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex flex-col items-start justify-center break-all">
|
||||
<div className="ph-no-capture col-span-3 flex flex-col items-start justify-center break-all">
|
||||
<RoleElement
|
||||
isAdminOrOwner={isAdminOrOwner}
|
||||
memberRole={member.role}
|
||||
@@ -225,8 +235,8 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
inviteId={member?.inviteId}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center justify-end gap-x-6 pr-6">
|
||||
{!member.accepted && <Badge type="warning" text="Pending" size="tiny" />}
|
||||
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
|
||||
{!member.accepted && <Badge className="mr-2" type="warning" text="Pending" size="tiny" />}
|
||||
{member.role !== "owner" && (
|
||||
<button onClick={(e) => handleOpenDeleteMemberModal(e, member)}>
|
||||
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
@@ -234,6 +244,19 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
)}
|
||||
{!member.accepted && (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleShareInvite(member);
|
||||
}}>
|
||||
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="TooltipContent" sideOffset={5}>
|
||||
Share Invite Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -267,6 +290,13 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
deleteWhat={activeMember.name + " from your team"}
|
||||
onDelete={handleDeleteMember}
|
||||
/>
|
||||
{showShareInviteModal && (
|
||||
<ShareInviteModal
|
||||
inviteToken={shareInviteToken}
|
||||
open={showShareInviteModal}
|
||||
setOpen={setShowShareInviteModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/shared/Modal";
|
||||
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
|
||||
import { useRef } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface ShareInviteModalProps {
|
||||
inviteToken: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ShareInviteModal({ inviteToken, open, setOpen }: ShareInviteModalProps) {
|
||||
const linkTextRef = useRef(null);
|
||||
|
||||
const handleTextSelection = () => {
|
||||
if (linkTextRef.current) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(linkTextRef.current);
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} blur={false}>
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
|
||||
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900">Your team invite link is ready!</h3>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-500">Share this link to let your team member join your team:</p>
|
||||
<p
|
||||
ref={linkTextRef}
|
||||
className="relative mt-3 w-full truncate rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
|
||||
onClick={() => handleTextSelection()}>
|
||||
{`${window.location.protocol}//${window.location.host}/invite?token=${inviteToken}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}/invite?token=${inviteToken}`
|
||||
);
|
||||
toast.success("URL copied to clipboard!");
|
||||
}}
|
||||
title="Copy invite link to clipboard"
|
||||
aria-label="Copy invite link to clipboard"
|
||||
EndIcon={DocumentDuplicateIcon}>
|
||||
Copy URL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,10 @@ export default function PreviewSurvey({
|
||||
Array.isArray(logic.value) &&
|
||||
logic.value.some((v) => answerValue.includes(v))
|
||||
);
|
||||
case "accepted":
|
||||
return answerValue === "accepted";
|
||||
case "clicked":
|
||||
return answerValue === "clicked";
|
||||
case "submitted":
|
||||
if (typeof answerValue === "string") {
|
||||
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
|
||||
@@ -252,7 +256,7 @@ export default function PreviewSurvey({
|
||||
</div>
|
||||
|
||||
{previewType === "modal" ? (
|
||||
<Modal isOpen={isModalOpen}>
|
||||
<Modal isOpen={isModalOpen} placement={product.placement}>
|
||||
{!countdownStop && autoClose !== null && autoClose > 0 && (
|
||||
<Progress progress={countdownProgress} brandColor={brandColor} />
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
|
||||
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { Session } from "next-auth";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ResponsesLimitReachedBannerProps {
|
||||
environmentId: string;
|
||||
limitReached: boolean;
|
||||
responsesCount: number;
|
||||
session: Session;
|
||||
surveyId: string;
|
||||
}
|
||||
|
||||
export default function ResponsesLimitReachedBanner({
|
||||
export default async function ResponsesLimitReachedBanner({
|
||||
surveyId,
|
||||
environmentId,
|
||||
limitReached,
|
||||
responsesCount,
|
||||
session,
|
||||
}: ResponsesLimitReachedBannerProps) {
|
||||
const { responsesCount, limitReached } = await getAnalysisData(session, surveyId);
|
||||
return (
|
||||
<>
|
||||
{limitReached && (
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { md } from "@formbricks/lib/markdownIt";
|
||||
import type { ConsentQuestion } from "@formbricks/types/questions";
|
||||
import { Survey } from "@formbricks/types/surveys";
|
||||
import { Editor, Input, Label } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ConsentQuestionFormProps {
|
||||
localSurvey: Survey;
|
||||
question: ConsentQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
}
|
||||
|
||||
export default function ConsentQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
}: ConsentQuestionFormProps): JSX.Element {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
return (
|
||||
<form>
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="headline">Question</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
id="headline"
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
getText={() =>
|
||||
md.render(
|
||||
question.html || "We would love to talk to you and learn more about how you use our product."
|
||||
)
|
||||
}
|
||||
setText={(value: string) => {
|
||||
updateQuestion(questionIdx, { html: value });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="label">Checkbox Label</Label>
|
||||
<Input
|
||||
id="label"
|
||||
name="label"
|
||||
className="mt-2"
|
||||
value={question.label}
|
||||
placeholder="I agree to the terms and conditions"
|
||||
onChange={(e) => updateQuestion(questionIdx, { label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="mt-3">
|
||||
<Label htmlFor="buttonLabel">Button Label</Label>
|
||||
<Input
|
||||
id="buttonLabel"
|
||||
name="buttonLabel"
|
||||
className="mt-2"
|
||||
value={question.buttonLabel}
|
||||
placeholder={lastQuestion ? "Finish" : "Next"}
|
||||
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
|
||||
/>
|
||||
</div> */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,8 @@ export default function LogicEditor({
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
cta: ["submitted", "skipped"],
|
||||
cta: ["clicked", "skipped"],
|
||||
consent: ["skipped", "accepted"],
|
||||
};
|
||||
const logicConditions: LogicConditions = {
|
||||
submitted: {
|
||||
@@ -92,6 +93,16 @@ export default function LogicEditor({
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
accepted: {
|
||||
label: "is accepted",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
clicked: {
|
||||
label: "is clicked",
|
||||
values: null,
|
||||
unique: true,
|
||||
},
|
||||
equals: {
|
||||
label: "equals",
|
||||
values: questionValues,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Survey } from "@formbricks/types/surveys";
|
||||
import { Input, Label, Switch } from "@formbricks/ui";
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CursorArrowRippleIcon,
|
||||
@@ -25,6 +26,7 @@ import NPSQuestionForm from "./NPSQuestionForm";
|
||||
import OpenQuestionForm from "./OpenQuestionForm";
|
||||
import QuestionDropdown from "./QuestionMenu";
|
||||
import RatingQuestionForm from "./RatingQuestionForm";
|
||||
import ConsentQuestionForm from "./ConsentQuestionForm";
|
||||
import AdvancedSettings from "@/app/environments/[environmentId]/surveys/[surveyId]/edit/AdvancedSettings";
|
||||
|
||||
interface QuestionCardProps {
|
||||
@@ -100,6 +102,8 @@ export default function QuestionCard({
|
||||
<CursorArrowRippleIcon />
|
||||
) : question.type === QuestionType.Rating ? (
|
||||
<StarIcon />
|
||||
) : question.type === "consent" ? (
|
||||
<CheckIcon />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
@@ -174,6 +178,13 @@ export default function QuestionCard({
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
/>
|
||||
) : question.type === "consent" ? (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
@@ -216,8 +227,24 @@ export default function QuestionCard({
|
||||
</Collapsible.CollapsibleContent>
|
||||
|
||||
{open && (
|
||||
<div className="m-4 mt-0 border-t border-slate-200">
|
||||
<div className="m-4 mr-0 flex items-center justify-end space-x-2">
|
||||
<div className="mx-4 flex justify-end space-x-6 border-t border-slate-200">
|
||||
{question.type === "openText" && (
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="longAnswer">Long Answer</Label>
|
||||
<Switch
|
||||
id="longAnswer"
|
||||
checked={question.longAnswer !== false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuestion(questionIdx, {
|
||||
longAnswer:
|
||||
typeof question.longAnswer === "undefined" ? false : !question.longAnswer,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="required-toggle">Required</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
|
||||
@@ -112,29 +112,31 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 ">
|
||||
<div className="ml-2 flex items-center space-x-1">
|
||||
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
|
||||
<Label htmlFor="redirectUrl" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Redirect user to specified link on survey completion
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
{localSurvey.type === "link" && (
|
||||
<div className="p-3 ">
|
||||
<div className="ml-2 flex items-center space-x-1">
|
||||
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
|
||||
<Label htmlFor="redirectUrl" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Redirect user to specified link on survey completion
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{redirectToggle && (
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://www.example.com"
|
||||
value={redirectUrl ? redirectUrl : ""}
|
||||
onChange={(e) => handleRedirectUrlChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{redirectToggle && (
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://www.example.com"
|
||||
value={redirectUrl ? redirectUrl : ""}
|
||||
onChange={(e) => handleRedirectUrlChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -5,13 +5,13 @@ import ResponseOptionsCard from "./ResponseOptionsCard";
|
||||
import WhenToSendCard from "./WhenToSendCard";
|
||||
import WhoToSendCard from "./WhoToSendCard";
|
||||
|
||||
interface AudienceViewProps {
|
||||
interface SettingsViewProps {
|
||||
environmentId: string;
|
||||
localSurvey: Survey;
|
||||
setLocalSurvey: (survey: Survey) => void;
|
||||
}
|
||||
|
||||
export default function AudienceView({ environmentId, localSurvey, setLocalSurvey }: AudienceViewProps) {
|
||||
export default function SettingsView({ environmentId, localSurvey, setLocalSurvey }: SettingsViewProps) {
|
||||
return (
|
||||
<div className="mt-12 space-y-3 p-5">
|
||||
<HowToSendCard
|
||||
@@ -32,7 +32,7 @@ export default function AudienceView({ environmentId, localSurvey, setLocalSurve
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
{localSurvey.type==="link" && <ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />}
|
||||
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
|
||||
<RecontactOptionsCard
|
||||
localSurvey={localSurvey}
|
||||
@@ -7,7 +7,7 @@ import type { Survey } from "@formbricks/types/surveys";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import PreviewSurvey from "../../PreviewSurvey";
|
||||
import AudienceView from "./AudienceView";
|
||||
import SettingsView from "./SettingsView";
|
||||
import QuestionsAudienceTabs from "./QuestionsAudienceTabs";
|
||||
import QuestionsView from "./QuestionsView";
|
||||
import SurveyMenuBar from "./SurveyMenuBar";
|
||||
@@ -67,7 +67,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
) : (
|
||||
<AudienceView
|
||||
<SettingsView
|
||||
environmentId={environmentId}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import TagsCombobox from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/TagsCombobox";
|
||||
import { useResponses } from "@/lib/responses/responses";
|
||||
import { removeTagFromResponse, useAddTagToResponse, useCreateTag } from "@/lib/tags/mutateTags";
|
||||
import { useTagsForEnvironment } from "@/lib/tags/tags";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Tag } from "./Tag";
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface ResponseTagsWrapperProps {
|
||||
tags: {
|
||||
@@ -22,13 +25,13 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
responseId,
|
||||
surveyId,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [tagsState, setTagsState] = useState(tags);
|
||||
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
|
||||
|
||||
const { createTag } = useCreateTag(environmentId);
|
||||
const { mutateResponses } = useResponses(environmentId, surveyId);
|
||||
const { data: environmentTags, mutate: refetchEnvironmentTags } = useTagsForEnvironment(environmentId);
|
||||
const { addTagToRespone } = useAddTagToResponse(environmentId, surveyId, responseId);
|
||||
|
||||
@@ -36,9 +39,10 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
try {
|
||||
await removeTagFromResponse(environmentId, surveyId, responseId, tagId);
|
||||
|
||||
mutateResponses();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
toast.error("An error occurred deleting the tag");
|
||||
router.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,27 +100,36 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
onSuccess: () => {
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
mutateResponses();
|
||||
|
||||
refetchEnvironmentTags();
|
||||
router.refresh();
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err?.message ?? "Something went wrong", {
|
||||
duration: 2000,
|
||||
});
|
||||
if (err?.cause === "DUPLICATE_RECORD") {
|
||||
toast.error(err?.message ?? "Something went wrong", {
|
||||
duration: 2000,
|
||||
icon: <ExclamationCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
|
||||
const tag = tags.find((tag) => tag.tagName === tagName?.trim() ?? "");
|
||||
setTagIdToHighlight(tag?.tagId ?? "");
|
||||
} else {
|
||||
toast.error(err?.message ?? "Something went wrong", {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
mutateResponses();
|
||||
|
||||
const tag = tags.find((tag) => tag.tagName === tagName?.trim() ?? "");
|
||||
setTagIdToHighlight(tag?.tagId ?? "");
|
||||
|
||||
refetchEnvironmentTags();
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
throwOnError: false,
|
||||
}
|
||||
);
|
||||
}}
|
||||
@@ -137,9 +150,9 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
onSuccess: () => {
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
mutateResponses();
|
||||
|
||||
refetchEnvironmentTags();
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function Tag({
|
||||
key={tagId}
|
||||
className={cn(
|
||||
"relative flex items-center justify-between gap-2 rounded-full border bg-slate-600 px-2 py-1 text-slate-100",
|
||||
highlight && "border-2 border-green-600"
|
||||
highlight && "animate-shake"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{tagName}</span>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const revalidate = 0;
|
||||
|
||||
import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import SurveyResultsTabs from "../SurveyResultsTabs";
|
||||
import ResponseTimeline from "./ResponseTimeline";
|
||||
@@ -11,7 +13,7 @@ export default async function ResponsesPage({ params }) {
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
|
||||
const { responses, survey } = await getAnalysisData(session, params.surveyId);
|
||||
return (
|
||||
<>
|
||||
<SurveyResultsTabs
|
||||
@@ -19,10 +21,11 @@ export default async function ResponsesPage({ params }) {
|
||||
environmentId={params.environmentId}
|
||||
surveyId={params.surveyId}
|
||||
/>
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<ResponsesLimitReachedBanner
|
||||
environmentId={params.environmentId}
|
||||
limitReached={limitReached}
|
||||
responsesCount={responsesCount}
|
||||
surveyId={params.surveyId}
|
||||
session={session}
|
||||
/>
|
||||
<ContentWrapper>
|
||||
<ResponseTimeline
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ConsentQuestion } from "@formbricks/types/questions";
|
||||
import type { QuestionSummary } from "@formbricks/types/responses";
|
||||
import { ProgressBar } from "@formbricks/ui";
|
||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ConsentSummaryProps {
|
||||
questionSummary: QuestionSummary<ConsentQuestion>;
|
||||
}
|
||||
|
||||
interface ChoiceResult {
|
||||
count: number;
|
||||
acceptedCount: number;
|
||||
acceptedPercentage: number;
|
||||
dismissedCount: number;
|
||||
dismissedPercentage: number;
|
||||
}
|
||||
|
||||
export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) {
|
||||
const ctr: ChoiceResult = useMemo(() => {
|
||||
const total = questionSummary.responses.length;
|
||||
const clickedAbs = questionSummary.responses.filter((response) => response.value !== "skipped").length;
|
||||
|
||||
return {
|
||||
count: total,
|
||||
acceptedCount: clickedAbs,
|
||||
acceptedPercentage: clickedAbs / total,
|
||||
dismissedCount: total - clickedAbs,
|
||||
dismissedPercentage: 1 - clickedAbs / total,
|
||||
};
|
||||
}, [questionSummary]);
|
||||
|
||||
return (
|
||||
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
||||
<div className="space-y-2 px-6 pb-5 pt-6">
|
||||
<div>
|
||||
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
|
||||
</div>
|
||||
<div className="flex space-x-2 font-semibold text-slate-600">
|
||||
<div className="rounded-lg bg-slate-100 p-2 text-sm">Consent</div>
|
||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
|
||||
<InboxStackIcon className="mr-2 h-4 w-4 " />
|
||||
{ctr.count} responses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 rounded-b-lg bg-white px-6 pb-6 pt-4">
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold text-slate-700">Accepted</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{Math.round(ctr.acceptedPercentage * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{ctr.acceptedCount} {ctr.acceptedCount === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand" progress={ctr.acceptedPercentage} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold text-slate-700">Skipped</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{Math.round(ctr.dismissedPercentage * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{ctr.dismissedCount} {ctr.dismissedCount === 1 ? "response" : "responses"}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand" progress={ctr.dismissedPercentage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import LinkSurveyModal from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkSurveyModal";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { ShareIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
interface LinkSurveyShareButtonProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export default function LinkSurveyShareButton({ survey }: LinkSurveyShareButtonProps) {
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-full border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6"
|
||||
onClick={() => setShowLinkModal(true)}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
|
||||
interface StatusDropdownProps {
|
||||
survey: TSurvey;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export default function StatusDropdown({ survey, environmentId }: StatusDropdownProps) {
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
|
||||
if (isLoadingEnvironment) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorEnvironment) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{environment.widgetSetupCompleted || survey.type === "link" ? (
|
||||
<SurveyStatusDropdown surveyId={survey.id} environmentId={environmentId} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Confetti } from "@formbricks/ui";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkSurveyModal from "./LinkSurveyModal";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export default function SuccessMessage({ environmentId, survey }: SummaryMetadataProps) {
|
||||
const { environment } = useEnvironment(environmentId);
|
||||
const searchParams = useSearchParams();
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
const newSurveyParam = searchParams?.get("success");
|
||||
if (newSurveyParam && survey && environment) {
|
||||
setConfetti(true);
|
||||
toast.success(
|
||||
survey.type === "web" && !environment.widgetSetupCompleted
|
||||
? "Almost there! Install widget to start receiving responses."
|
||||
: "Congrats! Your survey is live.",
|
||||
{
|
||||
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
}
|
||||
);
|
||||
if (survey.type === "link") {
|
||||
setShowLinkModal(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [environment, searchParams, survey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
|
||||
{confetti && <Confetti />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
import {
|
||||
QuestionType,
|
||||
@@ -9,8 +10,8 @@ import {
|
||||
type RatingQuestion,
|
||||
} from "@formbricks/types/questions";
|
||||
import type { QuestionSummary } from "@formbricks/types/responses";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
|
||||
import { Session } from "next-auth";
|
||||
import CTASummary from "./CTASummary";
|
||||
import MultipleChoiceSummary from "./MultipleChoiceSummary";
|
||||
import NPSSummary from "./NPSSummary";
|
||||
@@ -19,14 +20,16 @@ import RatingSummary from "./RatingSummary";
|
||||
|
||||
interface SummaryListProps {
|
||||
environmentId: string;
|
||||
responses: TResponse[];
|
||||
surveyId: string;
|
||||
session: Session;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export default function SummaryList({ environmentId, responses, survey }: SummaryListProps) {
|
||||
let summaryData: QuestionSummary<TSurveyQuestion>[] = [];
|
||||
if (survey && responses) {
|
||||
summaryData = survey.questions.map((question) => {
|
||||
export default async function SummaryList({ environmentId, surveyId, session }: SummaryListProps) {
|
||||
const { survey, responses } = await getAnalysisData(session, surveyId);
|
||||
|
||||
const getSummaryData = (): QuestionSummary<TSurveyQuestion>[] =>
|
||||
survey.questions.map((question) => {
|
||||
const questionResponses = responses
|
||||
.filter((response) => question.id in response.data)
|
||||
.map((r) => ({
|
||||
@@ -40,7 +43,6 @@ export default function SummaryList({ environmentId, responses, survey }: Summar
|
||||
responses: questionResponses,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -53,7 +55,7 @@ export default function SummaryList({ environmentId, responses, survey }: Summar
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{summaryData.map((questionSummary) => {
|
||||
{getSummaryData().map((questionSummary) => {
|
||||
if (questionSummary.question.type === QuestionType.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
|
||||
@@ -1,172 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import LinkSurveyShareButton from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkModalButton";
|
||||
import StatusDropdown from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/StatusDropdown";
|
||||
import SuccessMessage from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/SuccessMessage";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
import { getSurveyResponses } from "@formbricks/lib/services/response";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import {
|
||||
Button,
|
||||
Confetti,
|
||||
ErrorComponent,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import { ShareIcon } from "@heroicons/react/24/outline";
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { PencilSquareIcon, QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkSurveyModal from "./LinkSurveyModal";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
session: Session;
|
||||
surveyId: string;
|
||||
environmentId: string;
|
||||
responses: TResponse[];
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export default function SummaryMetadata({
|
||||
surveyId,
|
||||
environmentId,
|
||||
responses,
|
||||
survey,
|
||||
}: SummaryMetadataProps) {
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
export default async function SummaryMetadata({ session, surveyId, environmentId }: SummaryMetadataProps) {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
|
||||
const allResponses = await getSurveyResponses(surveyId);
|
||||
const limitReached =
|
||||
IS_FORMBRICKS_CLOUD && session?.user.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
|
||||
const responses = limitReached ? allResponses.slice(0, RESPONSES_LIMIT_FREE) : allResponses;
|
||||
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
const newSurveyParam = searchParams?.get("success");
|
||||
if (newSurveyParam && survey && environment) {
|
||||
setConfetti(true);
|
||||
toast.success(
|
||||
survey.type === "web" && !environment.widgetSetupCompleted
|
||||
? "Almost there! Install widget to start receiving responses."
|
||||
: "Congrats! Your survey is live.",
|
||||
{
|
||||
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
}
|
||||
);
|
||||
if (survey.type === "link") {
|
||||
setShowLinkModal(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [environment, searchParams, survey]);
|
||||
|
||||
const completionRate = useMemo(() => {
|
||||
if (!responses) return 0;
|
||||
return (responses.filter((r) => r.finished).length / responses.length) * 100;
|
||||
}, [responses]);
|
||||
|
||||
if (isLoadingEnvironment) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorEnvironment) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
const completionRate = !responses
|
||||
? 0
|
||||
: (responses.filter((r) => r.finished).length / responses.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="mb-4 ">
|
||||
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-2 lg:gap-x-2">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid md:grid-cols-4 md:gap-x-2">
|
||||
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Survey displays</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{survey.analytics.numDisplays === 0 ? <span>-</span> : survey.analytics.numDisplays}
|
||||
</p>
|
||||
<>
|
||||
<div className="mb-4 ">
|
||||
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-2 lg:gap-x-2">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid md:grid-cols-4 md:gap-x-2">
|
||||
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Survey displays</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{survey.analytics.numDisplays === 0 ? <span>-</span> : survey.analytics.numDisplays}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Total Responses</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{responses.length === 0 ? <span>-</span> : responses.length}
|
||||
</p>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Response %
|
||||
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span>{Math.round(survey.analytics.responseRate * 100)} %</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>% of people who responded when survey was shown.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Completion %
|
||||
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{responses.length === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span>{parseFloat(completionRate.toFixed(2))} %</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
% of people who started <strong>and</strong> completed the survey.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Total Responses</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{responses.length === 0 ? <span>-</span> : responses.length}
|
||||
</p>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Response %
|
||||
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span>{Math.round(survey.analytics.responseRate * 100)} %</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>% of people who responded when survey was shown.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex cursor-default flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="text-sm text-slate-600">
|
||||
Completion %
|
||||
<QuestionMarkCircleIcon className="mb-1 ml-2 inline h-4 w-4 text-slate-500" />
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
{responses.length === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span>{parseFloat(completionRate.toFixed(2))} %</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
% of people who started <strong>and</strong> completed the survey.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between lg:col-span-1">
|
||||
<div className="text-right text-xs text-slate-400">
|
||||
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-1.5">
|
||||
{survey.type === "link" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-full border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6"
|
||||
onClick={() => setShowLinkModal(true)}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-col justify-between lg:col-span-1">
|
||||
<div className="text-right text-xs text-slate-400">
|
||||
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-1.5">
|
||||
{survey.type === "link" && <LinkSurveyShareButton survey={survey} />}
|
||||
|
||||
{environment.widgetSetupCompleted || survey.type === "link" ? (
|
||||
<SurveyStatusDropdown surveyId={surveyId} environmentId={environmentId} />
|
||||
) : null}
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="h-full w-full px-3 lg:px-6"
|
||||
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-5 w-5 text-white" />
|
||||
Edit
|
||||
</Button>
|
||||
<StatusDropdown survey={survey} environmentId={environmentId} />
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="h-full w-full px-3 lg:px-6"
|
||||
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
|
||||
<PencilSquareIcon className="mr-2 h-5 w-5 text-white" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLinkModal && <LinkSurveyModal survey={survey} open={showLinkModal} setOpen={setShowLinkModal} />}
|
||||
{confetti && <Confetti />}
|
||||
</div>
|
||||
<SuccessMessage environmentId={environmentId} survey={survey} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import Modal from "components/shared/Modal";
|
||||
|
||||
interface SurveyCreatedSuccessModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function SurveyCreatedSuccessModal({ open, setOpen }: SurveyCreatedSuccessModalProps) {
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} setOpen={setOpen} title="Survey published 🎉">
|
||||
Your survey is live and collecting valuable insights!
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const revalidate = 0;
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
|
||||
import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import { getServerSession } from "next-auth";
|
||||
import ResponsesLimitReachedBanner from "../ResponsesLimitReachedBanner";
|
||||
@@ -12,24 +13,21 @@ export default async function SummaryPage({ params }) {
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const { responses, responsesCount, limitReached, survey } = await getAnalysisData(session, params.surveyId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SurveyResultsTabs activeId="summary" environmentId={params.environmentId} surveyId={params.surveyId} />
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<ResponsesLimitReachedBanner
|
||||
environmentId={params.environmentId}
|
||||
limitReached={limitReached}
|
||||
responsesCount={responsesCount}
|
||||
session={session}
|
||||
surveyId={params.surveyId}
|
||||
/>
|
||||
<ContentWrapper>
|
||||
<SummaryMetadata
|
||||
surveyId={params.surveyId}
|
||||
environmentId={params.environmentId}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
/>
|
||||
<SummaryList environmentId={params.environmentId} survey={survey} responses={responses} />
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<SummaryMetadata surveyId={params.surveyId} environmentId={params.environmentId} session={session} />
|
||||
{/* @ts-expect-error Server Component */}
|
||||
<SummaryList environmentId={params.environmentId} session={session} surveyId={params.surveyId} />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createSurvey } from "@/lib/surveys/surveys";
|
||||
import type { Template } from "@formbricks/types/templates";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/solid";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TemplateMenuBarProps {
|
||||
activeTemplate: Template | null;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export default function TemplateMenuBar({ activeTemplate, environmentId }: TemplateMenuBarProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const addSurvey = async (activeTemplate) => {
|
||||
setLoading(true);
|
||||
const survey = await createSurvey(environmentId, activeTemplate.preset);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="minimal" className="px-0" onClick={() => router.back()}>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-slate-700" />
|
||||
</Button>
|
||||
<h1 className="font-slate-700 text-lg font-semibold">Start with a template</h1>
|
||||
</div>
|
||||
<div className="mt-3 flex sm:ml-4 sm:mt-0">
|
||||
<Button
|
||||
variant="highlight"
|
||||
disabled={activeTemplate === null}
|
||||
loading={loading}
|
||||
onClick={() => addSurvey(activeTemplate)}
|
||||
EndIcon={ArrowRightIcon}>
|
||||
Create Survey
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,9 @@ import { useProduct } from "@/lib/products/products";
|
||||
import { replacePresetPlaceholders } from "@/lib/templates";
|
||||
import type { Template } from "@formbricks/types/templates";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
/* import { PaintBrushIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link"; */
|
||||
import { useEffect, useState } from "react";
|
||||
import PreviewSurvey from "../PreviewSurvey";
|
||||
import TemplateList from "./TemplateList";
|
||||
/* import TemplateMenuBar from "./TemplateMenuBar"; */
|
||||
import { templates } from "./templates";
|
||||
|
||||
export default function SurveyTemplatesPage({ params }) {
|
||||
@@ -36,7 +33,6 @@ export default function SurveyTemplatesPage({ params }) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col ">
|
||||
{/* <TemplateMenuBar activeTemplate={activeTemplate} environmentId={environmentId} /> */}
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex-col overflow-auto bg-slate-50">
|
||||
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
|
||||
|
||||
@@ -252,7 +252,7 @@ export const templates: Template[] = [
|
||||
id: "mao94214zoo6c1at5rpuz7io",
|
||||
html: '<p class="fb-editor-paragraph" dir="ltr"><span>We\'d love to keep you as a customer. Happy to offer a 30% discount for the next year.</span></p>',
|
||||
type: QuestionType.CTA,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
headline: "Get 30% off for the next year!",
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
@@ -273,7 +273,7 @@ export const templates: Template[] = [
|
||||
id: "hdftsos1odzjllr7flj4m3j9",
|
||||
html: '<p class="fb-editor-paragraph" dir="ltr"><span>We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.</span></p>',
|
||||
type: QuestionType.CTA,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
headline: "So sorry to hear 😔 Talk to our CEO directly!",
|
||||
required: true,
|
||||
buttonUrl: "mailto:ceo@company.com",
|
||||
@@ -409,7 +409,7 @@ export const templates: Template[] = [
|
||||
id: "x760wga1fhtr1i80cpssr7af",
|
||||
html: '<p class="fb-editor-paragraph" dir="ltr"><span>We\'re happy to offer you a 20% discount on a yearly plan.</span></p>',
|
||||
type: QuestionType.CTA,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
headline: "Sorry to hear! Get 20% off the first year.",
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
@@ -466,7 +466,7 @@ export const templates: Template[] = [
|
||||
id: createId(),
|
||||
html: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>',
|
||||
type: QuestionType.CTA,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
headline: "Happy to hear 🙏 Please write a review for us!",
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
@@ -945,7 +945,7 @@ export const templates: Template[] = [
|
||||
html: '<p class="fb-editor-paragraph" dir="ltr"><span>We will fix this as soon as possible. Do you want to be notified when we did?</span></p>',
|
||||
type: QuestionType.CTA,
|
||||
logic: [
|
||||
{ condition: "submitted", destination: "end" },
|
||||
{ condition: "clicked", destination: "end" },
|
||||
{ condition: "skipped", destination: "end" },
|
||||
],
|
||||
headline: "Want to stay in the loop?",
|
||||
|
||||
@@ -24,7 +24,11 @@ interface OnboardingProps {
|
||||
}
|
||||
|
||||
export default function Onboarding({ session }: OnboardingProps) {
|
||||
const { data: environment, error } = useSWR(`/api/v1/environments/find-first`, fetcher);
|
||||
const {
|
||||
data: environment,
|
||||
error: isErrorEnvironment,
|
||||
isLoading: isLoadingEnvironment,
|
||||
} = useSWR(`/api/v1/environments/find-first`, fetcher);
|
||||
const { profile } = useProfile();
|
||||
const { triggerProfileMutate } = useProfileMutation();
|
||||
const [formbricksResponseId, setFormbricksResponseId] = useState<ResponseId | undefined>();
|
||||
@@ -36,7 +40,7 @@ export default function Onboarding({ session }: OnboardingProps) {
|
||||
return currentStep / MAX_STEPS;
|
||||
}, [currentStep]);
|
||||
|
||||
if (!profile) {
|
||||
if (!profile || isLoadingEnvironment) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
@@ -44,7 +48,7 @@ export default function Onboarding({ session }: OnboardingProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (isErrorEnvironment) {
|
||||
return <div className="flex h-full w-full items-center justify-center">An error occurred</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WEBAPP_URL } from "@/../../packages/lib/constants";
|
||||
import ClientLogout from "@/app/ClientLogout";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { headers } from "next/headers";
|
||||
@@ -14,6 +15,8 @@ async function getEnvironment() {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
console.error(error);
|
||||
throw new Error("Failed to fetch data");
|
||||
}
|
||||
|
||||
@@ -31,10 +34,16 @@ export default async function Home() {
|
||||
return redirect(`/onboarding`);
|
||||
}
|
||||
|
||||
const environment = await getEnvironment();
|
||||
let environment;
|
||||
try {
|
||||
environment = await getEnvironment();
|
||||
} catch (error) {
|
||||
console.error("error getting environment", error);
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw Error("No environment found for user");
|
||||
console.error("Failed to get first environment of user; signing out");
|
||||
return <ClientLogout />;
|
||||
}
|
||||
|
||||
return redirect(`/environments/${environment.id}`);
|
||||
|
||||
@@ -460,5 +460,3 @@ export const GrinningSquintingFace: React.FC<React.SVGProps<SVGCircleElement>> =
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export let icons = [<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg"></svg>];
|
||||
|
||||
@@ -21,8 +21,8 @@ export const SignupForm = () => {
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
if(!isValid){
|
||||
return
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
setSigningUp(true);
|
||||
try {
|
||||
@@ -48,8 +48,8 @@ export const SignupForm = () => {
|
||||
const [isButtonEnabled, setButtonEnabled] = useState(true);
|
||||
const [isPasswordFocused, setIsPasswordFocused] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [password, setPassword] = useState<string|null>(null)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const checkFormValidity = () => {
|
||||
// If all fields are filled, enable the button
|
||||
@@ -145,7 +145,7 @@ export const SignupForm = () => {
|
||||
)}
|
||||
<Button
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (!showLogin) {
|
||||
setShowLogin(true);
|
||||
setButtonEnabled(false);
|
||||
@@ -158,7 +158,7 @@ export const SignupForm = () => {
|
||||
variant="darkCTA"
|
||||
className="w-full justify-center"
|
||||
loading={signingUp}
|
||||
disabled={formRef.current ? (!isButtonEnabled || !isValid): !isButtonEnabled}>
|
||||
disabled={formRef.current ? !isButtonEnabled || !isValid : !isButtonEnabled}>
|
||||
Continue with Email
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
62
apps/web/components/preview/ConsentQuestion.tsx
Normal file
62
apps/web/components/preview/ConsentQuestion.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ConsentQuestion } from "@formbricks/types/questions";
|
||||
import Headline from "./Headline";
|
||||
import HtmlBody from "./HtmlBody";
|
||||
import { cn } from "@/../../packages/lib/cn";
|
||||
import { isLight } from "@/lib/utils";
|
||||
|
||||
interface ConsentQuestionProps {
|
||||
question: ConsentQuestion;
|
||||
onSubmit: (data: { [x: string]: any }) => void;
|
||||
lastQuestion: boolean;
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
export default function ConsentQuestion({
|
||||
question,
|
||||
onSubmit,
|
||||
lastQuestion,
|
||||
brandColor,
|
||||
}: ConsentQuestionProps) {
|
||||
return (
|
||||
<div>
|
||||
<Headline headline={question.headline} questionId={question.id} />
|
||||
<HtmlBody htmlString={question.html || ""} questionId={question.id} />
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const checkbox = document.getElementById(question.id) as HTMLInputElement;
|
||||
onSubmit({ [question.id]: checkbox.checked ? "accepted" : "dismissed" });
|
||||
}}>
|
||||
<label className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 bg-slate-50 p-4 text-sm focus:outline-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={question.id}
|
||||
name={question.id}
|
||||
value={question.label}
|
||||
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
|
||||
aria-labelledby={`${question.id}-label`}
|
||||
style={{ borderColor: brandColor, color: brandColor }}
|
||||
required={question.required}
|
||||
/>
|
||||
<span id={`${question.id}-label`} className="ml-3 font-medium">
|
||||
{question.label}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="mt-4 flex w-full justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
"flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}
|
||||
style={{ backgroundColor: brandColor }}>
|
||||
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
export default function Modal({ children, isOpen }: { children: ReactNode; isOpen: boolean }) {
|
||||
export default function Modal({
|
||||
children,
|
||||
isOpen,
|
||||
placement,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isOpen: boolean;
|
||||
placement: PlacementType;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -9,11 +19,12 @@ export default function Modal({ children, isOpen }: { children: ReactNode; isOpe
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div aria-live="assertive" className="flex w-full grow items-end justify-end p-4">
|
||||
<div aria-live="assertive" className="relative h-full w-full">
|
||||
<div
|
||||
className={cn(
|
||||
show ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0",
|
||||
"pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out"
|
||||
"pointer-events-auto absolute h-fit w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
|
||||
getPlacementStyle(placement)
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -33,16 +33,30 @@ export default function OpenTextQuestion({
|
||||
<Headline headline={question.headline} questionId={question.id} />
|
||||
<Subheader subheader={question.subheader} questionId={question.id} />
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={3}
|
||||
name={question.id}
|
||||
id={question.id}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={question.placeholder}
|
||||
required={question.required}
|
||||
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"></textarea>
|
||||
{question.longAnswer === false ? (
|
||||
<input
|
||||
autoFocus
|
||||
name={question.id}
|
||||
id={question.id}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={question.placeholder}
|
||||
required={question.required}
|
||||
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={3}
|
||||
name={question.id}
|
||||
id={question.id}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={question.placeholder}
|
||||
required={question.required}
|
||||
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
<div></div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion";
|
||||
import NPSQuestion from "./NPSQuestion";
|
||||
import CTAQuestion from "./CTAQuestion";
|
||||
import RatingQuestion from "./RatingQuestion";
|
||||
import ConsentQuestion from "./ConsentQuestion";
|
||||
|
||||
interface QuestionConditionalProps {
|
||||
question: Question;
|
||||
@@ -61,5 +62,12 @@ export default function QuestionConditional({
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
/>
|
||||
) : question.type === "consent" ? (
|
||||
<ConsentQuestion
|
||||
question={question}
|
||||
onSubmit={onSubmit}
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import React from 'react'
|
||||
import { useEffect,useState } from 'react'
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function RedirectCountDown({
|
||||
initiateCountdown
|
||||
}:{
|
||||
initiateCountdown:boolean|undefined
|
||||
}) {
|
||||
export default function RedirectCountDown({ initiateCountdown }: { initiateCountdown: boolean | undefined }) {
|
||||
const [timeRemaining, setTimeRemaining] = useState(3);
|
||||
|
||||
const [timeRemaining, setTimeRemaining] = useState(3)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeRemaining((prevTime) => prevTime - 1);
|
||||
}, 1000);
|
||||
|
||||
if (timeRemaining === 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
// Clean up the interval when the component is unmounted
|
||||
return () => clearInterval(interval);
|
||||
}, [timeRemaining]);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeRemaining((prevTime) => prevTime - 1);
|
||||
}, 1000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{initiateCountdown && <div className="rounded-md bg-slate-100 p-2 text-sm mt-10">
|
||||
<span>You're redirected in </span>
|
||||
<span>{timeRemaining}</span>
|
||||
</div>}
|
||||
if (timeRemaining === 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
// Clean up the interval when the component is unmounted
|
||||
return () => clearInterval(interval);
|
||||
}, [timeRemaining]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{initiateCountdown && (
|
||||
<div className="mt-10 rounded-md bg-slate-100 p-2 text-sm">
|
||||
<span>You're redirected in </span>
|
||||
<span>{timeRemaining}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ interface ThankYouCardProps {
|
||||
initiateCountdown?: boolean;
|
||||
}
|
||||
|
||||
export default function ThankYouCard({ headline, subheader, brandColor,initiateCountdown }: ThankYouCardProps) {
|
||||
export default function ThankYouCard({
|
||||
headline,
|
||||
subheader,
|
||||
brandColor,
|
||||
initiateCountdown,
|
||||
}: ThankYouCardProps) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center" style={{ color: brandColor }}>
|
||||
@@ -33,7 +38,7 @@ export default function ThankYouCard({ headline, subheader, brandColor,initiateC
|
||||
<div>
|
||||
<Headline headline={headline} questionId="thankYouCard" />
|
||||
<Subheader subheader={subheader} questionId="thankYouCard" />
|
||||
<RedirectCountDown initiateCountdown={initiateCountdown}/>
|
||||
<RedirectCountDown initiateCountdown={initiateCountdown} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
interface ContentWrapperProps {
|
||||
children: React.ReactNode;
|
||||
@@ -6,5 +6,5 @@ interface ContentWrapperProps {
|
||||
}
|
||||
|
||||
export default function ContentWrapper({ children, className }: ContentWrapperProps) {
|
||||
return <div className={clsx("mx-auto max-w-7xl p-6", className)}>{children}</div>;
|
||||
return <div className={cn("mx-auto max-w-7xl p-6", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { Person } from "@formbricks/types/js";
|
||||
|
||||
const select = {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createPerson = async (environmentId: string): Promise<Person> => {
|
||||
return await prisma.person.create({
|
||||
data: {
|
||||
@@ -10,21 +27,45 @@ export const createPerson = async (environmentId: string): Promise<Person> => {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select,
|
||||
});
|
||||
};
|
||||
|
||||
export const createPersonWithUser = async (environmentId: string, userId: string): Promise<Person> => {
|
||||
const userIdAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
environmentId,
|
||||
name: "userId",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userIdAttributeClass) {
|
||||
throw new Error("Attribute class not found for the given environmentId");
|
||||
}
|
||||
|
||||
return await prisma.person.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
create: [
|
||||
{
|
||||
attributeClass: {
|
||||
connect: {
|
||||
id: userIdAttributeClass.id,
|
||||
},
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -207,6 +207,9 @@ export const getSettings = async (environmentId: string, personId: string): Prom
|
||||
select: {
|
||||
brandColor: true,
|
||||
formbricksSignature: true,
|
||||
placement: true,
|
||||
darkOverlay: true,
|
||||
clickOutsideClose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -214,6 +217,17 @@ export const getSettings = async (environmentId: string, personId: string): Prom
|
||||
|
||||
const formbricksSignature = environmentProdut?.product.formbricksSignature;
|
||||
const brandColor = environmentProdut?.product.brandColor;
|
||||
const placement = environmentProdut?.product.placement;
|
||||
const darkOverlay = environmentProdut?.product.darkOverlay;
|
||||
const clickOutsideClose = environmentProdut?.product.clickOutsideClose;
|
||||
|
||||
return { surveys, noCodeEvents, brandColor, formbricksSignature };
|
||||
return {
|
||||
surveys,
|
||||
noCodeEvents,
|
||||
brandColor,
|
||||
formbricksSignature,
|
||||
placement,
|
||||
darkOverlay,
|
||||
clickOutsideClose,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,29 +12,6 @@ export const useApiKeys = (environmentId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const useApiKey = (environmentId: string, id: string) => {
|
||||
const { data, error, mutate } = useSWR(`/api/v1/environments/${environmentId}/api-keys/${id}`, fetcher);
|
||||
|
||||
return {
|
||||
apiKey: data,
|
||||
isLoadingApiKey: !error && !data,
|
||||
isErrorApiKey: error,
|
||||
mutateApiKey: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const persistApiKey = async (environmentId: string, apiKey) => {
|
||||
try {
|
||||
await fetch(`/api/v1/environments/${environmentId}/api-keys/${apiKey.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(apiKey),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createApiKey = async (environmentId: string, apiKey = {}) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/environments/${environmentId}/api-keys`, {
|
||||
|
||||
@@ -9,20 +9,3 @@ export async function verifyPassword(password: string, hashedPassword: string) {
|
||||
const isValid = await compare(password, hashedPassword);
|
||||
return isValid;
|
||||
}
|
||||
export function requireAuthentication(gssp: any) {
|
||||
return async (context: any) => {
|
||||
const { req, resolvedUrl } = context;
|
||||
const token = req.cookies.userToken;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/auth/login?callbackUrl=${encodeURIComponent(resolvedUrl)}`,
|
||||
statusCode: 302,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return await gssp(context); // Continue on to call `getServerSideProps` logic
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface sendEmailData {
|
||||
html: string;
|
||||
}
|
||||
|
||||
export const sendEmail = async (emailData: sendEmailData) => {
|
||||
const sendEmail = async (emailData: sendEmailData) => {
|
||||
let transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TResponseInput } from "@formbricks/types/v1/responses";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Survey } from "@formbricks/types/surveys";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useGetOrCreatePerson } from "../people/people";
|
||||
|
||||
export const useLinkSurvey = (surveyId: string) => {
|
||||
const { data, error, mutate, isLoading } = useSWR(`/api/v1/client/surveys/${surveyId}`, fetcher);
|
||||
@@ -36,22 +37,28 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
|
||||
const lastQuestion = currentQuestion?.id === survey.questions[survey.questions.length - 1].id;
|
||||
|
||||
const userId = URLParams.get("userId");
|
||||
const { person, isLoadingPerson } = useGetOrCreatePerson(survey.environmentId, isPreview ? null : userId);
|
||||
const personId = person?.data.person.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (survey) {
|
||||
setCurrentQuestion(survey.questions[0]);
|
||||
if (!isLoadingPerson) {
|
||||
if (survey) {
|
||||
setCurrentQuestion(survey.questions[0]);
|
||||
|
||||
if (isPreview) return;
|
||||
if (isPreview) return;
|
||||
|
||||
// create display
|
||||
createDisplay(
|
||||
{ surveyId: survey.id },
|
||||
`${window.location.protocol}//${window.location.host}`,
|
||||
survey.environmentId
|
||||
).then((display) => {
|
||||
setDisplayId(display.id);
|
||||
});
|
||||
// create display
|
||||
createDisplay(
|
||||
{ surveyId: survey.id },
|
||||
`${window.location.protocol}//${window.location.host}`,
|
||||
survey.environmentId
|
||||
).then((display) => {
|
||||
setDisplayId(display.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [survey, isPreview]);
|
||||
}, [survey, isPreview, isLoadingPerson]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentQuestion && survey) {
|
||||
@@ -100,7 +107,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
// build response
|
||||
const responseRequest: TResponseInput = {
|
||||
surveyId: survey.id,
|
||||
personId: null,
|
||||
personId: personId,
|
||||
finished,
|
||||
data,
|
||||
};
|
||||
@@ -307,6 +314,10 @@ const evaluateCondition = (logic: Logic, answerValue: any): boolean => {
|
||||
Array.isArray(logic.value) &&
|
||||
logic.value.some((v) => answerValue.includes(v))
|
||||
);
|
||||
case "accepted":
|
||||
return answerValue === "accepted";
|
||||
case "clicked":
|
||||
return answerValue === "clicked";
|
||||
case "submitted":
|
||||
if (typeof answerValue === "string") {
|
||||
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
|
||||
|
||||
@@ -97,3 +97,21 @@ export const resendInvite = async (teamId: string, inviteId: string) => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const shareInvite = async (teamId: string, inviteId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
const json = await res.json();
|
||||
throw Error(json.message);
|
||||
}
|
||||
return res.json();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`shareInvite: unable to get invite link: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,3 +41,15 @@ export const deletePerson = async (environmentId: string, personId: string) => {
|
||||
throw Error(`deletePerson: unable to delete person: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const useGetOrCreatePerson = (environmentId: string, personId?: string | null) => {
|
||||
const { data, isLoading } = useSWR(
|
||||
personId ? `/api/v1/client/people/getOrCreate?userId=${personId}&environmentId=${environmentId}` : null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
return {
|
||||
person: data,
|
||||
isLoadingPerson: isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
18
apps/web/lib/preview.ts
Normal file
18
apps/web/lib/preview.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
|
||||
export const getPlacementStyle = (placement: PlacementType) => {
|
||||
switch (placement) {
|
||||
case "bottomRight":
|
||||
return "bottom-3 sm:right-3";
|
||||
case "topRight":
|
||||
return "sm:top-3 sm:right-3 bottom-3";
|
||||
case "topLeft":
|
||||
return "sm:top-3 sm:left-3 bottom-3";
|
||||
case "bottomLeft":
|
||||
return "bottom-3 sm:left-3";
|
||||
case "center":
|
||||
return "top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2";
|
||||
default:
|
||||
return "bottom-3 sm:right-3";
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PresentationChartBarIcon,
|
||||
QueueListIcon,
|
||||
StarIcon,
|
||||
CheckIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { replaceQuestionPresetPlaceholders } from "./templates";
|
||||
@@ -28,6 +29,7 @@ export const questionTypes: QuestionType[] = [
|
||||
headline: "Who let the dogs out?",
|
||||
subheader: "Who? Who? Who?",
|
||||
placeholder: "Type your answer here...",
|
||||
longAnswer: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -95,6 +97,17 @@ export const questionTypes: QuestionType[] = [
|
||||
upperLabel: "Very good",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "consent",
|
||||
label: "Consent",
|
||||
description: "Ask your users to accept something",
|
||||
icon: CheckIcon,
|
||||
preset: {
|
||||
headline: "Terms and Conditions",
|
||||
label: "I agree to the terms and conditions",
|
||||
dismissButtonLabel: "Skip",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const universalQuestionPresets = {
|
||||
|
||||
@@ -15,6 +15,13 @@ export const useCreateTag = (environmentId: string) => {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (errorData?.duplicateRecord) {
|
||||
throw new Error("Tag already assigned", {
|
||||
cause: "DUPLICATE_RECORD",
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import platform from "platform";
|
||||
|
||||
export function capitalizeFirstLetter(string = "") {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
export const onlyUnique = (value, index, self) => {
|
||||
return self.indexOf(value) === index;
|
||||
};
|
||||
|
||||
export const parseUserAgent = (userAgent: string) => {
|
||||
const info = platform.parse(userAgent);
|
||||
return info.description;
|
||||
};
|
||||
|
||||
// write a function that takes a string and truncates it to the specified length
|
||||
export const truncate = (str: string, length: number) => {
|
||||
if (!str) return "";
|
||||
@@ -53,12 +42,3 @@ export function isLight(color) {
|
||||
}
|
||||
return r * 0.299 + g * 0.587 + b * 0.114 > 128;
|
||||
}
|
||||
|
||||
export const toJson = (obj: any): Object | null => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,31 +15,22 @@
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@json2csv/node": "^7.0.1",
|
||||
"@paralleldrive/cuid2": "^2.2.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@types/node": "20.2.3",
|
||||
"@types/react": "18.2.7",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.6.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dotenv-webpack": "^8.0.1",
|
||||
"eslint": "8.41.0",
|
||||
"eslint-config-next": "^13.4.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.221.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"next": "13.4.3",
|
||||
"next-auth": "^4.22.1",
|
||||
"nodemailer": "^6.9.2",
|
||||
"platform": "^1.3.6",
|
||||
"posthog-js": "^1.57.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
"preact": "10.15.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
@@ -47,10 +38,7 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-use": "^17.4.0",
|
||||
"stripe": "^12.6.0",
|
||||
"swr": "^2.1.5",
|
||||
"typescript": "5.0.4",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
@@ -63,12 +51,14 @@
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/lodash": "^4.14.195",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"@types/node": "20.2.3",
|
||||
"@types/react": "18.2.7",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"postcss": "^8.4.23",
|
||||
"rimraf": "^5.0.1",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"tailwindcss-animate": "^1.0.5"
|
||||
"typescript": "5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const formId = req.query.formId?.toString();
|
||||
|
||||
// CORS
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
// POST/capture/forms/[formId]/schema
|
||||
// Update form schema
|
||||
// Required fields in body: -
|
||||
// Optional fields in body: customerId, data
|
||||
else if (req.method === "POST") {
|
||||
if (process.env.FORMBRICKS_LEGACY_HOST) {
|
||||
const response = await fetch(
|
||||
`${process.env.FORMBRICKS_LEGACY_HOST}/api/capture/forms/${formId}/schema`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.FORMBRICKS_LEGACY_HOST}`,
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
}
|
||||
);
|
||||
const responseData = await response.json();
|
||||
res.json(responseData);
|
||||
} else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const formId = req.query.formId?.toString();
|
||||
const submissionId = req.query.submissionId?.toString();
|
||||
|
||||
// CORS
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
// PUT /capture/forms/[formId]/submissions/[submissionId]
|
||||
// Extend an existing form submission
|
||||
// Required fields in body: -
|
||||
// Optional fields in body: customerId, data
|
||||
else if (req.method === "PUT") {
|
||||
// redirect request and make a request to legacy.formbricks.com
|
||||
if (process.env.FORMBRICKS_LEGACY_HOST) {
|
||||
const response = await fetch(
|
||||
`${process.env.FORMBRICKS_LEGACY_HOST}/api/capture/forms/${formId}/submissions/${submissionId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.FORMBRICKS_LEGACY_HOST}`,
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
}
|
||||
);
|
||||
const responseData = await response.json();
|
||||
res.json(responseData);
|
||||
} else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const formId = req.query.formId?.toString();
|
||||
|
||||
// CORS
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
// POST/capture/forms/[formId]/submissions
|
||||
// Create a new form submission
|
||||
// Required fields in body: -
|
||||
// Optional fields in body: customerId, data
|
||||
else if (req.method === "POST") {
|
||||
if (process.env.FORMBRICKS_LEGACY_HOST) {
|
||||
const response = await fetch(
|
||||
`${process.env.FORMBRICKS_LEGACY_HOST}/api/capture/forms/${formId}/submissions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.FORMBRICKS_LEGACY_HOST}`,
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
}
|
||||
);
|
||||
const responseData = await response.json();
|
||||
res.json(responseData);
|
||||
} else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const environmentId = req.query?.environmentId?.toString();
|
||||
|
||||
if (!environmentId) {
|
||||
return res.status(400).json({ message: "Missing environmentId" });
|
||||
}
|
||||
|
||||
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ message: "Not authorized" });
|
||||
}
|
||||
|
||||
// POST
|
||||
if (req.method === "POST") {
|
||||
// lastSyncedAt is the last time the environment was synced (iso string)
|
||||
const { lastSyncedAt } = req.body;
|
||||
|
||||
let lastSyncedCondition = lastSyncedAt
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
createdAt: {
|
||||
gt: lastSyncedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
updatedAt: {
|
||||
gt: lastSyncedAt,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
// Get all displays that have been created or updated since lastSyncedAt
|
||||
const displays = await prisma.display.findMany({
|
||||
where: {
|
||||
survey: {
|
||||
environmentId,
|
||||
},
|
||||
...lastSyncedCondition,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
person: {
|
||||
select: {
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get all responses that have been created or updated since lastSyncedAt
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
survey: {
|
||||
environmentId,
|
||||
},
|
||||
...lastSyncedCondition,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
person: {
|
||||
select: {
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const events = [
|
||||
...displays.map((display) => ({
|
||||
name: "formbricks_survey_displayed",
|
||||
timestamp: display.createdAt,
|
||||
userId: display.person?.attributes?.find((attr) => attr.attributeClass.name === "userId")?.value,
|
||||
})),
|
||||
...responses.map((response) => ({
|
||||
name: "formbricks_response_created",
|
||||
timestamp: response.createdAt,
|
||||
userId: response.person?.attributes?.find((attr) => attr.attributeClass.name === "userId")?.value,
|
||||
})),
|
||||
];
|
||||
|
||||
return res.json({ events, lastSyncedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
interface FormbricksUser {
|
||||
userId: string;
|
||||
attributes: { [key: string]: string };
|
||||
}
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
const environmentId = req.query?.environmentId?.toString();
|
||||
|
||||
if (!environmentId) {
|
||||
return res.status(400).json({ message: "Missing environmentId" });
|
||||
}
|
||||
|
||||
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ message: "Not authorized" });
|
||||
}
|
||||
|
||||
// POST
|
||||
if (req.method === "POST") {
|
||||
// lastSyncedAt is the last time the environment was synced (iso string)
|
||||
const { users }: { users: FormbricksUser[] } = req.body;
|
||||
|
||||
for (const user of users) {
|
||||
// check if user with this userId as attribute already exists
|
||||
const existingUser = await prisma.person.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeClass: {
|
||||
name: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: user.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// user already exists, loop through attributes and update or create them
|
||||
const attributeType: "noCode" = "noCode";
|
||||
for (const key of Object.keys(user.attributes)) {
|
||||
const existingAttribute = existingUser.attributes.find(
|
||||
(attribute) => attribute.attributeClass.name === key
|
||||
);
|
||||
if (existingAttribute) {
|
||||
// skip if value is the same
|
||||
if (existingAttribute.value === user.attributes[key]) {
|
||||
continue;
|
||||
}
|
||||
await prisma.attribute.update({
|
||||
where: {
|
||||
id: existingAttribute.id,
|
||||
},
|
||||
data: {
|
||||
value: user.attributes[key].toString(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// create attribute
|
||||
await prisma.attribute.create({
|
||||
data: {
|
||||
value: user.attributes[key],
|
||||
attributeClass: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
name_environmentId: {
|
||||
name: key,
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: key,
|
||||
description: "Created by Posthog Import",
|
||||
type: attributeType,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
person: {
|
||||
connect: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper";
|
||||
import { prisma } from "@formbricks/database/src/client";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -25,9 +23,9 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(403).json({ message: "You are not authorized to access this environment! " });
|
||||
}
|
||||
|
||||
// GET /api/environments/[environmentId]/tags
|
||||
// GET /api/environments/[environmentId]/tags/count
|
||||
|
||||
// Get all tags for an environment
|
||||
// Get the count of tags on responses
|
||||
|
||||
if (req.method === "GET") {
|
||||
let tagsCounts;
|
||||
@@ -46,38 +44,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.json(tagsCounts);
|
||||
}
|
||||
|
||||
// POST /api/environments/[environmentId]/tags
|
||||
|
||||
// Create a new tag for a product
|
||||
|
||||
if (req.method === "POST") {
|
||||
const name = req.body.name;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ message: "Invalid name" });
|
||||
}
|
||||
|
||||
let tag: TTag;
|
||||
|
||||
try {
|
||||
tag = await prisma.tag.create({
|
||||
data: {
|
||||
name,
|
||||
environmentId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
return res.status(400).json({ message: "Tag already exists" });
|
||||
}
|
||||
}
|
||||
return res.status(500).json({ message: "Internal Server Error" });
|
||||
}
|
||||
|
||||
return res.json(tag);
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
|
||||
|
||||
@@ -70,7 +70,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
return res.status(400).json({ message: "Tag already exists" });
|
||||
return res.status(400).json({ duplicateRecord: true });
|
||||
}
|
||||
}
|
||||
return res.status(500).json({ message: "Internal Server Error" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getSessionUser, isAdminOrOwner } from "@/lib/api/apiHelper";
|
||||
import { sendInviteMemberEmail } from "@/lib/email";
|
||||
import { createInviteToken } from "@/lib/jwt";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
@@ -112,6 +113,28 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
return res.status(200).json(updatedInvite);
|
||||
}
|
||||
// GET /api/v1/teams/[teamId]/invite/[inviteId]
|
||||
// Retrieve an invite token
|
||||
else if (req.method === "GET") {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
return res.status(403).json({ message: "You are not allowed to share this invite link" });
|
||||
}
|
||||
|
||||
const inviteToken = createInviteToken(inviteId, invite?.email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return res.status(200).json({ inviteToken: encodeURIComponent(inviteToken) });
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "3.3"
|
||||
services:
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
image: postgres:15-alpine
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
||||
formbricks:
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"generate": "turbo run generate",
|
||||
"lint": "turbo run lint",
|
||||
"release": "turbo run build --filter=react^... && changeset publish",
|
||||
"nuke": "rm -r node_modules; for d in **/node_modules; do echo $d; rm -r $d; done"
|
||||
"nuke": "rm -r node_modules; for d in **/node_modules; do echo $d; rm -r $d; done",
|
||||
"test": "turbo run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.26.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/js-library.json",
|
||||
"include": ["**/*.ts", "tsup.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WidgetPlacement" AS ENUM ('bottomLeft', 'bottomRight', 'topLeft', 'topRight', 'center');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "clickOutsideClose" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "darkOverlay" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "placement" "WidgetPlacement" NOT NULL DEFAULT 'bottomRight';
|
||||
@@ -20,25 +20,24 @@
|
||||
"generate": "prisma generate",
|
||||
"lint": "eslint ./src --fix",
|
||||
"prebuild": "npm run generate",
|
||||
"predev": "npm run generate",
|
||||
"studio": "prisma studio"
|
||||
"predev": "npm run generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@prisma/client": "^4.15.0",
|
||||
"prisma-json-types-generator": "^2.4.0",
|
||||
"@prisma/client": "^4.16.1",
|
||||
"prisma-json-types-generator": "^2.5.0",
|
||||
"zod": "^3.21.4",
|
||||
"zod-prisma": "^0.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"eslint": "^8.41.0",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"prisma": "^4.15.0",
|
||||
"prisma": "^4.16.1",
|
||||
"prisma-dbml-generator": "^0.10.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"tsup": "^6.7.0",
|
||||
"tsup": "^7.1.0",
|
||||
"tsx": "^3.12.7",
|
||||
"typescript": "^5.0.4"
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ datasource db {
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["filteredRelationCount", "extendedWhereUnique"]
|
||||
previewFeatures = ["extendedWhereUnique"]
|
||||
//provider = "prisma-dbml-generator"
|
||||
}
|
||||
|
||||
@@ -304,17 +304,28 @@ model Environment {
|
||||
tags Tag[]
|
||||
}
|
||||
|
||||
enum WidgetPlacement {
|
||||
bottomLeft
|
||||
bottomRight
|
||||
topLeft
|
||||
topRight
|
||||
center
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId String
|
||||
environments Environment[]
|
||||
brandColor String @default("#64748b")
|
||||
recontactDays Int @default(7)
|
||||
formbricksSignature Boolean @default(true)
|
||||
brandColor String @default("#64748b")
|
||||
recontactDays Int @default(7)
|
||||
formbricksSignature Boolean @default(true)
|
||||
placement WidgetPlacement @default(bottomRight)
|
||||
clickOutsideClose Boolean @default(true)
|
||||
darkOverlay Boolean @default(false)
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/node16.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "tsup.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "dist", "zod"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/database": "workspace:*",
|
||||
"next": "^13.4.4"
|
||||
"next": "^13.4.4",
|
||||
"stripe": "^12.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
9
packages/errors/LICENSE
Normal file
9
packages/errors/LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Matthias Nannt, Johannes Dancker
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@formbricks/errors",
|
||||
"license": "MIT",
|
||||
"description": "A helper package containing general error classes for Formbricks",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -15,8 +15,8 @@
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint ./src --fix",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/node16.json",
|
||||
"include": ["**/*.ts", "tsup.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
6
packages/js/babel.config.js
Normal file
6
packages/js/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = (api) => {
|
||||
return {
|
||||
presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript"],
|
||||
plugins: [["@babel/plugin-transform-react-jsx", { runtime: api.env("test") ? "automatic" : "classic" }]],
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user