Merge branch 'main' of github.com:formbricks/formbricks into feature/integrations

This commit is contained in:
Johannes
2023-07-11 16:02:04 +02:00
183 changed files with 3806 additions and 3441 deletions

View File

@@ -46,6 +46,7 @@ NEXTAUTH_URL=http://localhost:3000
# MAIL_FROM=noreply@example.com
# SMTP_HOST=localhost
# SMTP_PORT=1025
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
# SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
# SMTP_USER=smtpUser
# SMTP_PASSWORD=smtpPassword
@@ -96,4 +97,7 @@ GITHUB_SECRET=
# Configure Google Login
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=0
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_SECRET=
# Cron Secret
CRON_SECRET=

View File

@@ -46,7 +46,8 @@ NEXTAUTH_URL=http://localhost:3000
MAIL_FROM=noreply@example.com
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
SMTP_SECURE_ENABLED=0
SMTP_USER=smtpUser
SMTP_PASSWORD=smtpPassword
@@ -82,12 +83,6 @@ NEXT_PUBLIC_PRIVACY_URL=
NEXT_PUBLIC_TERMS_URL=
NEXT_PUBLIC_IMPRINT_URL=
# Disable Sentry warning
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
# Enable Sentry Error Tracking
NEXT_PUBLIC_SENTRY_DSN=
# Configure Github Login
NEXT_PUBLIC_GITHUB_AUTH_ENABLED=0
GITHUB_ID=
@@ -100,7 +95,6 @@ GOOGLE_CLIENT_SECRET=
# Stripe Billing Variables
NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID=
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -108,4 +102,7 @@ STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Cron Secret
CRON_SECRET=

View File

@@ -24,5 +24,14 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Build formbricks-js dependencies
run: pnpm build --filter=js
- name: create .env
run: cp .env.example .env
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test

23
.github/workflows/cron-closeOnDate.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Cron - weeklySummary
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At 00:00.” (see https://crontab.guru)
- cron: "0 0 * * *"
jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/close_surveys \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
--fail

View File

@@ -0,0 +1,23 @@
name: Cron - weeklySummary
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
- cron: "0 8 * * 1"
jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
--fail

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ yarn-error.log*
.env.test.local
.env.production.local
!packages/database/.env
!apps/web/.env
# Prisma generated files
packages/database/zod

1
.vercelignore Normal file
View File

@@ -0,0 +1 @@
apps/web/.env

View File

@@ -4,28 +4,19 @@
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
"dev": "next dev -p 3002",
"dev": "next dev -p 3002 --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@heroicons/react": "^2.0.17",
"eslint-config-formbricks": "workspace:*",
"next": "13.2.4",
"@heroicons/react": "^2.0.18",
"next": "13.4.8",
"react": "18.2.0",
"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",
"typescript": "5.0.3"
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -21,13 +21,16 @@ export default function AppPage({}) {
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
</div>
{/* <div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6">
<h3 className="text-lg font-semibold">Console</h3>
<p className="text-slate-700">You can also open your browser console to logs:</p>
<div className="max-h-[40vh] overflow-y-auto py-4">
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6">
<h3 className="text-lg font-semibold">Widget Logs</h3>
<p className="text-slate-700">
Look at the logs to understand how the widget works. <strong>Open your browser console</strong>{" "}
to see the logs.
</p>
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
<LogsContainer />
</div>
</div> */}
</div> */}
</div>
</div>
<div className="md:grid md:grid-cols-3">

View File

@@ -23,7 +23,7 @@ export const DocsFeedback: React.FC = () => {
<div className="mt-6 inline-flex cursor-default items-center rounded-md border border-slate-200 bg-white p-4 text-slate-800 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
{!sharedFeedback ? (
<div className="text-center md:text-left">
Was this page helpful?
Is everything on this page clear?
<Popover open={isOpen} onOpenChange={setIsOpen}>
<div className="mt-2 inline-flex space-x-3 md:ml-4 md:mt-0">
{["Yes 👍", " No 👎"].map((option) => (

View File

@@ -5,10 +5,9 @@ import CrowdLogoDark from "@/images/clients/crowd-logo-dark.svg";
import CrowdLogoLight from "@/images/clients/crowd-logo-light.svg";
import NILogoDark from "@/images/clients/niLogoDark.svg";
import NILogoLight from "@/images/clients/niLogoWhite.svg";
import StackOceanLogoDark from "@/images/clients/stack-ocean-dark.png";
import StackOceanLogoLight from "@/images/clients/stack-ocean-light.png";
import AnimationFallback from "@/public/animations/fallback-image-open-source-feedback-software.jpg";
import AnimationFallback from "@/public/animations/opensource-xm-platform-formbricks-fallback.png";
import { Button } from "@formbricks/ui";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { usePlausible } from "next-plausible";
import Image from "next/image";
import { useRouter } from "next/router";
@@ -19,17 +18,24 @@ export const Hero: React.FC = ({}) => {
const router = useRouter();
return (
<div className="relative">
<div className="px-4 py-20 text-center sm:px-6 lg:px-8 lg:py-28">
<h1 className="text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<span className="xl:inline">Create Products People Remember</span>
<div className="px-4 pb-20 pt-16 text-center sm:px-6 lg:px-8 lg:pb-32 lg:pt-20">
<a
href="https://github.com/formbricks/formbricks"
target="_blank"
className="border-brand-dark rounded-full border px-6 py-1.5 text-sm text-slate-500 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800">
We&apos;re Open-Source | Star us on GitHub{" "}
<ChevronRightIcon className="inline h-5 w-5 text-slate-300" />
</a>
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<span className="xl:inline">Open-source Experience Management</span>
</h1>
<p className="xs:max-w-none mx-auto mt-3 max-w-xs text-base text-slate-500 dark:text-slate-400 sm:text-lg md:mt-5 md:text-xl">
Understand what customers think & feel about your product.
<br />
<span className="hidden md:block">
Continuously gather deep user insights,{" "}
<span className="decoration-brand-dark underline underline-offset-4">all privacy-first.</span>
Natively integrate user research with minimal dev attention,{" "}
<span className="decoration-brand-dark underline underline-offset-4">privacy-first.</span>
</span>
</p>
@@ -37,7 +43,7 @@ export const Hero: React.FC = ({}) => {
<p className="hidden whitespace-nowrap pt-3 text-xs text-slate-400 dark:text-slate-500 md:block">
Trusted by
</p>
<div className="grid grid-cols-3 items-center gap-8 pt-2 md:grid-cols-5">
<div className="grid grid-cols-3 items-center gap-8 pt-2 md:grid-cols-4">
<Image
src={CalLogoLight}
alt="Cal Logo"
@@ -80,18 +86,6 @@ export const Hero: React.FC = ({}) => {
className="hidden pb-1 hover:opacity-100 dark:block md:opacity-50"
width={200}
/>
<Image
src={StackOceanLogoLight}
alt="StackOcean Logo"
className="block pb-1 hover:opacity-100 dark:hidden md:opacity-50"
width={200}
/>
<Image
src={StackOceanLogoDark}
alt="StakcOcean Logo"
className="hidden pb-1 hover:opacity-100 dark:block md:opacity-50"
width={200}
/>
</div>
</div>
<div className="hidden pt-10 md:block">

View File

@@ -19,7 +19,7 @@ export const HeroAnimation: React.FC<any> = ({ fallbackImage, ...props }) => {
loop: true,
autoplay: true,
// path to your animation file, place it inside public folder
path: "/animations/formbricks-open-source-survey-software-hero-animation-v1.json",
path: "/animations/opensource-xm-platform-formbricks.json",
});
animation.addEventListener("DOMLoaded", () => {

View File

@@ -84,7 +84,7 @@ export default function BestPracticeNavigation() {
<div className=" mx-auto grid grid-cols-1 gap-6 px-2 sm:grid-cols-3">
{BestPractices.map((bestPractice) => (
<Link href={bestPractice.href} key={bestPractice.name}>
<div className="drop-shadow-card duration-120 relative rounded-lg bg-slate-100 p-8 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:bg-slate-800">
<div className="drop-shadow-card duration-120 hover:border-brand-dark relative rounded-lg border border-slate-100 bg-slate-100 p-8 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:bg-slate-800">
<div
className={clsx(
// base styles independent what type of button it is

View File

@@ -37,6 +37,7 @@ const tiers = [
features: [
"Unlimited surveys",
"Unlimited team members",
"Remove branding",
"Granular targeting",
"In-product surveys",
"Link surveys",
@@ -57,8 +58,8 @@ const tiers = [
discounted: false,
highlight: false,
description: "All features included. Unlimited usage.",
features: ["All features of Free plan", "Unlimited responses", "Remove branding"],
ctaName: "Sign up now",
features: ["Unlimited responses per survey"],
ctaName: "Start for free",
plausibleGoal: "Pricing_CTA_ProPlan",
},
];
@@ -145,7 +146,7 @@ export default function Pricing() {
{tier.ctaName}
</Button>
{tier.name === "Free" && (
{tier.name !== "Self-hosting" && (
<p className="mt-1.5 text-center text-xs text-slate-500">No Creditcard required.</p>
)}
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -11,7 +11,8 @@ const navigation = [
title: "Getting Started",
links: [
{ title: "Quickstart", href: "/docs/getting-started/quickstart" },
{ title: "Setup with Next.js", href: "/docs/getting-started/nextjs" },
{ title: "Next.js App Dir", href: "/docs/getting-started/nextjs-app" },
{ title: "Next.js Pages Dir", href: "/docs/getting-started/nextjs-pages" },
{ title: "Setup with Vue.js", href: "/docs/getting-started/vuejs" },
],
},
@@ -43,6 +44,13 @@ const navigation = [
{ title: "Docs Feedback", href: "/docs/best-practices/docs-feedback" },
],
},
{
title: "Link Surveys",
links: [
{ title: "Data Prefilling", href: "/docs/link-surveys/data-prefilling" },
{ title: "User Identification", href: "/docs/link-surveys/user-identification" },
],
},
{
title: "API",
links: [
@@ -77,6 +85,8 @@ const navigation = [
links: [
{ title: "Introduction", href: "/docs/contributing/introduction" },
{ title: "Setup Dev Environment", href: "/docs/contributing/setup" },
{ title: "Demo App", href: "/docs/contributing/demo" },
{ title: "Troubleshooting", href: "/docs/contributing/troubleshooting" },
],
},
];

View File

@@ -46,6 +46,11 @@ const nextConfig = {
destination: "/docs/introduction/what-is-formbricks",
permanent: true,
},
{
source: "/docs/getting-started/nextjs",
destination: "/docs/getting-started/nextjs-app",
permanent: true,
},
{
source: "/docs/formbricks-hq/self-hosting",
destination: "/docs",

View File

@@ -11,7 +11,7 @@
"lint": "next lint"
},
"dependencies": {
"@calcom/embed-react": "^1.1.1",
"@calcom/embed-react": "^1.2.2",
"@docsearch/react": "^3.5.1",
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
@@ -21,35 +21,25 @@
"@mapbox/rehype-prism": "^0.8.0",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "^13.4.7",
"@paralleldrive/cuid2": "^2.2.0",
"@next/mdx": "^13.4.8",
"@paralleldrive/cuid2": "^2.2.1",
"clsx": "^1.2.1",
"lottie-web": "^5.12.2",
"next": "13.4.7",
"next": "13.4.8",
"next-plausible": "^3.8.0",
"next-sitemap": "^4.1.3",
"next-sitemap": "^4.1.8",
"prism-react-renderer": "^2.0.6",
"prismjs": "^1.29.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.8.0",
"react-icons": "^4.10.1",
"react-responsive-embed": "^2.1.0",
"remark-gfm": "^3.0.1",
"sharp": "^0.32.1"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@types/node": "20.3.2",
"@types/prismjs": "^1.26.0",
"@types/react": "18.2.7",
"@types/react-dom": "18.2.4",
"autoprefixer": "^10.4.14",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.24",
"rimraf": "^5.0.1",
"tailwindcss": "^3.3.2",
"typescript": "5.0.4"
"eslint-config-formbricks": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,122 @@
import Image from "next/image";
import LayoutMdx from "@/components/shared/LayoutMdx";
import TitleImage from "./formbricks-sponsored-by-github-accelerator-2023.webp";
import Demo from "./our-experience-github-acc-demo-screenshot.png";
import Mail from "./github-accelerator-selection-mail.png";
import Teams from "./github-accelerator-2022-teams.png";
import NewsletterSignup from "@/components/shared/NewsletterSignup";
export const meta = {
title: "Our GitHub Accelerator Experience 👀",
description:
"What we learned during the first GitHub Open-Source Accelerator Programm - our experience and if we would do it again.",
date: "2023-04-13",
};
_We were among the first 20 teams ever to run through the Open-Source Accelerator by Github. Read about our experience and if we would do it again:_
<Image
src={Demo}
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
className="rounded-lg"
/>
## Hey there,
In December of last year, we completed a rather brief questionnaire to apply for the inaugural batch of the GitHub Open-Source Accelerator. With not much information available, we went ahead and applied, hoping for the best. The timing couldn't have been more perfect, as both Matti and I had just wrapped up our freelance gigs to start working full-time on Formbricks.
As Christmas, New Year's Eve, and my birthday passed, we continued working diligently on Formbricks, iterating to pinpoint the right niche offering. Over the preceding months, we had learned what wouldn't constitute a good venture case ([Typeform open-source](https://formbricks.com/blog/open-source-qualtrics-beats-typeform)), what wasn't technically feasible (building blocks for all form and survey solutions), and what was too narrow to start with ([PMF survey only](https://www.producthunt.com/products/product-market-fit-survey-by-formbricks)).
January and February came and went. On the 22nd of March, we received an email from the GitHub team:
<Image
src={Mail}
alt="GitHub invited us to join the GitHub Accelerator and share our experience"
className="rounded-lg"
/>
Needless to say, we were thrilled! We were selected from over 1000 open-source projects, alongside renowned and popular projects like [Nuxt](https://github.com/nuxt/nuxt), [TRPC](https://github.com/trpc/trpc), and [Responsively App](https://github.com/responsively-org/responsively-app). Here is a summary of what we got:
### What we got on paper
✅ Ten sessions with **well-known** figures from the open-source community (Wednesdays)
✅ Ten optional co-working sessions (Fridays)
✅ 20.000 USD equally divided among core maintainers
✅ One-on-one session with the GitHub team to align on goals and objectives
### What we also gained
👌 Network of builders, maintainers, and founders in the open-source space
👌 Solid connection with GitHub (including a warm introduction to GitHub's venture arm 😏)
👌 Enhanced credibility in the open-source community, thanks to association with such a significant supporter of open source
I mean look at all these happy people:
<Image
src={Teams}
alt="GitHub invited us to join the GitHub Accelerator and share our experience"
className="rounded-lg"
/>
Here's an overview of the ten sessions and their relevance to us as a venture-focused startup:
**Week 1: Kick-Off, Licensing 101 and setting up with [Abby](https://twitter.com/abbycabs)**
Great to meet everyone, Abby is a great host and the licensing session was very useful. We had already decided on our license but it was useful nontheless.
**Week 2: Finding Sponsors with [Caleb Porzio](https://twitter.com/calebporzio)**
This was a really fun one! Caleb is a driven entrepreneur with many ideas and loooots of experience monetizing his two main projects [Livewire](https://laravel-livewire.com/) and [Alpine.js](https://alpinejs.dev/). Come up with a way to monetize a popular OS project, Caleb scaled it. Not suuuper relevant for us though.
**Week 3: Taking Funding: [Brian Douglas](https://twitter.com/bdougieYO)**
Brian is building [OpenSauced](https://opensauced.pizza/) and shared his journey of raising VC as an OS startup. Lots of great insights, Brian is super approachable 😊
**Week 4: [Evan You](https://evanyou.me/): Sustainable Open Source**
Evan You famously created Vue.js (which is on track to pass React in GitHub ⭐) and Vite. Evan had a lot of useful Dos and Donts for us, great session!
**Week 5:** Didn't happen due to Maintainer Summit.
**Week 6: [Mike Perham](https://github.com/mperham) - Starting a Software Business**
Mike is an absolute legend! With [SideKiq](https://sidekiq.org/) he makes over 300k USD per month 🤯 He was very open and down to earth. One of his best advice: If a customer annoys you, stop serving them. He was able to pull this off because he has been blogging about Ruby for years and is well-known in the community. And, obviously, his solution kicks ass!
**Week 7: Duane OBrien and Dawn Foster: Working with Enterprises**
Lots of useful insights around how enterprises handle open-source, barriers for corporate use and how to handle corporate sponsorships and donations. The notes will come in really handy down the line!
**Week 8: [Marko Saric](https://twitter.com/markosaric): SaaS-side of Open Source**
Marko is the marketing co-founder of [Plausible](https://plausible.io/). I think everyone in the SaaS space knows Plausible since they hit 1M ARR bootstrapped. Marko also blogged a lot about Plausible which really helped it grow in the first years. Gifted marketeer, great session!
**Week 9: Governance with [Shauna Gordon-McKeon](https://github.com/shaunagm/)**
For us this wasnt super relevant as Formbricks is ruled by a BDFL (Benevolent Dictator For Life) i.e. us but the discussion among the teams was really insightful. Helped us a great deal to understand the challenges of purely community-driven projects.
**Week 10: VC Funding and the legal Side of OSS with [Erica Brescia](https://twitter.com/ericabrescia) from Redpoint**
Ericas talk was really impressive and so is she: Founder of Bitnami, COO of GitHub and Board Member of the Linux Foundation all happened before she started as an investor at Redpoint Ventures. Her deep insights from both the founder and the VC perspective are invaluable!
**GitHub Demo Day:** All teams presented what they achieved during the 10 week programm. It was great fun to present Formbricks, [you can watch it on Youtube.](https://www.youtube.com/live/Gj6Bez2182k?feature=share&t=1448)
## Would we do it again? And should you?
Yes, absolutely. The sessions were excellent, we met a handful of inspiring builders, and the 20k USD was a helpful financial boost. The application process might evolve, but since all of your code is open-source anyway, you might as well throw your hat in the ring.
**Gratitude to Kara, Abby, and the entire GitHub team - we learned a lot! 😊**
<Image
src={TitleImage}
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
className="rounded-lg"
/>
<NewsletterSignup />
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -27,7 +27,7 @@ The API requests are authorized with a personal API key. This API key gives you
### Delete a personal API key
1. Go to settings on [app.formbricks.com](https://app.formbricks.com/app/me/settings).
1. Go to settings on [app.formbricks.com](https://app.formbricks.com/me/settings).
2. Go to page “API keys”.
3. Find the key you wish to revoke and select “Delete”.
4. Your API key will stop working immediately.

View File

@@ -43,33 +43,15 @@ To get this running, you'll need a bit of time. Here are the steps we're going t
2. In the Menu (top right) you see that you can switch between a “Development” and a “Production” environment. These are two separate environments so your test data doesnt mess up the insights from prod. Switch to “Development”:
<Image
src={SwitchToDev}
alt="switch to dev environment"
quality="100"
className="rounded-lg"
className="rounded"
/>
<Image src={SwitchToDev} alt="switch to dev environment" quality="100" className="rounded-lg" />
3. Then, create a survey using the template “Docs Feedback”:
<Image
src={DocsTemplate}
alt="select docs template"
quality="100"
className="rounded-lg"
className="rounded"
/>
<Image src={DocsTemplate} alt="select docs template" quality="100" className="rounded-lg" />
4. Change the Internal Question ID of the first question to **“isHelpful”** to make your life easier 😉
<Image
src={ChangeId}
alt="switch to dev environment"
quality="100"
className="rounded-lg"
className="rounded"
/>
<Image src={ChangeId} alt="switch to dev environment" quality="100" className="rounded-lg" />
5. In the same way, you can change the Internal Question ID of the _Please elaborate_ question to **“additionalFeedback”** and the one of the _Page URL_ question to **“pageUrl”**.
@@ -80,13 +62,7 @@ To get this running, you'll need a bit of time. Here are the steps we're going t
6. Click on “Continue to Settings or select the audience tab manually. Scroll down to “When to ask” and create a new Action:
<Image
src={WhenToAsk}
alt="set up when to ask card"
quality="100"
className="rounded-lg"
className="rounded"
/>
<Image src={WhenToAsk} alt="set up when to ask card" quality="100" className="rounded-lg" />
7. Our goal is to create an event that never fires. This is a bit nonsensical because it is a workaround. Stick with me 😃 Fill the action out like on the screenshot:
@@ -94,7 +70,7 @@ To get this running, you'll need a bit of time. Here are the steps we're going t
8. Select the Non-Event in the dropdown. Now you see that the “Publish survey” button is active. Publish your survey 🤝
<Image src={SelectNonevent} alt="select nonevent" quality="100" className="rounded-lg" className="rounded" />
<Image src={SelectNonevent} alt="select nonevent" quality="100" className="rounded-lg" />
**Youre all setup in Formbricks Cloud for now 👍**
@@ -280,7 +256,7 @@ return (
## 3. Connecting to the Formbricks API
The last step is to hook up your sparkling new frontend to the Formbricks API. To do so, we followed the “[Create Response](https://formbricks.com/docs/api/create-response)” and “[Update Response](https://formbricks.com/docs/api/update-response)” pages in our docs.
The last step is to hook up your sparkling new frontend to the Formbricks API. To do so, we followed the “[Create Response](/docs/client-api/create-response)” and “[Update Response](/docs/client-api/update-response)” pages in our docs.
Here is the code for the `handleFeedbackSubmit` function with comments:
@@ -373,7 +349,7 @@ Before you roll it out in production, you want to test it. To do so, you need tw
When you are on the survey detail page, youll find both of them in the URL:
<Image src={CopyIds} alt="copy IDs" quality="100" className="rounded-lg" className="rounded" />
<Image src={CopyIds} alt="copy IDs" quality="100" className="rounded-lg" />
Now, you have to replace the IDs and the API host accordingly in your `handleFeedbackSubmit`:

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,67 @@
import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import DemoApp from "./demoapp.png";
export const meta = {
title: "Demo App",
description: "To test in-app surveys, trigger actions and set attributes, you can use the Demo App.",
};
To play around with the user actions, you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set attributes.
<Image src={DemoApp} alt="Demo App Preview" quality="100" className="rounded-lg" />
## Functionality
### Code Action
This button sends a <a href="/docs/actions/code">Code Action</a> to the Formbricks API called 'Code Action'. You will find it in the Actions Tab.
```tsx
formbricks.track("Code Action");
```
### No Code Action
This button sends a <a href="/docs/actions/no-code">No Code Action</a> as long as you created it beforehand in the Formbricks App. For it to work, you need to add the No Code Action within Formbricks.
```tsx
<button>No-Code Action</button>
```
### Set Plan to "Free"
This button sets the <a href="/docs/attributes/custom-attributes">attribute</a> 'Plan' to 'Free'. If the attribute does not exist, it creates it.
```tsx
formbricks.setAttribute("Plan", "Free");
```
### Set Plan to "Paid"
This button sets the <a href="/docs/attributes/custom-attributes">attribute</a> 'Plan' to 'Paid'. If the attribute does not exist, it creates it.
```tsx
formbricks.setAttribute("Plan", "Paid");
```
### Set Email
This button sets the <a href="/docs/attributes/identify-users">user email</a> 'test@web.com'
```tsx
formbricks.setEmail("test@web.com");
```
### Set UserId
This button sets an external <a href="/docs/attributes/identify-users">user ID</a> to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
```tsx
formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING");
```
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,65 @@
import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import ClearAppData from "./clear-app-data.png";
import UncaughtPromise from "./uncaught-promise.png";
import Logout from "./logout.png";
export const meta = {
title: "Troubleshooting",
description:
"Formbricks is a complex application in constant development. Sometimes, things don't go as planned. Here are some tips to help you troubleshoot.",
};
Here you'll find help with Frequently Recurring Problems
## "The app doesn't work after doing a prisma migration"
This can happen but fear not, the fix is easy: Delete the application storage of your browser and reload the page. This will force the app to re-fetch the data from the server:
<Image src={ClearAppData} alt="Demo App Preview" quality="100" className="rounded-lg" />
## "I ran 'pnpm i' but there seems to be an error with the packages"
If nothing helps, run `pnpm clean` and then `pnpm i` again. This solves a lot.
## "I get a full-screen error with cryptic strings"
This usually happens when the Formbricks Widget wasn't correctly or completely built.
```bash
// Build formbricks-js first
pnpm build --filter=js
// Run the app again
pnpm dev
```
## My machine struggles with the repository
Since we're working with a monorepo structure, the repository can get quite big. If you're having trouble working with the repository, try the following:
```bash
// Only run the Formbricks app
pnpm dev --filter=web...
// Only run the landing page app
pnpm dev --filter=formbricks-com...
// Only run the demo app
pnpm dev --filter=demo...
```
However, in our experience it's better to run `pnpm dev` than having two terminals open (one with the Formbricks app and one with the demo).
## Uncaught (in promise) SyntaxError: Unexpected token !DOCTYPE ... is not valid JSON
<Image src={UncaughtPromise} alt="Uncaught promise" quality="100" className="rounded-lg" />
This happens when you're using the Demo App and delete the Person within the Formbricks app which the widget is currently connected with. We're fixing it, but you can also just logout your test person and reload the page to get rid of it.
<Image src={Logout} alt="Logout Person" quality="100" className="rounded-lg" />
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,97 @@
import { Layout } from "@/components/docs/Layout";
import Image from "next/image";
import SetupChecklist from "./env-id.png";
import WidgetNotConnected from "./widget-not-connected.png";
import WidgetConnected from "./widget-connected.png";
export const meta = {
title: "Setting up Formbricks SDK with Next.js App Directory",
description:
"Setting up Formbricks with the new Next.js 13 App Directory can be tricky. Follow this guide to make sure you get it right.",
};
This guide will walk you through the process of integrating the Formbricks SDK into a Next.js application using the new app directory. As the Formbricks SDK only works on the client side, it's essential to ensure proper integration to avoid any issues.
## Prerequisites
Before getting started, make sure you have:
1. A Next.js application set up and running.
2. A Formbricks account with access to your environment ID and API host. You can find these in the **Setup Checklist** in the Settings:
<Image src={SetupChecklist} alt="Step 2 - Setup Checklist" quality="100" className="rounded-lg" />
## Installing Formbricks SDK
First, you need to install the Formbricks SDK using one of the following commands:
```bash
npm install --save @formbricks/js
# or
yarn add @formbricks/js
# or
pnpm add @formbricks/js
```
## Integrating with Next.js 13 App Directory
The Next.js 13 app directory requires us to initialize Formbricks differently than the pages directory. Specifically, the app directory server-side renders components by default, and the formbricks-js library is a client-side library. To make these work together, create a `formbricks.js` file (or formbricks.ts if you are using Typescript) and set up the FormbricksProvider with the 'use client' directive:
```tsx
// app/formbricks.js
"use client";
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
logLevel: "debug", // remove when in production
});
}
export default function Providers({ Component, pageProps }: AppProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
formbricks?.registerRouteChange();
}, [pathname, searchParams]);
return null;
}
```
Once we do this, we can then import the `provider.js` file in our `app/layout.js` file, and wrap our app in the Formbricks provider.
```tsx
// app/layout.js
import Providers from "./formbricks";
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<Providers />
<body>{children}</body>
</html>
);
}
```
## Validate your setup
Once you have completed the steps above, you can validate your setup by checking the **Setup Checklist** in the Settings. Your widget status indicator should go from this:
<Image src={WidgetNotConnected} alt="Widget isnt connected" quality="100" className="rounded-lg" />
To this:
<Image src={WidgetConnected} alt="Widget is connected" quality="100" className="rounded-lg" />
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,88 @@
import { Layout } from "@/components/docs/Layout";
import Image from "next/image";
import SetupChecklist from "./env-id.png";
import WidgetNotConnected from "./widget-not-connected.png";
import WidgetConnected from "./widget-connected.png";
export const meta = {
title: "Setting up Formbricks SDK with Next.js Pages Directory",
description:
"Setting up Formbricks with the new Next.js 13 Pages Directory can be tricky. Follow this guide to make sure you get it right.",
};
This guide will walk you through the process of integrating the Formbricks SDK into a Next.js application using the Pages Directory. As the Formbricks SDK only works on the client side, it's essential to ensure proper integration to avoid any issues.
## Prerequisites
Before getting started, make sure you have:
1. A Next.js application with Pages Directory set up and running.
2. A Formbricks account with access to your environment ID and API host. You can find these in the **Setup Checklist** in the Settings:
<Image src={SetupChecklist} alt="Step 2 - Setup Checklist" quality="100" className="rounded-lg" />
## Installing Formbricks SDK
First, you need to install the Formbricks SDK using one of the following commands:
```bash
npm install --save @formbricks/js
# or
yarn add @formbricks/js
# or
pnpm add @formbricks/js
```
## Integrating with Next.js 13 Pages Directory
Update your Pages component in the \_app.ts file like so:
```tsx
import "@/styles/globals.css";
import type { PagesProps } from "next/app";
import { useEffect } from "react";
import { useRouter } from "next/router";
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
logLevel: "debug", // remove when in production
});
}
export default function Pages({ Component, pageProps }: PagesProps) {
const router = useRouter();
useEffect(() => {
// Connect next.js router to Formbricks
const handleRouteChange = formbricks?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, []);
return <Component {...pageProps} />;
}
```
## What are we doing here?
1. First we need to initialize the Formbricks SDK, making sure it only runs on the client side.
2. To connect the Next.js router to Formbricks and ensure the SDK can keep track of every page change, we are registering the route change event.
## Validate your setup
Once you have completed the steps above, you can validate your setup by checking the **Setup Checklist** in the Settings. Your widget status indicator should go from this:
<Image src={WidgetNotConnected} alt="Widget isnt connected" quality="100" className="rounded-lg" />
To this:
<Image src={WidgetConnected} alt="Widget is connected" quality="100" className="rounded-lg" />
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,82 +0,0 @@
import { Layout } from "@/components/docs/Layout";
export const meta = {
title: "Setting up Formbricks SDK with Next.js",
description: "Integrate Formbricks SDK into your Next.js app for seamless in-product micro-surveys. Follow our step-by-step guide to enhance user feedback and product experience.",
};
This guide will walk you through the process of integrating the Formbricks SDK into a Next.js application. As the Formbricks SDK only works on the client side, it's essential to ensure proper integration to avoid any issues.
## Introduction
Formbricks SDK allows you to seamlessly integrate in-product micro-surveys into your Next.js application. By following the steps outlined in this guide, you'll be able to gather valuable insights from your users and improve your product experience.
## Prerequisites
Before getting started, make sure you have:
1. A Next.js application set up and running.
2. A Formbricks account with access to your environment ID and API host. You can find these in the Setup Checklist in the Settings.
## Installing Formbricks SDK
First, you need to install the Formbricks SDK using one of the following commands:
```bash
npm install --save @formbricks/js
# or
yarn add @formbricks/js
# or
pnpm add @formbricks/js
```
## Integrating Formbricks SDK with Next.js
Update your App component in the \_app.ts file like so:
```tsx
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { useEffect } from "react";
import { useRouter } from "next/router";
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
logLevel: "debug", // remove when in production
});
}
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter();
useEffect(() => {
// Connect next.js router to Formbricks
const handleRouteChange = formbricks?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, []);
return <Component {...pageProps} />;
}
```
**What are we doing here?**
First we need to initialize the Formbricks SDK, making sure it only runs on the client side.
To connect the Next.js router to Formbricks and ensure the SDK can keep track of every page change, we are registering the route change event.
That's it! 🎉
You should now see that the Widget Status in the setup checklist updated accordingly:
If it doesnt work, please [join our Discord](https://formbricks.com/discord) and let us know!
You have now successfully integrated the Formbricks SDK into your Next.js application. You can start creating and customizing in-product micro-surveys to gather valuable feedback from your users and improve your product experience.
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

View File

@@ -0,0 +1,115 @@
import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import QuestionId from "./question-id.png";
export const meta = {
title: "Data Prefilling in Link Surveys",
description: "Prefill data in your surveys to make it easier for your users to provide feedback.",
};
Data prefilling via the URL allows you to increase conversion rate by prefilling data you already have in a different system.
## Purpose
URL prefilling of data comes in handy when you:
- Have data for some of the respondents, but not all
- Have data in a different system (e.g. your database) and want to add it to the user profile in Formbricks
- Want to embed the first question in an email and increase conversion by prefilling the choice
## Quick Example
```tsx
https://app.formbricks.com/s/clin3dxja02k8l80hpwmx4bjy?question_id=5
```
## How it works
To prefill the first question of a survey, append `?question_id=answer` at the end of the survey URL. The answer has to match the expected type of the question. For example, if the first question is a rating question, the answer has to be a number. If the first question is a single select question, the answer has to be a string.
Please make sure the answer is [URL encoded](https://www.urlencoder.org/).
<Callout title="Prefill only the first question" type="note">
Currently, you can only prefill the first question of a link survey.
</Callout>
## Where do I find my question Id?
You find the `questionId` in the Advanced Settings at the bottom of each question card in the Survey Editor. As you see, you can update the `questionId` to any string you like. However, once you published your survey, this `questionId` cannot be updated anymore:
<Image
src={QuestionId}
alt="The question Id is located at the bottom of each question card in the survey editor."
quality="100"
className="rounded-lg"
/>
## Examples
Here are a few examples to get you started:
### Rating Question
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?rating_question_id=5
// -> translates to 5 stars / points / emojis
```
### NPS Question
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?nps_question_id=10
// -> translates to an NPS rating of 10
```
### Single Select Question (Radio)
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id=Very%20disappointed
// -> Chooses the option "Very disappointed" in the single select question. The string has to be identical to the option in your question.
```
### Multi Select Question (Checkbox)
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id=Sun%2CPalms%2CBeach
// -> Selects three options "Sun, Palms and Beach" in the multi select question. The strings have to be identical to the options in your question.
```
### Open Text Question
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?openText_question_id=I%20love%20Formbricks
// -> Adds "I love Formbricks" as the answer to the open text question
```
### CTA Question
```tsx
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?cta_question_id=clicked
// -> Adds "clicked" as the answer to the CTA question. Alternatively, you can set it to "dismissed" to skip the question.
```
## Validation
Make sure that the answer in the URL matches the expected type for the first question.
The URL validation works as follows:
- For Rating or NPS questions, the response is parsed as a number and verified if it's accepted by the schema.
- For CTA type questions, the valid values are "clicked" (main CTA) and "dismissed" (skip CTA).
- For Consent type questions, the valid values are "accepted" (consent given) and "dismissed" (consent not given).
- All other question types are strings.
### Youre good to go! 🎉
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1,57 @@
import { Layout } from "@/components/docs/Layout";
import { Fence } from "@/components/shared/Fence";
import { Callout } from "@/components/shared/Callout";
import Image from "next/image";
import PeopleView from "./people-view.png";
export const meta = {
title: "User Identification in Link Surveys",
description:
"Identify users in link surveys via URL parameter. Connect responses to existing users in Formbricks.",
};
Identifying users in link features lets you connect responses from link surveys with existing users in your Formbricks database.
## Purpose
Identifying users in link surveys comes in handy when you:
- Want to send out link surveys to existing users in your database
- Want to connect responses from link surveys with existing users in your database
- Want to gather data and later connect it to a user who has not signed up yet
## Quick Example
```tsx
https://app.formbricks.com/s/clin3dxja02k8l80hpwmx4bjy?userId=ABC123
```
## How it works
To link a response to a user in your Formbricks database, you can pass your internal user Id as a URL parameter.
## Where do I find my userId?
The `userId` we are refering to is the `userId` of your own system. For example, a user signs up to your app and gets the Id `ABC123` assigned then this is the Id you pass along in the URL parameter.
This allows you to connect the response to the user profile of this specific in the Formbricks database. You can then use the response data to create segments for further surveying or invite them to an interview, etc.
## getOrCreateUser - how it works exactly
By default, respondents of link surveys are **not** recorded and displayed in the People view. This would lead to your People view to be spammend with unidentified people.
If you add the `userId` URL parameter a Person will be created, if there is no existing person in your database with a matching `userId`.
**Case 1:** User with userId `ABC111` exists, the response from the link survey will be added to this users profile.
**Case 2:** User with userId `ABC222` does not yet exists, so it is created with the response from the link survey connected to this user.
<Image
src={PeopleView}
alt="If users are identified by email, it will show. If not, the internal Id will be set."
quality="100"
className="rounded-lg"
/>
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Add a new webhook."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -27,11 +27,17 @@ export const meta = {
required: true,
},
{
label: "trigger",
type: "string",
description: "The event that will trigger the webhook.",
label: "triggers",
type: "string[]",
description: "List of events that will trigger the webhook",
required: true,
},
{
label: "surveyIds",
type: "string[]",
description:
"List of survey IDs that will trigger the webhook. If not provided, the webhook will be triggered for all surveys.",
},
]}
example={`{
"url": "https://mysystem.com/myendpoint",
@@ -51,7 +57,8 @@ export const meta = {
"environmentId": "clisypjy4000319t4imm289uo",
"triggers": [
"responseFinished"
]
],
"surveyIds": ["clisypjy4000319t4imm289uo"]
}
}`,
},
@@ -75,16 +82,17 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},
]}
/>
| field name | required | default | description |
| ---------- | -------- | ------- | ------------------------------------------------------------------------------------------------------ |
| url | yes | - | The endpoint that the webhook will send data to |
| trigger | yes | - | The event that will trigger the webhook ("responseCreated" or "responseUpdated" or "responseFinished") |
| field name | required | default | description |
| ---------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| url | yes | - | The endpoint that the webhook will send data to |
| trigger | yes | - | The event that will trigger the webhook ("responseCreated" or "responseUpdated" or "responseFinished") |
| surveyIds | no | - | List of survey IDs that will trigger the webhook. If not provided, the webhook will be triggered for all surveys. |
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Delete a specific webhook by its ID."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -45,7 +45,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Retrieve a specific webhook by its ID."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -45,7 +45,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Retrieve a list of all webhooks."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -47,7 +47,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -10,7 +10,7 @@ import BestPractices from "@/components/shared/BestPractices";
const IndexPage = () => (
<Layout
title="Formbricks | Privacy-first user research"
title="Formbricks | Privacy-first Experience Management"
description="Build qualitative user research into your product. Leverage Best practices to increase Product-Market Fit.">
<Hero />
<div className="hidden lg:block">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

1
apps/web/.env Symbolic link
View File

@@ -0,0 +1 @@
../../.env

View File

@@ -1,4 +0,0 @@
{
"typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@@ -23,7 +23,7 @@ USER nextjs
WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.js .
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Automatically leverage output traces to reduce image size

View File

@@ -1,13 +1,14 @@
"use client";
import { env } from "@/env.mjs";
import { formbricksEnabled } from "@/lib/formbricks";
import formbricks from "@formbricks/js";
import { useEffect } from "react";
/* if (typeof window !== "undefined" && formbricksEnabled) {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
logLevel: "debug",
});
} */
@@ -16,8 +17,8 @@ export default function FormbricksClient({ session }) {
useEffect(() => {
if (formbricksEnabled && session.user && formbricks) {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
});
formbricks.setUserId(session.user.id);
formbricks.setEmail(session.user.email);

View File

@@ -1,21 +1,22 @@
"use client";
import { env } from "@/env.mjs";
import { usePathname, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
const posthogEnabled = process.env.NEXT_PUBLIC_POSTHOG_API_KEY && process.env.NEXT_PUBLIC_POSTHOG_API_HOST;
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
// Check that PostHog is client-side (used to handle Next.js SSR)
if (
typeof window !== "undefined" &&
posthogEnabled &&
typeof process.env.NEXT_PUBLIC_POSTHOG_API_KEY === "string" &&
typeof process.env.NEXT_PUBLIC_POSTHOG_API_HOST === "string"
typeof env.NEXT_PUBLIC_POSTHOG_API_KEY === "string" &&
typeof env.NEXT_PUBLIC_POSTHOG_API_HOST === "string"
) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
posthog.init(env.NEXT_PUBLIC_POSTHOG_API_KEY, {
api_host: env.NEXT_PUBLIC_POSTHOG_API_HOST,
// Disable in development
loaded: (posthog) => {
if (process.env.NODE_ENV === "development") posthog.opt_out_capturing();

View File

@@ -1,3 +1,4 @@
import { env } from "@/env.mjs";
import { verifyPassword } from "@/lib/auth";
import { verifyToken } from "@/lib/jwt";
import { prisma } from "@formbricks/database";
@@ -120,12 +121,12 @@ export const authOptions: NextAuthOptions = {
},
}),
GitHubProvider({
clientId: process.env.GITHUB_ID || "",
clientSecret: process.env.GITHUB_SECRET || "",
clientId: env.GITHUB_ID || "",
clientSecret: env.GITHUB_SECRET || "",
}),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
clientId: env.GOOGLE_CLIENT_ID || "",
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: true,
}),
],
@@ -189,7 +190,7 @@ export const authOptions: NextAuthOptions = {
},
async signIn({ user, account }: any) {
if (account.provider === "credentials" || account.provider === "token") {
if (!user.emailVerified && process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
if (!user.emailVerified && env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
}
return true;

View File

@@ -0,0 +1,43 @@
import { responses } from "@/lib/api/response";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
export async function POST() {
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey || apiKey !== process.env.CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const surveys = await prisma.survey.findMany({
where: {
status: "inProgress",
closeOnDate: {
lte: new Date(),
},
},
select: {
id: true,
},
});
if (!surveys.length) {
return responses.successResponse({ message: "No surveys to close" });
}
const mutationResp = await prisma.survey.updateMany({
where: {
id: {
in: surveys.map((survey) => survey.id),
},
},
data: {
status: "completed",
},
});
return responses.successResponse({
message: `Closed ${mutationResp.count} survey(s)`,
});
}

View File

@@ -0,0 +1,228 @@
import { sendEmail } from "@/lib/email";
import { withEmailTemplate } from "@/lib/email-template";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { Insights, NotificationResponse, Survey, SurveyResponse } from "./types";
const getEmailSubject = (productName: string) => {
return `${productName} User Insights - Last Week by Formbricks`;
};
const notificationHeader = (
productName: string,
startDate: string,
endDate: string,
startYear: number,
endYear: number
) =>
`
<div style="display: block; padding: 1rem;">
<div style="float: left;">
<h1>Hey 👋</h1>
</div>
<div style="float: right;">
<p style="text-align: right; margin: 0; font-weight: 600;">Weekly Report for ${productName}</p>
${getNotificationHeaderimePeriod(startDate, endDate, startYear, endYear)}
</div>
</div>
<br/>
<br/>
`;
const getNotificationHeaderimePeriod = (
startDate: string,
endDate: string,
startYear: number,
endYear: number
) => {
if (startYear == endYear) {
return `<p style="text-align: right; margin: 0;">${startDate} - ${endDate} ${endYear}</p>`;
} else {
return `<p style="text-align: right; margin: 0;">${startDate} ${startYear} - ${endDate} ${endYear}</p>`;
}
};
const notificationInsight = (insights: Insights) =>
`<div style="display: block;">
<table style="background-color: #f1f5f9; border-radius:1em; margin-top:1em; margin-bottom:1em;">
<tr>
<td style="text-align:center;">
<p style="font-size:0.9em">Surveys</p>
<h1>${insights.numLiveSurvey}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Displays</p>
<h1>${insights.totalDisplays}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Responses</p>
<h1>${insights.totalResponses}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Completed</p>
<h1>${insights.totalCompletedResponses}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Completion %</p>
<h1>${insights.completionRate.toFixed(2)}%</h1>
</td>
</tr>
</table>
</div>
`;
function convertSurveyStatus(status) {
const statusMap = {
inProgress: "Live",
paused: "Paused",
completed: "Completed",
};
return statusMap[status] || status;
}
const getButtonLabel = (count) => {
if (count === 1) {
return "View Response";
}
return `View ${count > 2 ? count - 1 : "1"} more Response${count > 2 ? "s" : ""}`;
};
const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
if (!surveys.length) return ` `;
return surveys
.filter((survey) => survey.responses.length > 0)
.map((survey) => {
const displayStatus = convertSurveyStatus(survey.status);
const isLive = displayStatus === "Live";
return `
<div style="display: block; margin-top:3em;">
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses" style="color:#1e293b;">
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
</a>
<span style="display: inline; margin-left: 10px; background-color: ${
isLive ? "#34D399" : "#a7f3d0"
}; color: ${isLive ? "#F3F4F6" : "#15803d"}; border-radius:99px; padding: 2px 8px; font-size:0.9em">
${displayStatus}
</span>
${createSurveyFields(survey.responses)}
${
survey.responsesCount >= 1
? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses" style="background: #1e293b; margin-top:1em; font-size:0.9em; font-weight:500">
${getButtonLabel(survey.responsesCount)}
</a>`
: ""
}
<br/></div><br/>`;
})
.join("");
};
const createSurveyFields = (surveryResponses: SurveyResponse[]) => {
let surveyFields = "";
const responseCount = surveryResponses.length;
surveryResponses.forEach((response, index) => {
if (!response) {
return;
}
for (const [headline, answer] of Object.entries(response)) {
surveyFields += `
<div style="margin-top:1em;">
<p style="margin:0px;">${headline}</p>
<p style="font-weight: 500; margin:0px;">${answer}</p>
</div>
`;
}
// Add <hr/> only when there are 2 or more responses to display, and it's not the last response
if (responseCount >= 2 && index < responseCount - 1) {
surveyFields += "<hr/>";
}
});
return surveyFields;
};
const notificationFooter = () => {
return `
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
};
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
return `
<p>Wed love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
<p style="font-weight: bold; padding-top:1em;">Dont let a week pass without learning about your users:</p>
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys" style="background: #1e293b; font-size:0.9em; font-weight:500">Setup a new survey</a>
<br/>
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
};
export const sendWeeklySummaryNotificationEmail = async (
email: string,
notificationData: NotificationResponse
) => {
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const startDate = `${notificationData.lastWeekDate.getDate()} ${
monthNames[notificationData.lastWeekDate.getMonth()]
}`;
const endDate = `${notificationData.currentDate.getDate()} ${
monthNames[notificationData.currentDate.getMonth()]
}`;
const startYear = notificationData.lastWeekDate.getFullYear();
const endYear = notificationData.currentDate.getFullYear();
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.productName),
html: withEmailTemplate(`
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${notificationInsight(notificationData.insights)}
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
${notificationFooter()}
`),
});
};
export const sendNoLiveSurveyNotificationEmail = async (
email: string,
notificationData: NotificationResponse
) => {
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const startDate = `${notificationData.lastWeekDate.getDate()} ${
monthNames[notificationData.lastWeekDate.getMonth()]
}`;
const endDate = `${notificationData.currentDate.getDate()} ${
monthNames[notificationData.currentDate.getMonth()]
}`;
const startYear = notificationData.lastWeekDate.getFullYear();
const endYear = notificationData.currentDate.getFullYear();
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.productName),
html: withEmailTemplate(`
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
`),
});
};

View File

@@ -0,0 +1,187 @@
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { CRON_SECRET } from "@formbricks/lib/constants";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "./email";
import { EnvironmentData, NotificationResponse, ProductData, Survey, SurveyResponse } from "./types";
export async function POST(): Promise<NextResponse> {
// check authentication with x-api-key header and CRON_SECRET env variable
if (headers().get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
// list of email sending promises to wait for
const emailSendingPromises: Promise<void>[] = [];
const products = await getProducts();
// iterate through the products and send weekly summary email to each team member
for await (const product of products) {
// check if there are team members that have weekly summary notification enabled
const teamMembers = product.team.memberships;
const teamMembersWithNotificationEnabled = teamMembers.filter((member) => {
return (
member.user.notificationSettings?.weeklySummary &&
member.user.notificationSettings.weeklySummary[product.id]
);
});
// if there are no team members with weekly summary notification enabled, skip to the next product (do not send email)
if (teamMembersWithNotificationEnabled.length == 0) {
continue;
}
// calculate insights for the product
const notificationResponse = getNotificationResponse(product.environments[0], product.name);
// if there were no responses in the last 7 days, send a different email
if (notificationResponse.insights.totalCompletedResponses == 0) {
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendNoLiveSurveyNotificationEmail(teamMember.user.email, notificationResponse)
);
}
continue;
}
// send weekly summary email
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendWeeklySummaryNotificationEmail(teamMember.user.email, notificationResponse)
);
}
}
// wait for all emails to be sent
await Promise.all(emailSendingPromises);
return responses.successResponse({}, true);
}
const getNotificationResponse = (environment: EnvironmentData, productName: string): NotificationResponse => {
const insights = {
totalCompletedResponses: 0,
totalDisplays: 0,
totalResponses: 0,
completionRate: 0,
numLiveSurvey: 0,
};
const surveys: Survey[] = [];
// iterate through the surveys and calculate the overall insights
for (const survey of environment.surveys) {
const surveyData: Survey = {
id: survey.id,
name: survey.name,
status: survey.status,
responsesCount: survey.responses.length,
responses: [],
};
// iterate through the responses and calculate the survey insights
for (const response of survey.responses) {
// only take the first 3 responses
if (surveyData.responses.length >= 1) {
break;
}
const surveyResponse: SurveyResponse = {};
for (const question of survey.questions) {
const headline = question.headline;
const answer = response.data[question.id]?.toString() || null;
if (answer === null || answer === "" || answer?.length === 0) {
continue;
}
surveyResponse[headline] = answer;
}
surveyData.responses.push(surveyResponse);
}
surveys.push(surveyData);
// calculate the overall insights
if (survey.status == "inProgress") {
insights.numLiveSurvey += 1;
}
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
insights.totalDisplays += survey.displays.length;
insights.totalResponses += survey.responses.length;
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalDisplays) * 100);
}
// build the notification response needed for the emails
const lastWeekDate = new Date();
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
return {
environmentId: environment.id,
currentDate: new Date(),
lastWeekDate,
productName: productName,
surveys,
insights,
};
};
const getProducts = async (): Promise<ProductData[]> => {
// gets all products together with team members, surveys, responses, and displays for the last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return await prisma.product.findMany({
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
select: {
status: true,
},
},
},
},
},
},
team: {
select: {
memberships: {
select: {
user: {
select: {
email: true,
notificationSettings: true,
},
},
},
},
},
},
},
});
};

View File

@@ -0,0 +1,81 @@
import { TResponseData } from "@formbricks/types/v1/responses";
import { TSurveyQuestion } from "@formbricks/types/v1/surveys";
import { TUserNotificationSettings } from "@formbricks/types/v1/users";
import { DisplayStatus, SurveyStatus } from "@prisma/client";
export interface Insights {
totalCompletedResponses: number;
totalDisplays: number;
totalResponses: number;
completionRate: number;
numLiveSurvey: number;
}
export interface SurveyResponse {
[headline: string]: string | number | boolean | Date | string[];
}
export interface Survey {
id: string;
name: string;
responses: SurveyResponse[];
responsesCount: number;
status: string;
}
export interface NotificationResponse {
environmentId: string;
currentDate: Date;
lastWeekDate: Date;
productName: string;
surveys: Survey[];
insights: Insights;
}
// Prisma Types
type ResponseData = {
id: string;
createdAt: Date;
updatedAt: Date;
finished: boolean;
data: TResponseData;
};
type DisplayData = {
status: DisplayStatus;
};
type SurveyData = {
id: string;
name: string;
questions: TSurveyQuestion[];
status: SurveyStatus;
responses: ResponseData[];
displays: DisplayData[];
};
export type EnvironmentData = {
id: string;
surveys: SurveyData[];
};
type UserData = {
email: string;
notificationSettings: TUserNotificationSettings;
};
type MembershipData = {
user: UserData;
};
type TeamData = {
memberships: MembershipData[];
};
export type ProductData = {
id: string;
name: string;
environments: EnvironmentData[];
team: TeamData;
};

View File

@@ -51,6 +51,18 @@ export async function POST(request: Request) {
triggers: {
hasSome: event,
},
OR: [
{
surveyIds: {
has: surveyId,
},
},
{
surveyIds: {
isEmpty: true,
},
},
],
},
});
@@ -60,6 +72,7 @@ export async function POST(request: Request) {
await fetch(webhook.url, {
method: "POST",
body: JSON.stringify({
webhookId: webhook.id,
event,
data,
}),

View File

@@ -1,10 +1,5 @@
/*
THIS FILE IS WORK IN PROGRESS
PLEASE DO NOT USE IT YET
*/
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { markDisplayResponded } from "@formbricks/lib/services/displays";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -14,21 +9,21 @@ export async function OPTIONS(): Promise<NextResponse> {
export async function POST(_: Request, { params }: { params: { displayId: string } }): Promise<NextResponse> {
const { displayId } = params;
const display = await prisma.display.update({
where: {
id: displayId,
},
data: {
status: "responded",
},
});
if (!displayId) {
return responses.badRequestResponse("Missing displayId");
}
return responses.successResponse(
{
...display,
createdAt: display.createdAt.toISOString(),
updatedAt: display.updatedAt.toISOString(),
},
true
);
try {
const display = await markDisplayResponded(displayId);
return responses.successResponse(
{
...display,
createdAt: display.createdAt.toISOString(),
updatedAt: display.updatedAt.toISOString(),
},
true
);
} catch (error) {
return responses.internalServerErrorResponse(error.message);
}
}

View File

@@ -1,11 +1,11 @@
/*
THIS FILE IS WORK IN PROGRESS
PLEASE DO NOT USE IT YET
*/
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { transformErrorToDetails } from "@/lib/api/validator";
import { InvalidInputError } from "@formbricks/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createDisplay } from "@formbricks/lib/services/displays";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { TDisplay, ZDisplayInput } from "@formbricks/types/v1/displays";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -13,76 +13,50 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function POST(request: Request): Promise<NextResponse> {
const { surveyId, personId, environmentId } = await request.json();
const jsonInput: unknown = await request.json();
const inputValidation = ZDisplayInput.safeParse(jsonInput);
if (!surveyId) {
return responses.missingFieldResponse("surveyId", true);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
if (!environmentId) {
return responses.missingFieldResponse("environmentId", true);
const displayInput = inputValidation.data;
// find environmentId from surveyId
let survey;
try {
survey = await getSurvey(displayInput.surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
return responses.internalServerErrorResponse(error.message);
}
}
// get teamId from environment
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
team: {
select: {
id: true,
memberships: {
select: {
userId: true,
role: true,
},
},
},
},
},
},
},
});
// find teamId & teamOwnerId from environmentId
const teamDetails = await getTeamDetails(survey.environmentId);
if (!environment) {
return responses.notFoundResponse("Environment", environmentId, true);
// create display
let display: TDisplay;
try {
display = await createDisplay(displayInput);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
return responses.internalServerErrorResponse(error.message);
}
}
const teamId = environment.product.team.id;
// find team owner
const teamOwnerId = environment.product.team.memberships.find((m) => m.role === "owner")?.userId;
const createBody: any = {
select: {
id: true,
},
data: {
status: "seen",
survey: {
connect: {
id: surveyId,
},
},
},
};
if (personId) {
createBody.data.person = {
connect: {
id: personId,
},
};
}
// create new display
const display = await prisma.display.create(createBody);
if (teamOwnerId) {
await capturePosthogEvent(teamOwnerId, "display created", teamId, {
surveyId,
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "display created", teamDetails.teamId, {
surveyId: displayInput.surveyId,
});
} else {
console.warn("Posthog capture not possible. No team owner found");

View File

@@ -16,6 +16,11 @@ export async function PUT(
{ params }: { params: { responseId: string } }
): Promise<NextResponse> {
const { responseId } = params;
if (!responseId) {
return responses.badRequestResponse("Response ID is missing", undefined, true);
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);

View File

@@ -1,13 +1,13 @@
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createResponse } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
@@ -40,41 +40,9 @@ export async function POST(request: Request): Promise<NextResponse> {
}
}
// prisma call to get the teamId
// TODO use services
const environment = await prisma.environment.findUnique({
where: { id: survey.environmentId },
include: {
product: {
select: {
team: {
select: {
id: true,
memberships: {
where: { role: "owner" },
select: { userId: true },
take: 1,
},
},
},
},
},
},
});
if (!environment) {
return responses.internalServerErrorResponse("Environment not found");
}
const {
product: {
team: { id: teamId, memberships },
},
} = environment;
const teamOwnerId = memberships[0]?.userId;
let response;
const teamDetails = await getTeamDetails(survey.environmentId);
let response: TResponse;
try {
const meta = {
userAgent: {
@@ -113,8 +81,9 @@ export async function POST(request: Request): Promise<NextResponse> {
}
captureTelemetry("response created");
if (teamOwnerId) {
await capturePosthogEvent(teamOwnerId, "response created", teamId, {
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "response created", teamDetails.teamId, {
surveyId: response.surveyId,
surveyType: survey.type,
});

View File

@@ -12,7 +12,21 @@ export async function GET() {
hashedKey: hashApiKey(apiKey),
},
select: {
environment: true,
environment: {
select: {
id: true,
createdAt: true,
updatedAt: true,
type: true,
product: {
select: {
id: true,
name: true,
},
},
widgetSetupCompleted: true,
},
},
},
});
if (!apiKeyData) {

View File

@@ -1,10 +1,11 @@
import { responses } from "@/lib/api/response";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getEnvironmentResponses } from "@formbricks/lib/services/response";
import { getEnvironmentResponses, getSurveyResponses } from "@formbricks/lib/services/response";
import { headers } from "next/headers";
import { DatabaseError } from "@formbricks/errors";
import { getSurvey } from "@formbricks/lib/services/survey";
export async function GET() {
export async function GET(request: Request) {
const apiKey = headers().get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
@@ -19,10 +20,27 @@ export async function GET() {
return responses.notAuthenticatedResponse();
}
// get webhooks from database
// get surveyId from searchParams
const { searchParams } = new URL(request.url);
const surveyId = searchParams.get("surveyId");
// get responses from database
try {
const environmentResponses = await getEnvironmentResponses(apiKeyData.environmentId);
return responses.successResponse(environmentResponses);
if (!surveyId) {
const environmentResponses = await getEnvironmentResponses(apiKeyData.environmentId);
return responses.successResponse(environmentResponses);
}
// check if survey is part of environment
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse(surveyId, "survey");
}
if (survey.environmentId !== apiKeyData.environmentId) {
return responses.notFoundResponse(surveyId, "survey");
}
// get responses for survey
const surveyResponses = await getSurveyResponses(surveyId);
return responses.successResponse(surveyResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);

View File

@@ -0,0 +1,32 @@
import { responses } from "@/lib/api/response";
import { DatabaseError } from "@formbricks/errors";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getSurveys } from "@formbricks/lib/services/survey";
import { headers } from "next/headers";
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 surveys from database
try {
const surveys = await getSurveys(apiKeyData.environmentId);
return responses.successResponse(surveys);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
}

View File

@@ -3,14 +3,11 @@ import { verifyInviteToken } from "@/lib/jwt";
import { populateEnvironment } from "@/lib/populate";
import { prisma } from "@formbricks/database";
import { NextResponse } from "next/server";
import { env } from "@/env.mjs";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
if (
inviteToken
? process.env.NEXT_PUBLIC_INVITE_DISABLED === "1"
: process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
) {
if (inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1") {
return NextResponse.json({ error: "Signup disabled" }, { status: 403 });
}
user = { ...user, ...{ email: user.email.toLowerCase() } };
@@ -99,7 +96,7 @@ export async function POST(request: Request) {
await prisma.invite.delete({ where: { id: inviteId } });
}
if (process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
if (env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
await sendVerificationEmail(userData);
}
return NextResponse.json(userData);

View File

@@ -1,4 +1,4 @@
import { PasswordResetForm } from "../../../components/auth/PasswordResetForm";
import { PasswordResetForm } from "../../../components/auth/RequestPasswordResetForm";
import FormWrapper from "@/components/auth/FormWrapper";
const ForgotPasswordPage: React.FC = () => {

View File

@@ -5,7 +5,7 @@ export default function ResetPasswordSuccessPage() {
return (
<FormWrapper>
<div>
<h1 className="leading-2 mb-4 text-center font-bold">Password successfully reset</h1>
<h1 className="leading-2 mb-4 text-center font-bold">Password successfully reset.</h1>
<p className="text-center">You can now log in with your new password</p>
<div className="mt-3 text-center">
<BackToLoginButton />

View File

@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
import { SignupForm } from "@/components/auth/SignupForm";
import FormWrapper from "@/components/auth/FormWrapper";
import Testimonial from "@/components/auth/Testimonial";
import { env } from "@/env.mjs";
export default function SignUpPage() {
const searchParams = useSearchParams();
@@ -18,9 +19,7 @@ export default function SignUpPage() {
<div className="col-span-3 flex flex-col items-center justify-center">
<FormWrapper>
{(
inviteToken
? process.env.NEXT_PUBLIC_INVITE_DISABLED === "1"
: process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
) ? (
<>
<h1 className="leading-2 mb-4 text-center font-bold">Sign up disabled</h1>

View File

@@ -1,9 +1,10 @@
"use client";
import { env } from "@/env.mjs";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
const posthogEnabled = process.env.NEXT_PUBLIC_POSTHOG_API_KEY && process.env.NEXT_PUBLIC_POSTHOG_API_HOST;
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
export default function PosthogIdentify({ session }: { session: Session }) {
const posthog = usePostHog();

View File

@@ -45,13 +45,11 @@ export default function EventClassesList({ environmentId }) {
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">Edit</span>
<div className="col-span-4 pl-6 ">User Actions</div>
<div className="text-center"># Reps</div>
<div className="text-center">Created</div>
<div className="text-center">
<span className="sr-only">Edit</span>
</div>
</div>
<div className="grid-cols-7">
{eventClasses.map((eventClass) => (
@@ -61,7 +59,7 @@ export default function EventClassesList({ environmentId }) {
}}
className="w-full"
key={eventClass.id}>
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg hover:bg-slate-100">
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
@@ -85,17 +83,7 @@ export default function EventClassesList({ environmentId }) {
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSinceConditionally(eventClass.createdAt)}
</div>
<div className="text-center">
{/* {eventClass.type !== "automatic" && (
<button
onClick={(e) => {
handleOpenEventDetailModalClick(e, eventClass);
}}
className="text-brand-dark hover:text-brand">
Edit<span className="sr-only">, {eventClass.name}</span>
</button>
)} */}
</div>
<div className="text-center"></div>
</div>
</button>
))}

View File

@@ -6,12 +6,17 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import PosthogIdentify from "./PosthogIdentify";
import FormbricksClient from "../../FormbricksClient";
import { PosthogClientWrapper } from "../../PosthogClientWrapper";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
export default async function EnvironmentLayout({ children, params }) {
const session = await getServerSession(authOptions);
if (!session) {
return redirect(`/auth/login`);
}
const hasAccess = await hasUserEnvironmentAccess(session.user, params.environmentId);
if (!hasAccess) {
throw new Error("User does not have access to this environment");
}
return (
<>

View File

@@ -1,78 +0,0 @@
"use client";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { usePeople } from "@/lib/people/people";
import { truncateMiddle } from "@/lib/utils";
import { ErrorComponent, PersonAvatar } from "@formbricks/ui";
import Link from "next/link";
export default function PeopleList({ environmentId }: { environmentId: string }) {
const { people, isLoadingPeople, isErrorPeople } = usePeople(environmentId);
if (isLoadingPeople) {
return <LoadingSpinner />;
}
if (isErrorPeople) {
return <ErrorComponent />;
}
const getAttributeValue = (person, attributeName) => {
return person.attributes.find((a) => a.attributeClass.name === attributeName)?.value;
};
return (
<>
{people.length === 0 ? (
<EmptySpaceFiller type="table" environmentId={environmentId} />
) : (
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6 ">User</div>
<div className="col-span-2 text-center">User ID</div>
<div className="text-center">Email</div>
<div className="text-center">Sessions</div>
</div>
<div className="grid-cols-7">
{people.map((person) => (
<Link
href={`/environments/${environmentId}/people/${person.id}`}
key={person.id}
className="w-full">
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
<PersonAvatar personId={person.id} />
</div>
<div className="ml-4">
<div className="ph-no-capture font-medium text-slate-900">
{getAttributeValue(person, "email") ? (
<span>{getAttributeValue(person, "email")}</span>
) : (
<span>{person.id}</span>
)}
</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="ph-no-capture text-slate-900">
{truncateMiddle(getAttributeValue(person, "userId"), 24)}
</div>
</div>
<div className="ph-no-capture my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{getAttributeValue(person, "email")}</div>
</div>
<div className="ph-no-capture my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{person._count?.sessions}</div>
</div>
</div>
</Link>
))}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,31 @@
export default function Loading() {
return (
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">User</div>
<div className="col-span-2 text-center">User ID</div>
<div className="col-span-2 text-center">Email</div>
</div>
<div className="w-full">
{[...Array(3)].map((_, index) => (
<div key={index} className="m-2 grid h-16 grid-cols-7 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="ph-no-capture h-10 w-10 flex-shrink-0 animate-pulse rounded-full bg-gray-200"></div>
<div className="ml-4">
<div className="ph-no-capture h-4 w-28 animate-pulse rounded-full bg-gray-200 font-medium text-slate-900"></div>
</div>
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="ph-no-capture m-12 h-4 animate-pulse rounded-full bg-gray-200 text-slate-900"></div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="ph-no-capture m-12 h-4 animate-pulse rounded-full bg-gray-200 text-slate-900"></div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,13 +1,63 @@
import PeopleList from "./PeopleList";
export const revalidate = 0;
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { truncateMiddle } from "@/lib/utils";
import { TransformPersonOutput, getPeople } from "@formbricks/lib/services/person";
import { PersonAvatar } from "@formbricks/ui";
import Link from "next/link";
const getAttributeValue = (person: TransformPersonOutput, attributeName: string) =>
person.attributes[attributeName]?.toString();
export default async function PeoplePage({ params }) {
const people = await getPeople(params.environmentId);
export default function PeoplePage({ params }) {
return (
<>
<h1 className="my-2 text-3xl font-bold text-slate-800">People</h1>
<p className="mb-6 text-slate-500">
A list of all people who used your application since embedding the Formbricks JS widget.
</p>
<PeopleList environmentId={params.environmentId} />
{people.length === 0 ? (
<EmptySpaceFiller type="table" environmentId={params.environmentId} />
) : (
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6 ">User</div>
<div className="col-span-2 text-center">User ID</div>
<div className="col-span-2 text-center">Email</div>
</div>
{people.map((person) => (
<Link
href={`/environments/${params.environmentId}/people/${person.id}`}
key={person.id}
className="w-full">
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
<PersonAvatar personId={person.id} />
</div>
<div className="ml-4">
<div className="ph-no-capture font-medium text-slate-900">
{getAttributeValue(person, "email") ? (
<span>{getAttributeValue(person, "email")}</span>
) : (
<span>{person.id}</span>
)}
</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="ph-no-capture text-slate-900">
{truncateMiddle(getAttributeValue(person, "userId"), 24)}
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="ph-no-capture text-slate-900">{getAttributeValue(person, "email")}</div>
</div>
</div>
</Link>
))}
</div>
)}
</>
);
}

View File

@@ -8,6 +8,7 @@ export default function SettingsCard({
soon = false,
noPadding = false,
dangerZone,
beta,
}: {
title: string;
description: string;
@@ -15,18 +16,19 @@ export default function SettingsCard({
soon?: boolean;
noPadding?: boolean;
dangerZone?: boolean;
beta?: boolean;
}) {
return (
<div className="my-4 w-full bg-white shadow sm:rounded-lg">
<div className="rounded-t-lg border-b border-slate-200 bg-slate-100 px-6 py-5">
<div className="flex">
<h3
className={`${
dangerZone ? "text-red-600" : "text-slate-900"
} "mr-2 text-lg font-medium leading-6 `}>
<h3 className={`${dangerZone ? "text-red-600" : "text-slate-900"} "text-lg font-medium leading-6 `}>
{title}
</h3>
{soon && <Badge text="coming soon" size="normal" type="success" />}
<div className="ml-2">
{beta && <Badge text="Beta" size="normal" type="warning" />}
{soon && <Badge text="coming soon" size="normal" type="success" />}
</div>
</div>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>

View File

@@ -67,7 +67,7 @@ export default function AddMemberModal({ open, setOpen, onSubmit }: MemberModalP
render={({ field: { onChange, value } }) => (
<div>
<Label>Role</Label>
<Select value={value} onValueChange={onChange}>
<Select value={value} onValueChange={(v) => onChange(v as MembershipRole)}>
<SelectTrigger className="capitalize">
<SelectValue placeholder={<span className="text-slate-400">Select role</span>} />
</SelectTrigger>

View File

@@ -1,7 +1,10 @@
"use client";
import ShareInviteModal from "@/app/environments/[environmentId]/settings/members/ShareInviteModal";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import { env } from "@/env.mjs";
import {
addMember,
deleteInvite,
@@ -12,6 +15,8 @@ import {
updateMemberRole,
useMembers,
} from "@/lib/members";
import { useProfile } from "@/lib/profile";
import { capitalizeFirstLetter } from "@/lib/utils";
import {
Badge,
Button,
@@ -28,15 +33,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import toast from "react-hot-toast";
import AddMemberModal from "./AddMemberModal";
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;
@@ -180,6 +181,12 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
mutateTeam();
};
const isExpired = (invite) => {
const now = new Date();
const expiresAt = new Date(invite.expiresAt);
return now > expiresAt;
};
return (
<>
<div className="mb-6 text-right">
@@ -191,7 +198,7 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
}}>
Create New Team
</Button>
{process.env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
{env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
<Button
variant="darkCTA"
onClick={() => {
@@ -236,7 +243,12 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
/>
</div>
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
{!member.accepted && <Badge className="mr-2" type="warning" text="Pending" size="tiny" />}
{!member.accepted &&
(isExpired(member) ? (
<Badge className="mr-2" type="gray" text="Expired" size="tiny" />
) : (
<Badge className="mr-2" type="warning" text="Pending" size="tiny" />
))}
{member.role !== "owner" && (
<button onClick={(e) => handleOpenDeleteMemberModal(e, member)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />

View File

@@ -1,39 +0,0 @@
"use client";
import { Switch } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { updateNotificationSettings } from "./actions";
interface AlertSwitchProps {
surveyId: string;
userId: string;
notificationSettings: any;
}
export function AlertSwitch({ surveyId, userId, notificationSettings }: AlertSwitchProps) {
const router = useRouter();
return (
<Switch
id="every-submission"
aria-label="toggle every submission"
checked={notificationSettings[surveyId]["responseFinished"]}
onCheckedChange={async () => {
// update notificiation settings
const updatedNotificationSettings = { ...notificationSettings };
updatedNotificationSettings[surveyId]["responseFinished"] =
!updatedNotificationSettings[surveyId]["responseFinished"];
// update db
await updateNotificationSettings(userId, notificationSettings);
// show success message if toggled on, different message if toggled off
if (updatedNotificationSettings[surveyId]["responseFinished"]) {
toast.success(`Every new response is coming your way.`);
} else {
toast.success(`You won't receive notifications anymore.`);
}
router.refresh();
}}
/>
);
}

View File

@@ -1,32 +1,9 @@
import { AlertSwitch } from "./AlertSwitch";
import { Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import type { NotificationSettings } from "@formbricks/types/users";
import { NotificationSwitch } from "./NotificationSwitch";
import { Membership, User } from "./types";
const cleanNotificationSettings = (notificationSettings: NotificationSettings, memberships: Membership[]) => {
const newNotificationSettings = {};
for (const membership of memberships) {
for (const product of membership.team.products) {
for (const environment of product.environments) {
for (const survey of environment.surveys) {
// check if the user has notification settings for this survey
if (notificationSettings[survey.id]) {
newNotificationSettings[survey.id] = notificationSettings[survey.id];
} else {
newNotificationSettings[survey.id] = {
responseFinished: false,
weeklySummary: false,
};
}
}
}
}
}
return newNotificationSettings;
};
interface EditAlertsProps {
memberships: Membership[];
user: User;
@@ -34,8 +11,6 @@ interface EditAlertsProps {
}
export default function EditAlerts({ memberships, user, environmentId }: EditAlertsProps) {
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
return (
<>
{memberships.map((membership) => (
@@ -47,9 +22,9 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
<p className="text-slate-800">{membership.team.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-5 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1">Product</div>
<div className="grid h-12 grid-cols-4 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">Survey</div>
<div className="col-span-1">Product</div>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
@@ -60,48 +35,45 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
<TooltipContent>Sends complete responses, no partials.</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="col-span-1 cursor-default text-center">Weekly Summary</div>
</TooltipTrigger>
<TooltipContent>Coming soon 🚀</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="grid-cols-8 space-y-1 p-2">
{membership.team.products.map((product) => (
<div key={product.id}>
{product.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-5 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={survey.name}>
<div className="col-span-1 flex flex-col justify-center break-all">
{product?.name}
{membership.team.products.some((product) =>
product.environments.some((environment) => environment.surveys.length > 0)
) ? (
<div className="grid-cols-8 space-y-1 p-2">
{membership.team.products.map((product) => (
<div key={product.id}>
{product.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-4 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={survey.name}>
<div className=" col-span-2 flex items-center ">
<p className="text-slate-800">{survey.name}</p>
</div>
<div className="col-span-1 flex flex-col justify-center break-all">
{product?.name}
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrProductId={survey.id}
userId={user.id}
notificationSettings={user.notificationSettings}
notificationType={"alert"}
/>
</div>
</div>
<div className=" col-span-2 flex items-center ">
<p className="text-slate-800">{survey.name}</p>
</div>
<div className="col-span-1 text-center">
<AlertSwitch
surveyId={survey.id}
userId={user.id}
notificationSettings={user.notificationSettings}
/>
</div>
<div className="col-span-1 text-center">
<Switch disabled id="weekly-summary" aria-label="toggle weekly summary" />
</div>
</div>
))}
</div>
))}
</div>
))}
</div>
))}
</div>
))}
</div>
))}
</div>
) : (
<div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
<p>No surveys found.</p>
</div>
)}
<p className="pb-3 pl-4 text-xs text-slate-400">
Want to loop in team mates?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>

View File

@@ -0,0 +1,56 @@
import { UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { NotificationSwitch } from "./NotificationSwitch";
import { Membership, User } from "./types";
interface EditAlertsProps {
memberships: Membership[];
user: User;
environmentId: string;
}
export default function EditWeeklySummary({ memberships, user, environmentId }: EditAlertsProps) {
return (
<>
{memberships.map((membership) => (
<>
<div className="mb-5 flex items-center space-x-3 font-semibold">
<div className="rounded-full bg-slate-100 p-1">
<UsersIcon className="h-6 w-7 text-slate-600" />
</div>
<p className="text-slate-800">{membership.team.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div>Product</div>
<div className="cursor-default pr-12 text-right">Weekly Summary</div>
</div>
<div className="grid-cols-8 space-y-1 p-2">
{membership.team.products.map((product) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-2 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={product.id}>
<div>{product?.name}</div>
<div className="mr-20 flex justify-end">
<NotificationSwitch
surveyOrProductId={product.id}
userId={user.id}
notificationSettings={user.notificationSettings}
notificationType={"weeklySummary"}
/>
</div>
</div>
))}
</div>
<p className="pb-3 pl-4 text-xs text-slate-400">
Want to loop in team mates?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>
Invite them.
</Link>
</p>
</div>
</>
))}
</>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { Switch } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { updateNotificationSettings } from "./actions";
import { NotificationSettings } from "@formbricks/types/users";
import { useState } from "react";
interface NotificationSwitchProps {
surveyOrProductId: string;
userId: string;
notificationSettings: NotificationSettings;
notificationType: "alert" | "weeklySummary";
}
export function NotificationSwitch({
surveyOrProductId,
userId,
notificationSettings,
notificationType,
}: NotificationSwitchProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
return (
<Switch
id="notification-switch"
aria-label="toggle notification settings"
checked={notificationSettings[notificationType][surveyOrProductId]}
disabled={isLoading}
onCheckedChange={async () => {
setIsLoading(true);
// update notificiation settings
const updatedNotificationSettings = { ...notificationSettings };
updatedNotificationSettings[notificationType][surveyOrProductId] =
!updatedNotificationSettings[notificationType][surveyOrProductId];
await updateNotificationSettings(userId, notificationSettings);
setIsLoading(false);
toast.success(`Notification settings updated`, { id: "notification-switch" });
router.refresh();
}}
/>
);
}

View File

@@ -1,9 +1,11 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import SettingsCard from "@/app/environments/[environmentId]/settings/SettingsCard";
import { prisma } from "@formbricks/database";
import { NotificationSettings } from "@formbricks/types/users";
import { getServerSession } from "next-auth";
import SettingsTitle from "../SettingsTitle";
import EditAlerts from "./EditAlerts";
import { prisma } from "@formbricks/database";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import EditWeeklySummary from "./EditWeeklySummary";
import type { Membership, User } from "./types";
async function getUser(userId: string | undefined): Promise<User> {
@@ -29,6 +31,27 @@ async function getUser(userId: string | undefined): Promise<User> {
return user;
}
function cleanNotificationSettings(notificationSettings: NotificationSettings, memberships: Membership[]) {
const newNotificationSettings = { alert: {}, weeklySummary: {} };
for (const membership of memberships) {
for (const product of membership.team.products) {
// set default values for weekly summary
newNotificationSettings.weeklySummary[product.id] =
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[product.id]) || false;
// set default values for alerts
for (const environment of product.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
notificationSettings[survey.id]?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
}
}
}
return newNotificationSettings;
}
async function getMemberships(userId: string): Promise<Membership[]> {
const memberships = await prisma.membership.findMany({
where: {
@@ -72,13 +95,22 @@ export default async function ProfileSettingsPage({ params }) {
throw new Error("Unauthorized");
}
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
return (
<div>
<SettingsTitle title="Notifications" />
<SettingsCard title="Email alerts" description="Set up an alert to get an email on new responses.">
<SettingsCard
title="Email alerts (Surveys)"
description="Set up an alert to get an email on new responses.">
<EditAlerts memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
beta
title="Weekly summary (Products)"
description="Stay up-to-date with a Weekly every Monday.">
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export default async function ResponsesLimitReachedBanner({
environmentId,
session,
}: ResponsesLimitReachedBannerProps) {
const { responsesCount, limitReached } = await getAnalysisData(session, surveyId);
const { responsesCount, limitReached } = await getAnalysisData(session, surveyId, environmentId);
return (
<>
{limitReached && (

View File

@@ -100,7 +100,7 @@ export default function QuestionsView({
const addQuestion = (question: any) => {
const updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.push(question);
updatedSurvey.questions.push({ ...question, isDraft: true });
setLocalSurvey(updatedSurvey);
setActiveQuestionId(question.id);
internalQuestionIdMap[question.id] = createId();

View File

@@ -1,7 +1,7 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import { Input, Label, Switch, DatePicker } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
@@ -15,20 +15,38 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const [open, setOpen] = useState(false);
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
heading: "Survey Completed",
subheading: "This free & open-source survey has been closed",
});
const [closeOnDate, setCloseOnDate] = useState<Date>();
const handleRedirectCheckMark = () => {
setRedirectToggle((prev) => !prev);
if (redirectToggle && localSurvey.redirectUrl) {
setRedirectToggle(false);
setRedirectUrl(null);
setLocalSurvey({ ...localSurvey, redirectUrl: null });
}
};
const handleSurveyCloseOnDateToggle = () => {
if (surveyCloseOnDateToggle && localSurvey.closeOnDate) {
setSurveyCloseOnDateToggle(false);
setCloseOnDate(undefined);
setLocalSurvey({ ...localSurvey, closeOnDate: null });
return;
}
if (redirectToggle) {
setRedirectToggle(false);
if (surveyCloseOnDateToggle) {
setSurveyCloseOnDateToggle(false);
return;
}
setRedirectToggle(true);
setSurveyCloseOnDateToggle(true);
};
const handleRedirectUrlChange = (link: string) => {
@@ -36,12 +54,59 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setLocalSurvey({ ...localSurvey, redirectUrl: link });
};
const handleCloseSurveyMessageToggle = () => {
setSurveyClosedMessageToggle((prev) => !prev);
if (surveyClosedMessageToggle && localSurvey.surveyClosedMessage) {
setLocalSurvey({ ...localSurvey, surveyClosedMessage: null });
}
};
const handleCloseOnDateChange = (date: Date) => {
const equivalentDate = date?.getDate();
date?.setUTCHours(0, 0, 0, 0);
date?.setDate(equivalentDate);
setCloseOnDate(date);
setLocalSurvey({ ...localSurvey, closeOnDate: date ?? null });
};
const handleClosedSurveyMessageChange = ({
heading,
subheading,
}: {
heading?: string;
subheading?: string;
}) => {
const message = {
heading: heading ?? surveyClosedMessage.heading,
subheading: subheading ?? surveyClosedMessage.subheading,
};
setSurveyClosedMessage(message);
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
};
useEffect(() => {
if (localSurvey.redirectUrl) {
setRedirectUrl(localSurvey.redirectUrl);
setRedirectToggle(true);
}
if (!!localSurvey.surveyClosedMessage) {
setSurveyClosedMessage({
heading: localSurvey.surveyClosedMessage.heading ?? surveyClosedMessage.heading,
subheading: localSurvey.surveyClosedMessage.subheading ?? surveyClosedMessage.subheading,
});
setSurveyClosedMessageToggle(true);
}
if (localSurvey.closeOnDate) {
setCloseOnDate(localSurvey.closeOnDate);
setSurveyCloseOnDateToggle(true);
}
}, []);
const handleCheckMark = () => {
if (autoComplete) {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: null };
@@ -113,8 +178,8 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
</div>
)}
{localSurvey.type === "link" && (
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<>
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
@@ -125,18 +190,110 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
</div>
</Label>
</div>
<div className="mt-4">
{redirectToggle && (
{redirectToggle && (
<div className="ml-2 space-x-1 px-4 pb-4">
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
)}
</div>
)}
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch
id="redirectUrl"
checked={surveyClosedMessageToggle}
onCheckedChange={handleCloseSurveyMessageToggle}
/>
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
{"Adjust 'Survey Closed' Message"}
</h3>
<p className="text-xs font-normal text-slate-500">
Change the message visitors see when the survey is closed.
</p>
</div>
</Label>
</div>
</div>
{surveyClosedMessageToggle && (
<div className="ml-2 space-x-1 px-4 pb-4">
<div>
<Label htmlFor="headline">Heading</Label>
<div className="mt-2">
<Input
autoFocus
id="heading"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="headline">Subheading</Label>
<div className="mt-2">
<Input
id="subheading"
name="subheading"
defaultValue={surveyClosedMessage.subheading}
onChange={(e) => handleClosedSurveyMessageChange({ subheading: e.target.value })}
/>
</div>
</div>
</div>
)}
</>
)}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
{redirectToggle && (
<div className="mt-4">
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
)}
</div>
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="surveyDeadline"
checked={surveyCloseOnDateToggle}
onCheckedChange={handleSurveyCloseOnDateToggle}
/>
<Label htmlFor="surveyDeadline" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Close survey on date</h3>
{localSurvey.status === "completed" && (
<p className="text-xs font-normal text-slate-500">
This form is already completed. You can change the status settings to make best use of
this option.
</p>
)}
</div>
</Label>
</div>
{surveyCloseOnDateToggle && (
<div className="mt-4">
<DatePicker date={closeOnDate} handleDateChange={handleCloseOnDateChange} />
</div>
)}
</div>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -86,7 +86,15 @@ export default function SurveyMenuBar({
};
const saveSurveyAction = (shouldNavigateBack = false) => {
triggerSurveyMutate({ ...localSurvey })
// variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question
const strippedSurvey = {
...localSurvey,
questions: localSurvey.questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
}),
};
triggerSurveyMutate({ ...strippedSurvey })
.then(async (response) => {
if (!response?.ok) {
throw new Error(await response?.text());
@@ -108,6 +116,7 @@ export default function SurveyMenuBar({
toast.error(`Error saving changes`);
});
};
return (
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2 whitespace-nowrap">

View File

@@ -1,25 +1,41 @@
"use client";
import { Button, Input, Label } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/solid";
import { Input, Label } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
export default function UpdateQuestionId({ localSurvey, question, questionIdx, updateQuestion }) {
const [currentValue, setCurrentValue] = useState(question.id);
const [prevValue, setPrevValue] = useState(question.id);
const saveAction = () => {
// return early if the input value was not changed
if (currentValue === prevValue) {
return;
}
// check if id is unique
const questionIds = localSurvey.questions.map((q) => q.id);
if (questionIds.includes(currentValue)) {
alert("Question Identifier must be unique within the survey.");
toast.error("IDs have to be unique per survey.");
setCurrentValue(question.id);
return;
}
// check if id contains any spaces
if (currentValue.trim() === "" || currentValue.includes(" ")) {
toast.error("ID should not contain space.");
setCurrentValue(question.id);
return;
}
updateQuestion(questionIdx, { id: currentValue });
toast.success("Question ID updated.");
setPrevValue(currentValue); // after successful update, set current value as previous value
};
const isInputInvalid = currentValue.trim() === "" || currentValue.includes(" ");
return (
<div>
<Label htmlFor="questionId">Question ID</Label>
@@ -29,17 +45,10 @@ export default function UpdateQuestionId({ localSurvey, question, questionIdx, u
name="questionId"
value={currentValue}
onChange={(e) => setCurrentValue(e.target.value)}
disabled={localSurvey.status !== "draft"}
onBlur={saveAction}
disabled={!(localSurvey.status === "draft" || question.isDraft)}
className={isInputInvalid ? "border-red-300 focus:border-red-300" : ""}
/>
{localSurvey.status === "draft" && (
<Button
variant="darkCTA"
className="ml-2 bg-slate-600 text-white hover:bg-slate-700 disabled:bg-slate-400"
onClick={saveAction}
disabled={currentValue === question.id}>
<CheckIcon className="h-4 w-4" />
</Button>
)}
</div>
</div>
);

View File

@@ -1,5 +1,6 @@
"use client";
import AddNoCodeEventModal from "@/app/environments/[environmentId]/events/AddNoCodeEventModal";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEventClasses } from "@/lib/eventClasses/eventClasses";
import { cn } from "@formbricks/lib/cn";
@@ -7,7 +8,7 @@ import type { Survey } from "@formbricks/types/surveys";
import {
Badge,
Button,
Switch,
Input,
Label,
Select,
SelectContent,
@@ -15,12 +16,11 @@ import {
SelectSeparator,
SelectTrigger,
SelectValue,
Input,
Switch,
} from "@formbricks/ui";
import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
import AddNoCodeEventModal from "../../../events/AddNoCodeEventModal";
interface WhenToSendCardProps {
localSurvey: Survey;

View File

@@ -172,7 +172,11 @@ export default function ResponseTimeline({
return (
<div className="space-y-4">
{responses.length === 0 ? (
<EmptySpaceFiller type="response" environmentId={environmentId} />
<EmptySpaceFiller
type="response"
environmentId={environmentId}
noWidgetRequired={survey.type === "link"}
/>
) : (
<div>
<Button variant="darkCTA" onClick={() => downloadResponses()} loading={isDownloadCSVLoading}>

Some files were not shown because too many files have changed in this diff Show More