Merge branch 'main' of github.com:formbricks/formbricks into feature/integrations
@@ -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=
|
||||
15
.env.example
@@ -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=
|
||||
|
||||
9
.github/workflows/checks.yml
vendored
@@ -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
@@ -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
|
||||
23
.github/workflows/cron-weeklySummary.yml
vendored
Normal 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
@@ -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
@@ -0,0 +1 @@
|
||||
apps/web/.env
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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 Do’s and Dont’s 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 O’Brien 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 wasn’t 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>;
|
||||
|
After Width: | Height: | Size: 113 KiB |
@@ -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.
|
||||
|
||||
@@ -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 doesn’t 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" />
|
||||
|
||||
**You’re 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, you’ll 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`:
|
||||
|
||||
|
||||
BIN
apps/formbricks-com/pages/docs/contributing/demo/demoapp.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
67
apps/formbricks-com/pages/docs/contributing/demo/index.mdx
Normal 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>;
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -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>;
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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>;
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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>;
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -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>;
|
||||
@@ -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.
|
||||
|
||||
### You’re good to go! 🎉
|
||||
|
||||
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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>;
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 23 KiB |
1
apps/web/.env
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.env
|
||||
4
apps/web/.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
apps/web/app/api/cron/close_surveys/route.ts
Normal 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)`,
|
||||
});
|
||||
}
|
||||
228
apps/web/app/api/cron/weekly_summary/email.ts
Normal 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>We’d 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;">Don’t 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)}
|
||||
`),
|
||||
});
|
||||
};
|
||||
187
apps/web/app/api/cron/weekly_summary/route.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
81
apps/web/app/api/cron/weekly_summary/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
apps/web/app/api/v1/surveys/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/web/app/environments/[environmentId]/people/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||