diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e9625dc38f..402b8d37de 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f1547a39e3..bcc25bd390 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -31,6 +31,8 @@ Fixes # (issue) +- [ ] 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 diff --git a/LICENSE b/LICENSE index 81998a1128..549e017679 100644 --- a/LICENSE +++ b/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. diff --git a/README.md b/README.md index a6677ea3ab..66e15df09e 100644 --- a/README.md +++ b/README.md @@ -12,67 +12,89 @@

-License Join Formbricks Discord Github Stars +License Join Formbricks Discord Github Stars Hacker News Product Hunt + Github Accelerator +


-## About Formbricks +

+Trusted by      +      +      +      + +

-formbricks-sneak +## ✨ 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. +formbricks-sneak + +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. + +Book us with Cal.com + +## βš–οΈ 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. diff --git a/apps/demo/components/ConsoleFeed.tsx b/apps/demo/components/ConsoleFeed.tsx deleted file mode 100644 index d359eb3eff..0000000000 --- a/apps/demo/components/ConsoleFeed.tsx +++ /dev/null @@ -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(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 ( - <> - -
- - ); -}; - -export { LogsContainer }; diff --git a/apps/demo/package.json b/apps/demo/package.json index fe1bdd65d8..6548a5484c 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -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" } } diff --git a/apps/demo/pages/app/index.tsx b/apps/demo/pages/app/index.tsx index d76978548c..393a17ddd1 100644 --- a/apps/demo/pages/app/index.tsx +++ b/apps/demo/pages/app/index.tsx @@ -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({}) {

fb setup
-
+ {/*

Console

You can also open your browser console to logs:

-
+
*/}
diff --git a/apps/formbricks-com/components/dummyUI/templates.ts b/apps/formbricks-com/components/dummyUI/templates.ts index 13f5372750..eec443a3a6 100644 --- a/apps/formbricks-com/components/dummyUI/templates.ts +++ b/apps/formbricks-com/components/dummyUI/templates.ts @@ -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, }, { diff --git a/apps/formbricks-com/components/home/GitHubSponsorship.tsx b/apps/formbricks-com/components/home/GitHubSponsorship.tsx index d426a5ef09..c26ee3c187 100644 --- a/apps/formbricks-com/components/home/GitHubSponsorship.tsx +++ b/apps/formbricks-com/components/home/GitHubSponsorship.tsx @@ -23,10 +23,10 @@ export const GitHubSponsorship: React.FC = () => { />

- Sponsored by GitHub + Proudly Open-Source 🀍

- We're proud to join the first accelerator program by GitHub!{" "} + We're proud to to be supported by GitHubs Open-Source Program!{" "} {

- Survey any segment.{" "} - - No coding required. - + Create Products People Remember

- Survey granular user segments at any point in the user journey. + Understand what customers think & feel about your product.
- Gather up to 6x more insights with targeted micro-surveys.{" "} - All open-source. + Continuously gather deep user insights,{" "} + all privacy-first.

diff --git a/apps/formbricks-com/components/shared/Footer.tsx b/apps/formbricks-com/components/shared/Footer.tsx index b226f74dc0..081277a414 100644 --- a/apps/formbricks-com/components/shared/Footer.tsx +++ b/apps/formbricks-com/components/shared/Footer.tsx @@ -48,9 +48,7 @@ export default function Footer() { Formbricks -

- Make customer-centric decisions based on data. -

+

Privacy-first Experience Management

© 2022. All rights reserved. diff --git a/apps/formbricks-com/components/shared/Header.tsx b/apps/formbricks-com/components/shared/Header.tsx index 0d630511e6..000b1e84fb 100644 --- a/apps/formbricks-com/components/shared/Header.tsx +++ b/apps/formbricks-com/components/shared/Header.tsx @@ -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() { */} - Community + Concierge

@@ -281,21 +283,24 @@ export default function Header() { className="group px-2" href="https://formbricks.com/github" target="_blank"> - + GitHub Sponsors Formbricks badge + GitHub Sponsors Formbricks badge {/* */} - {/* */}
)} - Community + Concierge Pricing Docs Blog diff --git a/apps/formbricks-com/components/shared/Pricing.tsx b/apps/formbricks-com/components/shared/Pricing.tsx index de3bda807d..ba0e91e2bb 100644 --- a/apps/formbricks-com/components/shared/Pricing.tsx +++ b/apps/formbricks-com/components/shared/Pricing.tsx @@ -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() { ))}
-
- -
); } diff --git a/apps/formbricks-com/lib/cn.ts b/apps/formbricks-com/lib/cn.ts deleted file mode 100644 index cec6ac9e86..0000000000 --- a/apps/formbricks-com/lib/cn.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/apps/formbricks-com/next.config.mjs b/apps/formbricks-com/next.config.mjs index 9e85ae1d90..7633dace97 100644 --- a/apps/formbricks-com/next.config.mjs +++ b/apps/formbricks-com/next.config.mjs @@ -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", diff --git a/apps/formbricks-com/package.json b/apps/formbricks-com/package.json index 3138815893..ff595434a8 100644 --- a/apps/formbricks-com/package.json +++ b/apps/formbricks-com/package.json @@ -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" } } diff --git a/apps/formbricks-com/pages/concierge.tsx b/apps/formbricks-com/pages/concierge.tsx new file mode 100644 index 0000000000..bf0ff7198c --- /dev/null +++ b/apps/formbricks-com/pages/concierge.tsx @@ -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 ( + + +
+
+ {XMOffer.map((offer) => ( +
+
+ {offer.step} +
+
+

{offer.header}

+

{offer.description}

+
+
+ ))} +
+

$2.290

+
+
+

+ + 100% Risk-free: Pay after the kick-off call. +

+

+ + Money-back: If you're not happy, get a full refund. +

+
+
+
+ +
+
+
+ ); +}; + +export default ConciergePage; diff --git a/apps/formbricks-com/pages/index.tsx b/apps/formbricks-com/pages/index.tsx index 418d831b0c..49824b0327 100644 --- a/apps/formbricks-com/pages/index.tsx +++ b/apps/formbricks-com/pages/index.tsx @@ -10,7 +10,7 @@ import BestPractices from "@/components/shared/BestPractices"; const IndexPage = () => (
diff --git a/apps/web/README.md b/apps/web/README.md index f47558bb4f..7f6bb8e358 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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: diff --git a/apps/web/app/ClientLogout.tsx b/apps/web/app/ClientLogout.tsx new file mode 100644 index 0000000000..d5f26fc4c8 --- /dev/null +++ b/apps/web/app/ClientLogout.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { useEffect } from "react"; + +export default function ClientLogout() { + useEffect(() => { + signOut(); + }); + return null; +} diff --git a/apps/web/app/api/v1/client/actions/route.ts b/apps/web/app/api/v1/client/actions/route.ts deleted file mode 100644 index 4db063e56e..0000000000 --- a/apps/web/app/api/v1/client/actions/route.ts +++ /dev/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 { - return responses.successResponse({}, true); -} - -export async function POST(request: Request): Promise { - 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); -} diff --git a/apps/web/app/api/v1/client/people/getOrCreate/route.ts b/apps/web/app/api/v1/client/people/getOrCreate/route.ts new file mode 100644 index 0000000000..1b3de716fa --- /dev/null +++ b/apps/web/app/api/v1/client/people/getOrCreate/route.ts @@ -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 { + 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); +} diff --git a/apps/web/app/api/v1/client/sessions/route.ts b/apps/web/app/api/v1/client/sessions/route.ts deleted file mode 100644 index 55bb3bea60..0000000000 --- a/apps/web/app/api/v1/client/sessions/route.ts +++ /dev/null @@ -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 { - return responses.successResponse({}, true); -} - -export async function POST(request: Request): Promise { - 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); -} diff --git a/apps/web/app/api/v1/client/settings/route.ts b/apps/web/app/api/v1/client/settings/route.ts deleted file mode 100644 index 23199d7383..0000000000 --- a/apps/web/app/api/v1/client/settings/route.ts +++ /dev/null @@ -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 { - return responses.successResponse({}, true); -} - -export async function POST(request: Request): Promise { - 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); -} diff --git a/apps/web/app/api/v1/client/users/[userCuid]/attribute/route.ts b/apps/web/app/api/v1/client/users/[userCuid]/attribute/route.ts deleted file mode 100644 index 3925e2a2a1..0000000000 --- a/apps/web/app/api/v1/client/users/[userCuid]/attribute/route.ts +++ /dev/null @@ -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 { - return responses.successResponse({}, true); -} - -export async function POST(request: Request, params: { userCuid: string }): Promise { - 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); -} diff --git a/apps/web/app/api/v1/client/users/[userCuid]/user-id/route.ts b/apps/web/app/api/v1/client/users/[userCuid]/user-id/route.ts deleted file mode 100644 index e55f41977b..0000000000 --- a/apps/web/app/api/v1/client/users/[userCuid]/user-id/route.ts +++ /dev/null @@ -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 { - return responses.successResponse({}, true); -} - -export async function POST(request: Request, params: { userCuid: string }): Promise { - 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); -} diff --git a/apps/web/app/api/v1/client/users/route.ts b/apps/web/app/api/v1/client/users/route.ts deleted file mode 100644 index d8e04b4b5c..0000000000 --- a/apps/web/app/api/v1/client/users/route.ts +++ /dev/null @@ -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 { - return responses.successResponse({}, true); -} - -export async function POST(request: Request): Promise { - 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); -} diff --git a/apps/web/app/api/v1/responses/route.ts b/apps/web/app/api/v1/responses/route.ts new file mode 100644 index 0000000000..9181674e4d --- /dev/null +++ b/apps/web/app/api/v1/responses/route.ts @@ -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; + } +} diff --git a/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx b/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx index 4d0bb98f90..fa15f798a1 100644 --- a/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx +++ b/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx @@ -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 ))} + {IS_FORMBRICKS_CLOUD && ( + + + + )} { setLoading(true); diff --git a/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx b/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx index 4fe9cdcb1a..6e62ae9e5e 100644 --- a/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx +++ b/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx @@ -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 (
@@ -105,9 +107,6 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
-
- Limited Early Bird Deal -

Pro

@@ -126,9 +125,7 @@ export default function PricingTable({ environmentId, session }: PricingTablePro ))}

- - $9949$ - + $99 / month

diff --git a/apps/web/app/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx b/apps/web/app/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx index e1c54a03df..43fdfc3fa5 100644 --- a/apps/web/app/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx +++ b/apps/web/app/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx @@ -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("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 ; } - if (isErrorEnvironment) { + if (isErrorProduct) { return ; } 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 (
- + setCurrentPlacement(e as PlacementType)} value={currentPlacement}> {placements.map((placement) => (
- +
-
diff --git a/apps/web/app/environments/[environmentId]/settings/lookandfeel/page.tsx b/apps/web/app/environments/[environmentId]/settings/lookandfeel/page.tsx index c9f70203dd..be5702fef4 100644 --- a/apps/web/app/environments/[environmentId]/settings/lookandfeel/page.tsx +++ b/apps/web/app/environments/[environmentId]/settings/lookandfeel/page.tsx @@ -10,7 +10,6 @@ export default function ProfileSettingsPage({ params }: { params: { environmentI diff --git a/apps/web/app/environments/[environmentId]/settings/members/EditMemberships.tsx b/apps/web/app/environments/[environmentId]/settings/members/EditMemberships.tsx index 95e6b895bd..db68fc2ca0 100644 --- a/apps/web/app/environments/[environmentId]/settings/members/EditMemberships.tsx +++ b/apps/web/app/environments/[environmentId]/settings/members/EditMemberships.tsx @@ -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(""); 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) { )}
-
-
-
Fullname
-
Email
-
Role
-
+
+
+
Fullname
+
Email
+
Role
+
-
+
{[...team.members, ...team.invitees].map((member) => (
-
+
-
+

{member.name}

-
+
{member.email}
-
+
-
- {!member.accepted && } +
+ {!member.accepted && } {member.role !== "owner" && ( + + + Share Invite Link + + +
+
+
+ + ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/PreviewSurvey.tsx b/apps/web/app/environments/[environmentId]/surveys/PreviewSurvey.tsx index fe0402d069..06f05b275b 100644 --- a/apps/web/app/environments/[environmentId]/surveys/PreviewSurvey.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/PreviewSurvey.tsx @@ -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({
{previewType === "modal" ? ( - + {!countdownStop && autoClose !== null && autoClose > 0 && ( )} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/ResponsesLimitReachedBanner.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/ResponsesLimitReachedBanner.tsx index ea77891a06..06b3dcd1a1 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/ResponsesLimitReachedBanner.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/ResponsesLimitReachedBanner.tsx @@ -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 && ( diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/ConsentQuestionForm.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/ConsentQuestionForm.tsx new file mode 100644 index 0000000000..42b13d4b26 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/ConsentQuestionForm.tsx @@ -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 ( +
+
+ +
+ updateQuestion(questionIdx, { headline: e.target.value })} + /> +
+
+ +
+ +
+ + 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} + /> +
+
+ +
+ + updateQuestion(questionIdx, { label: e.target.value })} + /> +
+ {/*
+ + updateQuestion(questionIdx, { buttonLabel: e.target.value })} + /> +
*/} +
+ ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx index b4ada2d79f..47949ae64e 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx @@ -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, diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx index bee17d67fb..8a42182e05 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx @@ -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({ ) : question.type === QuestionType.Rating ? ( + ) : question.type === "consent" ? ( + ) : null}
@@ -174,6 +178,13 @@ export default function QuestionCard({ updateQuestion={updateQuestion} lastQuestion={lastQuestion} /> + ) : question.type === "consent" ? ( + ) : null}
@@ -216,8 +227,24 @@ export default function QuestionCard({ {open && ( -
-
+
+ {question.type === "openText" && ( +
+ + { + e.stopPropagation(); + updateQuestion(questionIdx, { + longAnswer: + typeof question.longAnswer === "undefined" ? false : !question.longAnswer, + }); + }} + /> +
+ )} +
)} -
-
- - + {localSurvey.type === "link" && ( +
+
+ + +
+
+ {redirectToggle && ( + handleRedirectUrlChange(e.target.value)} + /> + )} +
-
- {redirectToggle && ( - handleRedirectUrlChange(e.target.value)} - /> - )} -
-
+ )}
diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/AudienceView.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SettingsView.tsx similarity index 80% rename from apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/AudienceView.tsx rename to apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SettingsView.tsx index e37b7165f4..9d96432cf1 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/AudienceView.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/SettingsView.tsx @@ -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 (
- {localSurvey.type==="link" && } + ) : ( - = ({ 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 = ({ 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 = ({ 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: , + }); + + 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 = ({ onSuccess: () => { setSearchValue(""); setOpen(false); - mutateResponses(); - refetchEnvironmentTags(); + + router.refresh(); }, } ); diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/Tag.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/Tag.tsx index 52ef533864..7d2708e862 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/Tag.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/Tag.tsx @@ -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" )}>
{tagName} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx index 0ed99e96ed..30f6492a87 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx @@ -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 ( <> + {/* @ts-expect-error Server Component */} ; +} + +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 ( +
+
+
+

{questionSummary.question.headline}

+
+
+
Consent
+
+ + {ctr.count} responses +
+
+
+
+
+
+
+

Accepted

+
+

+ {Math.round(ctr.acceptedPercentage * 100)}% +

+
+
+

+ {ctr.acceptedCount} {ctr.acceptedCount === 1 ? "response" : "responses"} +

+
+ +
+
+
+
+

Skipped

+
+

+ {Math.round(ctr.dismissedPercentage * 100)}% +

+
+
+

+ {ctr.dismissedCount} {ctr.dismissedCount === 1 ? "response" : "responses"} +

+
+ +
+
+
+ ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkModalButton.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkModalButton.tsx new file mode 100644 index 0000000000..7ac6cf86c6 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/LinkModalButton.tsx @@ -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 ( + <> + + {showLinkModal && } + + ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/StatusDropdown.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/StatusDropdown.tsx new file mode 100644 index 0000000000..b970c69ee7 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/StatusDropdown.tsx @@ -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 ; + } + + if (isErrorEnvironment) { + return ; + } + + return ( + <> + {environment.widgetSetupCompleted || survey.type === "link" ? ( + + ) : null} + + ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SuccessMessage.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SuccessMessage.tsx new file mode 100644 index 0000000000..dcd3248055 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SuccessMessage.tsx @@ -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 && } + {confetti && } + + ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx index e14b05c891..40b0784e2e 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx @@ -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[] = []; - 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[] => + 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 ( = 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 ; - } - - if (isErrorEnvironment) { - return ; - } + const completionRate = !responses + ? 0 + : (responses.filter((r) => r.finished).length / responses.length) * 100; return ( -
-
-
-
-

Survey displays

-

- {survey.analytics.numDisplays === 0 ? - : survey.analytics.numDisplays} -

+ <> +
+
+
+
+

Survey displays

+

+ {survey.analytics.numDisplays === 0 ? - : survey.analytics.numDisplays} +

+
+
+

Total Responses

+

+ {responses.length === 0 ? - : responses.length} +

+
+ + + +
+

+ Response % + +

+

+ {survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? ( + - + ) : ( + {Math.round(survey.analytics.responseRate * 100)} % + )} +

+
+
+ +

% of people who responded when survey was shown.

+
+
+
+ + + +
+

+ Completion % + +

+

+ {responses.length === 0 ? ( + - + ) : ( + {parseFloat(completionRate.toFixed(2))} % + )} +

+
+
+ +

+ % of people who started and completed the survey. +

+
+
+
-
-

Total Responses

-

- {responses.length === 0 ? - : responses.length} -

-
- - - -
-

- Response % - -

-

- {survey.analytics.responseRate === null || survey.analytics.responseRate === 0 ? ( - - - ) : ( - {Math.round(survey.analytics.responseRate * 100)} % - )} -

-
-
- -

% of people who responded when survey was shown.

-
-
-
- - - -
-

- Completion % - -

-

- {responses.length === 0 ? ( - - - ) : ( - {parseFloat(completionRate.toFixed(2))} % - )} -

-
-
- -

- % of people who started and completed the survey. -

-
-
-
-
-
-
- Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())} -
-
- {survey.type === "link" && ( - - )} +
+
+ Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())} +
+
+ {survey.type === "link" && } - {environment.widgetSetupCompleted || survey.type === "link" ? ( - - ) : null} - + + +
- {showLinkModal && } - {confetti && } -
+ + ); } diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SurveyCreatedSuccessModal.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SurveyCreatedSuccessModal.tsx deleted file mode 100644 index c163ed5076..0000000000 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SurveyCreatedSuccessModal.tsx +++ /dev/null @@ -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 ( - <> - - Your survey is live and collecting valuable insights! - - - ); -} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/page.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/page.tsx index 20cbac6912..8b4a8fe4df 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/page.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/page.tsx @@ -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 ( <> + {/* @ts-expect-error Server Component */} - - + {/* @ts-expect-error Server Component */} + + {/* @ts-expect-error Server Component */} + ); diff --git a/apps/web/app/environments/[environmentId]/surveys/templates/TemplateMenuBar.tsx b/apps/web/app/environments/[environmentId]/surveys/templates/TemplateMenuBar.tsx deleted file mode 100644 index 511925e32d..0000000000 --- a/apps/web/app/environments/[environmentId]/surveys/templates/TemplateMenuBar.tsx +++ /dev/null @@ -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 ( -
-
- -

Start with a template

-
-
- -
-
- ); -} diff --git a/apps/web/app/environments/[environmentId]/surveys/templates/page.tsx b/apps/web/app/environments/[environmentId]/surveys/templates/page.tsx index 15eb6badfa..6e9a1be8e1 100644 --- a/apps/web/app/environments/[environmentId]/surveys/templates/page.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/templates/page.tsx @@ -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 (
- {/* */}

Create a new survey

diff --git a/apps/web/app/environments/[environmentId]/surveys/templates/templates.ts b/apps/web/app/environments/[environmentId]/surveys/templates/templates.ts index bd6f8c6a37..2e81b92472 100644 --- a/apps/web/app/environments/[environmentId]/surveys/templates/templates.ts +++ b/apps/web/app/environments/[environmentId]/surveys/templates/templates.ts @@ -252,7 +252,7 @@ export const templates: Template[] = [ id: "mao94214zoo6c1at5rpuz7io", html: '

We\'d love to keep you as a customer. Happy to offer a 30% discount for the next year.

', 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: '

We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.

', 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: '

We\'re happy to offer you a 20% discount on a yearly plan.

', 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: '

This helps us a lot.

', 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: '

We will fix this as soon as possible. Do you want to be notified when we did?

', type: QuestionType.CTA, logic: [ - { condition: "submitted", destination: "end" }, + { condition: "clicked", destination: "end" }, { condition: "skipped", destination: "end" }, ], headline: "Want to stay in the loop?", diff --git a/apps/web/app/onboarding/Onboarding.tsx b/apps/web/app/onboarding/Onboarding.tsx index 172e92589f..f3dce61a1f 100644 --- a/apps/web/app/onboarding/Onboarding.tsx +++ b/apps/web/app/onboarding/Onboarding.tsx @@ -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(); @@ -36,7 +40,7 @@ export default function Onboarding({ session }: OnboardingProps) { return currentStep / MAX_STEPS; }, [currentStep]); - if (!profile) { + if (!profile || isLoadingEnvironment) { return (
@@ -44,7 +48,7 @@ export default function Onboarding({ session }: OnboardingProps) { ); } - if (error) { + if (isErrorEnvironment) { return
An error occurred
; } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index b516bd63fc..8c0a36a8b7 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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 ; } return redirect(`/environments/${environment.id}`); diff --git a/apps/web/components/Smileys.tsx b/apps/web/components/Smileys.tsx index 8115f244d4..1dc7b009a6 100644 --- a/apps/web/components/Smileys.tsx +++ b/apps/web/components/Smileys.tsx @@ -460,5 +460,3 @@ export const GrinningSquintingFace: React.FC> = ); }; - -export let icons = []; diff --git a/apps/web/components/auth/SignupForm.tsx b/apps/web/components/auth/SignupForm.tsx index a9e0e40319..dc93f572fb 100644 --- a/apps/web/components/auth/SignupForm.tsx +++ b/apps/web/components/auth/SignupForm.tsx @@ -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(null); - const [password, setPassword] = useState(null) - const [isValid, setIsValid] = useState(false) + const [password, setPassword] = useState(null); + const [isValid, setIsValid] = useState(false); const checkFormValidity = () => { // If all fields are filled, enable the button @@ -145,7 +145,7 @@ export const SignupForm = () => { )} diff --git a/apps/web/components/preview/ConsentQuestion.tsx b/apps/web/components/preview/ConsentQuestion.tsx new file mode 100644 index 0000000000..a2b9dc1e33 --- /dev/null +++ b/apps/web/components/preview/ConsentQuestion.tsx @@ -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 ( +
+ + + +
{ + e.preventDefault(); + + const checkbox = document.getElementById(question.id) as HTMLInputElement; + onSubmit({ [question.id]: checkbox.checked ? "accepted" : "dismissed" }); + }}> + + +
+ +
+
+
+ ); +} diff --git a/apps/web/components/preview/Modal.tsx b/apps/web/components/preview/Modal.tsx index bcc619a205..9ca7e02300 100644 --- a/apps/web/components/preview/Modal.tsx +++ b/apps/web/components/preview/Modal.tsx @@ -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 ( -
+
{children}
diff --git a/apps/web/components/preview/OpenTextQuestion.tsx b/apps/web/components/preview/OpenTextQuestion.tsx index a076c3f4d5..296f1fb10b 100644 --- a/apps/web/components/preview/OpenTextQuestion.tsx +++ b/apps/web/components/preview/OpenTextQuestion.tsx @@ -33,16 +33,30 @@ export default function OpenTextQuestion({
- + {question.longAnswer === false ? ( + 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" + /> + ) : ( + + {question.longAnswer === false ? ( + + ) : ( + + )}
diff --git a/packages/js/src/components/QuestionConditional.tsx b/packages/js/src/components/QuestionConditional.tsx index 19b45ee225..73f94ab8ac 100644 --- a/packages/js/src/components/QuestionConditional.tsx +++ b/packages/js/src/components/QuestionConditional.tsx @@ -6,6 +6,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; @@ -62,5 +63,12 @@ export default function QuestionConditional({ lastQuestion={lastQuestion} brandColor={brandColor} /> + ) : question.type === "consent" ? ( + ) : null; } diff --git a/packages/js/src/components/SurveyView.tsx b/packages/js/src/components/SurveyView.tsx index e358c92838..ae110c4d51 100644 --- a/packages/js/src/components/SurveyView.tsx +++ b/packages/js/src/components/SurveyView.tsx @@ -121,6 +121,10 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv 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; diff --git a/packages/js/src/lib/noCodeEvents.ts b/packages/js/src/lib/noCodeEvents.ts index c06dbe4bd1..06eea331b3 100644 --- a/packages/js/src/lib/noCodeEvents.ts +++ b/packages/js/src/lib/noCodeEvents.ts @@ -12,6 +12,10 @@ const errorHandler = ErrorHandler.getInstance(); export const checkPageUrl = async (): Promise> => { logger.debug(`Checking page url: ${window.location.href}`); const { settings } = config.get(); + if (settings?.noCodeEvents === undefined) { + return okVoid(); + } + const pageUrlEvents: Event[] = settings?.noCodeEvents.filter((e) => e.noCodeConfig?.type === "pageUrl"); if (pageUrlEvents.length === 0) { diff --git a/packages/js/tests/__mocks__/apiMock.ts b/packages/js/tests/__mocks__/apiMock.ts new file mode 100644 index 0000000000..193724d4d2 --- /dev/null +++ b/packages/js/tests/__mocks__/apiMock.ts @@ -0,0 +1,286 @@ +import fetchMock from "jest-fetch-mock"; +import { constants } from "../constants"; + +const { + environmentId, + apiHost, + sessionId, + expiryTime, + surveyId, + questionOneId, + questionTwoId, + choiceOneId, + choiceTwoId, + choiceThreeId, + initialPersonUid, + initialUserId, + initialUserEmail, + newPersonUid, + eventIdForRouteChange, + updatedUserEmail, + customAttributeKey, + customAttributeValue, + eventIdForEventTracking, + userIdAttributeId, + userInitialEmailAttributeId, + userCustomAttrAttributeId, + userUpdatedEmailAttributeId, +} = constants; + +export const mockInitResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + apiHost, + environmentId, + person: { + id: initialPersonUid, + environmentId, + attributes: [], + }, + session: { + id: sessionId, + expiresAt: expiryTime, + }, + settings: { + surveys: [ + { + id: surveyId, + questions: [ + { + id: questionOneId, + type: "multipleChoiceSingle", + choices: [ + { + id: choiceOneId, + label: "Not at all disappointed", + }, + { + id: choiceTwoId, + label: "Somewhat disappointed", + }, + { + id: choiceThreeId, + label: "Very disappointed", + }, + ], + headline: "How disappointed would you be if you could no longer use Test-Formbricks?", + required: true, + subheader: "Please select one of the following options:", + }, + { + id: questionTwoId, + type: "openText", + headline: "How can we improve Test-Formbricks for you?", + required: true, + subheader: "Please be as specific as possible.", + }, + ], + triggers: [], + thankYouCard: { + enabled: true, + headline: "Thank you!", + subheader: "We appreciate your feedback.", + }, + autoClose: null, + delay: 0, + }, + ], + noCodeEvents: [], + brandColor: "#20b398", + formbricksSignature: true, + placement: "bottomRight", + darkOverlay: false, + clickOutsideClose: true, + }, + }) + ); +}; + +export const mockSetUserIdResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + apiHost, + environmentId, + settings: { + surveys: [], + noCodeEvents: [], + }, + person: { + id: initialPersonUid, + environmentId, + attributes: [ + { + id: userIdAttributeId, + value: initialUserId, + attributeClass: { + id: environmentId, + name: "userId", + }, + }, + ], + }, + }) + ); +}; + +export const mockSetEmailIdResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + apiHost, + environmentId, + settings: { + surveys: [], + noCodeEvents: [], + }, + person: { + id: initialPersonUid, + environmentId, + attributes: [ + { + id: userIdAttributeId, + value: initialUserId, + attributeClass: { + id: environmentId, + name: "userId", + }, + }, + { + id: userInitialEmailAttributeId, + value: initialUserEmail, + attributeClass: { + id: environmentId, + name: "email", + }, + }, + ], + }, + }) + ); +}; + +export const mockSetCustomAttributeResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + apiHost, + environmentId, + settings: { + surveys: [], + noCodeEvents: [], + }, + person: { + id: initialPersonUid, + environmentId, + attributes: [ + { + id: userIdAttributeId, + value: initialUserId, + attributeClass: { + id: environmentId, + name: "userId", + }, + }, + { + id: userInitialEmailAttributeId, + value: initialUserEmail, + attributeClass: { + id: environmentId, + name: "email", + }, + }, + { + id: userCustomAttrAttributeId, + value: customAttributeValue, + attributeClass: { + id: environmentId, + name: customAttributeKey, + }, + }, + ], + }, + }) + ); +}; + +export const mockUpdateEmailResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + apiHost, + environmentId, + settings: { + surveys: [], + noCodeEvents: [], + }, + person: { + id: initialPersonUid, + environmentId, + attributes: [ + { + id: userIdAttributeId, + value: initialUserId, + attributeClass: { + id: environmentId, + name: "userId", + }, + }, + { + id: userUpdatedEmailAttributeId, + value: updatedUserEmail, + attributeClass: { + id: environmentId, + name: "email", + }, + }, + { + id: userCustomAttrAttributeId, + value: customAttributeValue, + attributeClass: { + id: environmentId, + name: customAttributeKey, + }, + }, + ], + }, + }) + ); +}; + +export const mockEventTrackResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + id: eventIdForEventTracking, + }) + ); + console.log('Formbricks: Event "Button Clicked" tracked'); +}; + +export const mockRefreshResponse = () => { + fetchMock.mockResponseOnce(JSON.stringify({})); + console.log("Settings refreshed"); +}; + +export const mockRegisterRouteChangeResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + id: eventIdForRouteChange, + }) + ); + console.log("Checking page url: http://localhost/"); +}; + +export const mockLogoutResponse = () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + settings: { + surveys: [], + noCodeEvents: [], + }, + person: { + id: newPersonUid, + environmentId, + attributes: [], + }, + session: {}, + }) + ); + console.log("Resetting person. Getting new person, session and settings from backend"); +}; diff --git a/packages/js/tests/__mocks__/fileMock.js b/packages/js/tests/__mocks__/fileMock.js new file mode 100644 index 0000000000..1d87881797 --- /dev/null +++ b/packages/js/tests/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'placeholer-to-not-mock-all-files-as-js'; diff --git a/packages/js/tests/__mocks__/setupTests.js b/packages/js/tests/__mocks__/setupTests.js index d0bbb11755..47833658f7 100644 --- a/packages/js/tests/__mocks__/setupTests.js +++ b/packages/js/tests/__mocks__/setupTests.js @@ -1,6 +1,10 @@ -import { configure } from "enzyme"; -import Adapter from "enzyme-adapter-preact-pure"; +/** @type {import('jest').Config} */ +const config = { + verbose: true, + testEnvironment: "jsdom" +}; -configure({ - adapter: new Adapter() -}); \ No newline at end of file +import fetchMock from "jest-fetch-mock"; +fetchMock.enableMocks(); + +module.exports = config; diff --git a/packages/js/tests/__mocks__/styleMock.js b/packages/js/tests/__mocks__/styleMock.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/packages/js/tests/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/js/tests/constants.ts b/packages/js/tests/constants.ts new file mode 100644 index 0000000000..83380b38c1 --- /dev/null +++ b/packages/js/tests/constants.ts @@ -0,0 +1,59 @@ +const generateUserId = () => { + const min = 1000; + const max = 9999; + const randomNum = Math.floor(Math.random() * (max - min + 1) + min); + return randomNum.toString(); +}; + +const generateEmailId = () => { + const domain = "formbricks.test"; + const randomString = Math.random().toString(36).substring(2); + const emailId = `${randomString}@${domain}`; + return emailId; +}; + +const generateRandomString = () => { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const maxLength = 8; + + let randomString = ""; + for (let i = 0; i < maxLength; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + randomString += characters.charAt(randomIndex); + } + return randomString; +}; + +const getOneDayExpiryTime = () => { + var ms = new Date().getTime(); + var oneDayMs = 24 * 60 * 60 * 1000; // Number of milliseconds in one day + var expiryOfOneDay = ms + oneDayMs; + return expiryOfOneDay; +}; + +export const constants = { + environmentId: "mockedEnvironmentId", + apiHost: "mockedApiHost", + sessionId: generateRandomString(), + expiryTime: getOneDayExpiryTime(), + surveyId: generateRandomString(), + questionOneId: generateRandomString(), + questionTwoId: generateRandomString(), + choiceOneId: generateRandomString(), + choiceTwoId: generateRandomString(), + choiceThreeId: generateRandomString(), + choiceFourId: generateRandomString(), + initialPersonUid: generateRandomString(), + newPersonUid: generateRandomString(), + initialUserId: generateUserId(), + initialUserEmail: generateEmailId(), + updatedUserEmail: generateEmailId(), + customAttributeKey: generateRandomString(), + customAttributeValue: generateRandomString(), + userIdAttributeId: generateRandomString(), + userInitialEmailAttributeId: generateRandomString(), + userCustomAttrAttributeId: generateRandomString(), + userUpdatedEmailAttributeId: generateRandomString(), + eventIdForEventTracking: generateRandomString(), + eventIdForRouteChange: generateRandomString(), +} as const; diff --git a/packages/js/tests/declarations.d.ts b/packages/js/tests/declarations.d.ts deleted file mode 100644 index 67e9402778..0000000000 --- a/packages/js/tests/declarations.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Enable enzyme adapter's integration with TypeScript -// See: https://github.com/preactjs/enzyme-adapter-preact-pure#usage-with-typescript -/// diff --git a/packages/js/tests/index.test.ts b/packages/js/tests/index.test.ts new file mode 100644 index 0000000000..789fb969bd --- /dev/null +++ b/packages/js/tests/index.test.ts @@ -0,0 +1,200 @@ +/** + * @jest-environment jsdom + */ +import formbricks from "../src/index"; +import { constants } from "./constants"; +import { Attribute } from "./types"; +import { + mockEventTrackResponse, + mockInitResponse, + mockLogoutResponse, + mockRefreshResponse, + mockRegisterRouteChangeResponse, + mockSetCustomAttributeResponse, + mockSetEmailIdResponse, + mockSetUserIdResponse, + mockUpdateEmailResponse, +} from "./__mocks__/apiMock"; + +const consoleLogMock = jest.spyOn(console, "log").mockImplementation(); + +test("Test Jest", () => { + expect(1 + 9).toBe(10); +}); + +const { + environmentId, + apiHost, + initialUserId, + initialUserEmail, + updatedUserEmail, + customAttributeKey, + customAttributeValue, +} = constants; + +beforeEach(() => { + fetchMock.resetMocks(); +}); + +test("Formbricks should Initialise", async () => { + mockInitResponse(); + + await formbricks.init({ + environmentId, + apiHost, + }); + + const configFromBrowser = localStorage.getItem("formbricksConfig"); + expect(configFromBrowser).toBeTruthy(); + + if (configFromBrowser) { + const jsonSavedConfig = JSON.parse(configFromBrowser); + expect(jsonSavedConfig.environmentId).toStrictEqual(environmentId); + expect(jsonSavedConfig.apiHost).toStrictEqual(apiHost); + } +}); + +test("Formbricks should get the current person with no attributes", () => { + const currentState = formbricks.getPerson(); + + const currentStateAttributes: Array = currentState.attributes as Array; + expect(currentStateAttributes).toHaveLength(0); +}); + +test("Formbricks should set userId", async () => { + mockSetUserIdResponse(); + await formbricks.setUserId(initialUserId); + + const currentState = formbricks.getPerson(); + expect(currentState.environmentId).toStrictEqual(environmentId); + + const currentStateAttributes: Array = currentState.attributes as Array; + const numberOfUserAttributes = currentStateAttributes.length; + expect(numberOfUserAttributes).toStrictEqual(1); + + currentStateAttributes.forEach((attribute) => { + switch (attribute.attributeClass.name) { + case "userId": + expect(attribute.value).toStrictEqual(initialUserId); + break; + default: + expect(0).toStrictEqual(1); + } + }); +}); + +test("Formbricks should set email", async () => { + mockSetEmailIdResponse(); + await formbricks.setEmail(initialUserEmail); + + const currentState = formbricks.getPerson(); + expect(currentState.environmentId).toStrictEqual(environmentId); + + const currentStateAttributes: Array = currentState.attributes as Array; + const numberOfUserAttributes = currentStateAttributes.length; + expect(numberOfUserAttributes).toStrictEqual(2); + + currentStateAttributes.forEach((attribute) => { + switch (attribute.attributeClass.name) { + case "userId": + expect(attribute.value).toStrictEqual(initialUserId); + break; + case "email": + expect(attribute.value).toStrictEqual(initialUserEmail); + break; + default: + expect(0).toStrictEqual(1); + } + }); +}); + +test("Formbricks should set custom attribute", async () => { + mockSetCustomAttributeResponse(); + await formbricks.setAttribute(customAttributeKey, customAttributeValue); + + const currentState = formbricks.getPerson(); + expect(currentState.environmentId).toStrictEqual(environmentId); + + const currentStateAttributes: Array = currentState.attributes as Array; + const numberOfUserAttributes = currentStateAttributes.length; + expect(numberOfUserAttributes).toStrictEqual(3); + + currentStateAttributes.forEach((attribute) => { + switch (attribute.attributeClass.name) { + case "userId": + expect(attribute.value).toStrictEqual(initialUserId); + break; + case "email": + expect(attribute.value).toStrictEqual(initialUserEmail); + break; + case customAttributeKey: + expect(attribute.value).toStrictEqual(customAttributeValue); + break; + default: + expect(0).toStrictEqual(1); + } + }); +}); + +test("Formbricks should update attribute", async () => { + mockUpdateEmailResponse(); + await formbricks.setEmail(updatedUserEmail); + + const currentState = formbricks.getPerson(); + expect(currentState.environmentId).toStrictEqual(environmentId); + + const currentStateAttributes: Array = currentState.attributes as Array; + + const numberOfUserAttributes = currentStateAttributes.length; + expect(numberOfUserAttributes).toStrictEqual(3); + + currentStateAttributes.forEach((attribute) => { + switch (attribute.attributeClass.name) { + case "email": + expect(attribute.value).toStrictEqual(updatedUserEmail); + break; + case "userId": + expect(attribute.value).toStrictEqual(initialUserId); + break; + case customAttributeKey: + expect(attribute.value).toStrictEqual(customAttributeValue); + break; + default: + expect(0).toStrictEqual(1); + } + }); +}); + +test("Formbricks should track event", async () => { + mockEventTrackResponse(); + const mockButton = document.createElement("button"); + mockButton.addEventListener("click", async () => { + await formbricks.track("Button Clicked"); + }); + await mockButton.click(); + expect(consoleLogMock).toHaveBeenCalledWith( + expect.stringMatching(/Formbricks: Event "Button Clicked" tracked/) + ); +}); + +test("Formbricks should refresh", async () => { + mockRefreshResponse(); + await formbricks.refresh(); + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringMatching(/Settings refreshed/)); +}); + +test("Formbricks should register for route change", async () => { + mockRegisterRouteChangeResponse(); + await formbricks.registerRouteChange(); + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringMatching(/Checking page url/)); +}); + +test("Formbricks should logout", async () => { + mockLogoutResponse(); + await formbricks.logout(); + const currentState = formbricks.getPerson(); + const currentStateAttributes: Array = currentState.attributes as Array; + + expect(currentState.environmentId).toStrictEqual(environmentId); + expect(currentStateAttributes.length).toBe(0); +}); diff --git a/packages/js/tests/index.test.tsx b/packages/js/tests/index.test.tsx deleted file mode 100644 index d47c4699b2..0000000000 --- a/packages/js/tests/index.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { h } from "preact"; -import { shallow } from "enzyme"; - -import Hello from "../src/component"; - -describe("Hello logic", () => { - it("should be able to run tests", () => { - expect(1 + 2).toEqual(3); - }); -}); - -describe("Hello Snapshot", () => { - it("should render header with content", () => { - const tree = shallow(); - expect(tree.find("h1").text()).toBe("Hello, World!"); - }); -}); diff --git a/packages/js/tests/types.ts b/packages/js/tests/types.ts new file mode 100644 index 0000000000..f434d0d369 --- /dev/null +++ b/packages/js/tests/types.ts @@ -0,0 +1,8 @@ +export interface Attribute { + id: string; + value: string; + attributeClass: { + id: string; + name: string; + }; +} diff --git a/packages/js/tsconfig.json b/packages/js/tsconfig.json index e1026db534..b88de68d4e 100644 --- a/packages/js/tsconfig.json +++ b/packages/js/tsconfig.json @@ -58,5 +58,6 @@ "references": [ { "path": "../../../types/tsconfig.json" } // Add this line, adjust the path to the actual location ], - "include": ["src", "../types", "../lib/client"] + "include": ["src", "../types", "../lib/client"], + "exclude": ["node_modules", "dist", "coverage"] } diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 2f4d0fe206..9d0c39072e 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -1,5 +1,5 @@ export const RESPONSES_LIMIT_FREE = 100; -export const IS_FORMBRICKS_CLOUD = process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1"; +export const IS_FORMBRICKS_CLOUD = process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD === "1"; // URLs const VERCEL_URL = process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : ""; diff --git a/packages/lib/package.json b/packages/lib/package.json index 4a5deb0f1c..b366a619e0 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -13,8 +13,13 @@ }, "dependencies": { "@formbricks/database": "*", + "@formbricks/errors": "*", "@formbricks/types": "*", - "@formbricks/errors": "*" + "date-fns": "^2.30.0", + "markdown-it": "^13.0.1", + "posthog-node": "^3.1.1", + "server-only": "^0.0.1", + "tailwind-merge": "^1.12.0" }, "devDependencies": { "@formbricks/tsconfig": "*", diff --git a/packages/lib/services/response.ts b/packages/lib/services/response.ts index c4d3cfcce8..0184c27cc7 100644 --- a/packages/lib/services/response.ts +++ b/packages/lib/services/response.ts @@ -1,9 +1,11 @@ import { prisma } from "@formbricks/database"; -import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses"; -import { Prisma } from "@prisma/client"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; -import { getPerson, TransformPersonOutput, transformPrismaPerson } from "./person"; +import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses"; import { TTag } from "@formbricks/types/v1/tags"; +import { Prisma } from "@prisma/client"; +import "server-only"; +import { TransformPersonOutput, getPerson, transformPrismaPerson } from "./person"; +import { cache } from "react"; const responseSelection = { id: true, @@ -133,7 +135,11 @@ export const getResponse = async (responseId: string): Promise } }; -export const getSurveyResponses = async (surveyId: string): Promise => { +export const preloadSurveyResponses = (surveyId: string) => { + void getSurveyResponses(surveyId); +}; + +export const getSurveyResponses = cache(async (surveyId: string): Promise => { try { const responsesPrisma = await prisma.response.findMany({ where: { @@ -161,8 +167,44 @@ export const getSurveyResponses = async (surveyId: string): Promise throw error; } +}); + +export const preloadEnvironmentResponses = (environmentId: string) => { + void getEnvironmentResponses(environmentId); }; +export const getEnvironmentResponses = cache(async (environmentId: string): Promise => { + try { + const responsesPrisma = await prisma.response.findMany({ + where: { + survey: { + environmentId, + }, + }, + select: responseSelection, + orderBy: [ + { + createdAt: "desc", + }, + ], + }); + + const responses: TResponse[] = responsesPrisma.map((responsePrisma) => ({ + ...responsePrisma, + person: transformPrismaPerson(responsePrisma.person), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + })); + + return responses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}); + export const updateResponse = async ( responseId: string, responseInput: TResponseUpdateInput diff --git a/packages/lib/services/survey.ts b/packages/lib/services/survey.ts index f64c4445b6..8092200cb2 100644 --- a/packages/lib/services/survey.ts +++ b/packages/lib/services/survey.ts @@ -4,8 +4,14 @@ import { ValidationError } from "@formbricks/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; import { TSurvey, ZSurvey } from "@formbricks/types/v1/surveys"; import { Prisma } from "@prisma/client"; +import "server-only"; +import { cache } from "react"; -export const getSurvey = async (surveyId: string): Promise => { +export const preloadSurvey = (surveyId: string) => { + void getSurvey(surveyId); +}; + +export const getSurvey = cache(async (surveyId: string): Promise => { let surveyPrisma; try { surveyPrisma = await prisma.survey.findUnique({ @@ -96,4 +102,4 @@ export const getSurvey = async (surveyId: string): Promise => { } throw new ValidationError("Data validation of survey failed"); } -}; +}); diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js index 6885b794f2..27f7e777fa 100644 --- a/packages/tailwind-config/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -4,6 +4,7 @@ module.exports = { "./app/**/*.{js,ts,jsx,tsx}", // Note the addition of the `app` directory. "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", + "./lib/**/*.{js,ts,jsx,tsx}", // include packages if not transpiling "../../packages/ui/components/**/*.{js,ts,jsx,tsx}", ], @@ -11,6 +12,7 @@ module.exports = { extend: { animation: { "ping-slow": "ping 2s cubic-bezier(0, 0, 0.2, 1) infinite", + shake: "shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both", }, backgroundImage: { "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", @@ -30,6 +32,23 @@ module.exports = { "0%": { opacity: "0" }, "100%": { opacity: "1" }, }, + shake: { + "10%, 90%": { + transform: "translate3d(-1px, 0, 0)", + }, + + "20%, 80%": { + transform: "translate3d(2px, 0, 0),", + }, + + "30%, 50%, 70%": { + transform: "translate3d(-4px, 0, 0)", + }, + + "40%, 60%": { + transform: "translate3d(4px, 0, 0)", + }, + }, }, maxWidth: { "8xl": "88rem", @@ -40,6 +59,9 @@ module.exports = { scale: { 97: "0.97", }, + gridTemplateColumns: { + 20: "repeat(20, minmax(0, 1fr))", + }, }, }, plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")], diff --git a/packages/types/js.ts b/packages/types/js.ts index 57800461f7..83570ab5a0 100644 --- a/packages/types/js.ts +++ b/packages/types/js.ts @@ -53,6 +53,9 @@ export interface Settings { noCodeEvents?: any[]; brandColor?: string; formbricksSignature?: boolean; + placement?: PlacementType; + clickOutsideClose?: boolean; + darkOverlay?: boolean; } export interface JsConfig { @@ -92,3 +95,4 @@ export interface Trigger { } export type MatchType = "exactMatch" | "contains" | "startsWith" | "endsWith" | "notMatch" | "notContains"; +export type PlacementType = "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center"; diff --git a/packages/types/questions.ts b/packages/types/questions.ts index 0e8056ec77..153f0ba411 100644 --- a/packages/types/questions.ts +++ b/packages/types/questions.ts @@ -19,7 +19,8 @@ export type Question = | MultipleChoiceMultiQuestion | NPSQuestion | CTAQuestion - | RatingQuestion; + | RatingQuestion + | ConsentQuestion; export interface IQuestion { id: string; @@ -33,6 +34,7 @@ export interface IQuestion { export interface OpenTextQuestion extends IQuestion { type: QuestionType.OpenText; + longAnswer?: boolean; placeholder?: string; } @@ -68,9 +70,18 @@ export interface RatingQuestion extends IQuestion { upperLabel: string; } +export interface ConsentQuestion extends IQuestion { + type: "consent"; + html?: string; + label: string; + dismissButtonLabel?: string; +} + export type LogicCondition = | "submitted" | "skipped" + | "accepted" + | "clicked" | "equals" | "notEquals" | "lessThan" @@ -112,7 +123,7 @@ export interface NPSLogic extends LogicBase { value?: number; } export interface CTALogic extends LogicBase { - condition: "submitted" | "skipped" | undefined; + condition: "clicked" | "skipped" | undefined; value?: undefined; } export interface RatingLogic extends LogicBase { @@ -128,10 +139,17 @@ export interface RatingLogic extends LogicBase { | undefined; value?: number | string; } + +export interface ConsentLogic extends LogicBase { + condition: "submitted" | "skipped" | "accepted" | undefined; + value: undefined; +} + export type Logic = | OpenTextLogic | MultipleChoiceSingleLogic | MultipleChoiceMultiLogic | NPSLogic | CTALogic - | RatingLogic; + | RatingLogic + | ConsentLogic; diff --git a/packages/types/v1/surveys.ts b/packages/types/v1/surveys.ts index 310fbac497..4c118d9b0b 100644 --- a/packages/types/v1/surveys.ts +++ b/packages/types/v1/surveys.ts @@ -40,7 +40,7 @@ export const ZSurveyOpenTextLogic = ZSurveyLogicBase.extend({ }); export const ZSurveyConsentLogic = ZSurveyLogicBase.extend({ - condition: z.enum(["submitted", "skipped", "accepted"]).optional(), + condition: z.enum(["skipped", "accepted"]).optional(), value: z.undefined(), }); @@ -71,7 +71,8 @@ export const ZSurveyNPSLogic = ZSurveyLogicBase.extend({ }); const ZSurveyCTALogic = ZSurveyLogicBase.extend({ - condition: z.enum(["submitted", "skipped"]).optional(), + // "submitted" condition is legacy and should be removed later + condition: z.enum(["clicked", "submitted", "skipped"]).optional(), value: z.undefined(), }); @@ -116,6 +117,7 @@ const ZSurveyQuestionBase = z.object({ export const ZSurveyOpenTextQuestion = ZSurveyQuestionBase.extend({ type: z.literal(QuestionType.OpenText), placeholder: z.string().optional(), + longAnswer: z.boolean().optional(), logic: z.array(ZSurveyOpenTextLogic).optional(), }); diff --git a/packages/ui/components/PasswordInput.tsx b/packages/ui/components/PasswordInput.tsx index d5143acf90..853d1fae59 100644 --- a/packages/ui/components/PasswordInput.tsx +++ b/packages/ui/components/PasswordInput.tsx @@ -25,9 +25,8 @@ const PasswordInput = ({ className, ...rest }: PasswordInputProps) => { />