Compare commits

...

38 Commits
v0.14 ... v0.15

Author SHA1 Message Date
Johannes
ba1a17578f fix autoFocus in FreeText standalone (#354) 2023-06-09 17:08:32 +02:00
Matti Nannt
1243017718 Include formbricks-api into formbricks-js (#352)
* remove debug loglevel from formbricks usage;

* remove license fields from internal packages

* improve package descriptions, add logger message for survey delay

* include formbricks api into formbricks js

* make formbricks errors package private

* update formbricks-js dependencies to include formbricks-api

* update formbricks-js to 0.1.20
2023-06-09 15:10:01 +02:00
Johannes
93c66c0caf Update Notification Email Subject (#350)
* update email noti subject

* add improvement to PR template
2023-06-09 15:02:57 +02:00
Johannes
4bfaf68de2 Smoothen Progressbar animations and minor survey editor improvements (#339)
* auto focus on sign up

* update PR template

* add updatedAt date to survey summary

* add animation to Progress, make timer smoother

* change button size in question card, auto focus

* add transition to js widget, fix auto focus in editor

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-06-09 10:31:22 +02:00
Moritz Rengert
d2aa9b5f04 fix rating question button alignment (#341)
* fix: input in Link Survey / Preview alignment

* fix: input alignment in js package
2023-06-09 10:17:31 +02:00
Johannes
91d4b09453 Feature/template update (#343)
* improve layout and information design of templates view

---------

Co-authored-by: moritzrengert <moritz@rengert.de>
2023-06-09 10:14:02 +02:00
Moritz Rengert
fc6534fa19 feature/delay survey (#345)
* add delay option to survey trigger
2023-06-09 10:08:23 +02:00
Johannes
b7e6ef5bd6 Merge pull request #346 from formbricks/lp/add-careers
add careers page, update OSS friends
2023-06-08 15:45:19 +02:00
Johannes
f0d321b073 add careers page, update OSS friends 2023-06-08 15:41:12 +02:00
Matti Nannt
944c861b18 Fix Formbricks Usage Bug leading to unidentified users (#340)
* move formbricks client to useEffect only

* add formbricks client to onboarding
2023-06-07 11:30:31 +02:00
Moritz Rengert
8a2beab5d1 Add Other Option to Multiple Choice Questions (#314)
* add other options to multiple choice question types

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2023-06-06 20:10:47 +02:00
Matti Nannt
d7fb29607a bugfix: mark onboarding responses as finished in formbricks (#338) 2023-06-06 15:00:32 +02:00
Matti Nannt
e2ebad0735 add current page url to formbricks-js logging (#337) 2023-06-06 09:19:18 +02:00
Matti Nannt
bd31d87046 Multiple fixes for Formbricks usage within Formbricks (#336)
* use label instead of id in onboarding analysis, add logout to formbricks usage

* add await option for all sdk commands, fix logout bug in formbricks usage
2023-06-05 17:22:52 +02:00
Matti Nannt
7040755b40 send onboarding results to formbricks (#335) 2023-06-05 11:41:47 +02:00
Matti Nannt
c4e70fbfaa update package dependencies (#333) 2023-06-01 19:16:54 +02:00
Midka
7fa2a260e8 create: api wrapper & errors package (#262)
* add @formbricks/api (api abstraction layer) and @formbricks/errors package to monorepo
* use @formbricks/api in @formbricks/js to expose an api endpoint

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-06-01 14:02:53 +02:00
Johannes
dbe7f138b6 Merge pull request #331 from formbricks/lp/reorder-friends
Reorder OSS Friends (alphabetically)
2023-06-01 13:43:07 +02:00
Johannes
35fc7b2d25 reorder friends 2023-06-01 13:36:44 +02:00
Johannes
37a0914c5a Merge pull request #323 from kof/patch-1
Added Webstudio to oss-friends.tsx
2023-06-01 10:51:43 +02:00
Moritz Rengert
8e43939206 fix: extract activeQuestionId from submit data not the expanded form (#326) 2023-06-01 08:51:32 +02:00
Matti Nannt
c4dd7ae4a2 fix security issue in link (#330) 2023-05-31 18:22:12 +02:00
Matti Nannt
0f6210c559 fix prisma commands with new json plugin (#328)
* add prisma migration, change prisma commands

* remove userAttributes from type definitions
2023-05-31 17:45:47 +02:00
Matti Nannt
965ae44344 Create SECURITY.md (#329) 2023-05-31 17:45:23 +02:00
Matti Nannt
0e94900e2c enhance prisma json types (#327) 2023-05-31 15:57:10 +02:00
Matti Nannt
99bb6932c9 update vercel migration script to fix preview deployment (#325) 2023-05-31 10:31:10 +02:00
Matti Nannt
a2e428f3c9 update readme (#324) 2023-05-31 09:55:52 +02:00
Johannes
78f7b4d03e Duplicate Questions, Add Survey Name to Summary, Update Login Screen (#322)
* Duplicate Questions, Add Survey Name to Summary, Update Login Screen

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-05-31 09:52:58 +02:00
Oleg Isonen
38f1803188 Update oss-friends.tsx
Added Webstudio
2023-05-30 21:13:16 +02:00
Johannes
66c747d1ca Merge pull request #321 from formbricks/fix/logic-in-last-question
fix/logic in last question
2023-05-30 10:53:44 +02:00
Johannes
0b24f1fe09 add "Preview" text to survey preview 2023-05-30 10:52:19 +02:00
moritzrengert
9631776552 fix: remove comments and console.log 2023-05-30 09:51:59 +02:00
moritzrengert
726b734b1a fix: update js package to make last question work with logic. fix build errors 2023-05-30 09:45:27 +02:00
moritzrengert
7ba1cc5055 fix: upade link survey to have logic working on last question 2023-05-30 09:38:59 +02:00
moritzrengert
94a10b2870 fix: thank you card not opening on skip logic, simplify check 2023-05-30 09:32:12 +02:00
moritzrengert
f71cc87b3d fix: update goToNextQuestion logic to work in last question 2023-05-30 08:50:39 +02:00
Johannes
b70b0008c1 Merge pull request #320 from formbricks/lp/add-boxy
Add BoxyHQ to OSS Friends
2023-05-29 17:44:08 +02:00
Johannes
5601f046d7 Add BoxyHQ to OSS Friends 2023-05-29 10:40:56 -05:00
111 changed files with 4694 additions and 1689 deletions

View File

@@ -100,4 +100,9 @@ GOOGLE_CLIENT_SECRET=
NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID=
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=

View File

@@ -14,6 +14,7 @@ Fixes # (issue)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Chore (refactoring code, technical debt, workflow improvements)
- [ ] Enhancement (small improvements)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change adds a new database migration
@@ -37,4 +38,5 @@ Fixes # (issue)
- [ ] Checked for warnings, there are none
- [ ] Removed all `console.logs`
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
- [ ] My changes don't cause any responsiveness issues
- [ ] Updated the Formbricks Docs if changes were necessary

View File

@@ -54,7 +54,7 @@ Formbricks helps you apply best practices from data-driven work and experience m
| | Feature |
| --- | ------------------------------------------ |
| 👷 | Zapier, Slack & Posthog Integration |
| 👷 | Branching Logic in Surveys |
| 👷 | Webhooks |
| 🗒️ | Filtering Options in Survey Analysis |
| 🗒️ | Multi-Language Functionality |
| 🗒️ | Auto-complete Surveys after at x responses |

39
SECURITY.md Normal file
View File

@@ -0,0 +1,39 @@
# Security
Contact: security@formbricks.com
Based on [https://supabase.com/.well-known/security.txt](https://supabase.com/.well-known/security.txt)
At Formbricks, we consider the security of our systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present.
If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our clients and our systems.
## Out of scope vulnerabilities:
- Clickjacking on pages with no sensitive actions.
- Unauthenticated/logout/login CSRF.
- Attacks requiring MITM or physical access to a user's device.
- Any activity that could lead to the disruption of our service (DoS).
- Content spoofing and text injection issues without showing an attack vector/without being able to modify HTML/CSS.
- Email spoofing
- Missing DNSSEC, CAA, CSP headers
- Lack of Secure or HTTP only flag on non-sensitive cookies
- Deadlinks
## Please do the following:
- E-mail your findings to [security@formbricks.com](mailto:security@formbricks.com).
- Do not run automated scanners on our infrastructure or dashboard. If you wish to do this, contact us and we will set up a sandbox for you.
- Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data,
- Do not reveal the problem to others until it has been resolved,
- Do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties,
- Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Usually, the IP address or the URL of the affected system and a description of the vulnerability will be sufficient, but complex vulnerabilities may require further explanation.
## What we promise:
- We will respond to your report within 3 business days with our evaluation of the report and an expected resolution date,
- If you have followed the instructions above, we will not take any legal action against you in regard to the report,
- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission,
- We will keep you informed of the progress towards resolving the problem,
- In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise), and
- We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved.

View File

@@ -1,4 +1,4 @@
import formbricks from "@formbricks/js";
import formbricks, { PersonId, SurveyId } from "@formbricks/js";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect } from "react";

View File

@@ -48,22 +48,6 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
))}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* <button
type="button"
onClick={() => {
onTemplateClick(activeTemplate);
setActiveTemplate(activeTemplate);
}}
className={cn(
activeTemplate?.name === customSurvey.name
? "ring-brand border-transparent ring-2"
: "hover:border-brand-dark border-dashed border-slate-300",
"duration-120 group relative rounded-lg border-2 bg-transparent p-8 transition-colors duration-150"
)}>
<PlusCircleIcon className="text-brand-dark h-8 w-8 transition-all duration-150 group-hover:scale-110" />
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700 ">{customSurvey.name}</h3>
<p className="text-left text-xs text-slate-600 ">{customSurvey.description}</p>
</button> */}
{templates
.filter((template) => selectedFilter === ALL_CATEGORY_NAME || template.category === selectedFilter)
.map((template: Template) => (

View File

@@ -28,7 +28,7 @@ import type { Template } from "@formbricks/types/templates";
const thankYouCardDefault = {
enabled: true,
headline: "Thank you!",
subheader: "We appreciate your time and insight.",
subheader: "We appreciate your feedback.",
};
export const customSurvey: Template = {

View File

@@ -260,7 +260,12 @@ export default function Header() {
<Link
href="/blog"
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
Blog <p className="bg-brand inline rounded-full px-2 text-xs text-white">1</p>
Blog {/* <p className="bg-brand inline rounded-full px-2 text-xs text-white">1</p> */}
</Link>
<Link
href="/careers"
className="text-base font-medium text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">
Careers <p className="bg-brand inline rounded-full px-2 text-xs text-white">2</p>
</Link>
{/* <Link
@@ -363,6 +368,7 @@ export default function Header() {
<Link href="#pricing">Pricing</Link>
<Link href="/docs">Docs</Link>
<Link href="/blog">Blog</Link>
<Link href="/careers">Careers</Link>
<Button
variant="secondary"
EndIcon={GitHubIcon}

View File

@@ -0,0 +1,46 @@
import Layout from "@/components/shared/Layout";
import HeroTitle from "@/components/shared/HeroTitle";
import Link from "next/link";
const Roles = [
{
name: "Full-Stack Engineer",
description: "Join early and be a part of our journey from start to IPO 🚀",
location: "Worldwide",
workplace: "Remote",
},
{
name: "Junior Full-Stack Engineer",
description: "All you want is write code and learn? You're exactly right!",
location: "Worldwide",
workplace: "Remote",
},
];
export default function CareersPage() {
return (
<Layout
title="Careers"
description="Work with us on helping teams make customer-centric decisions - all privacy-focused.">
<HeroTitle
headingPt1="Help teams make"
headingTeal="customer-centric"
headingPt2="decisions."
subheading="We are hiring! Please see all open roles below:"
/>
<div className="mx-auto w-3/4">
{Roles.map((role) => (
<Link
href="https://formbricks.notion.site/Work-at-Formbricks-6c3ad218b2c7461ca2714ce2101730e4?pvs=4"
target="_blank"
key="role.name">
<div className="mb-6 rounded-lg border border-slate-300 bg-slate-100 p-6 shadow-sm hover:bg-slate-50">
<h4 className="text-xl font-bold text-slate-700">{role.name}</h4>
<p className="text-lg text-slate-500">{role.description}</p>
</div>
</Link>
))}
</div>
</Layout>
);
}

View File

@@ -49,7 +49,7 @@ To get the project running locally on your machine you need to have the followin
1. Make sure your Docker containers are running. Then let prisma set up the database for you:
```bash
pnpm dlx prisma migrate dev
pnpm db:migrate:dev
```
1. Start the development server of the app:

View File

@@ -3,6 +3,12 @@ import HeroTitle from "@/components/shared/HeroTitle";
import { Button } from "@formbricks/ui";
const OSSFriends = [
{
name: "BoxyHQ",
description:
"BoxyHQs suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
href: "https://boxyhq.com",
},
{
name: "Cal.com",
description:
@@ -33,12 +39,6 @@ const OSSFriends = [
"Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
href: "https://formbricks.com",
},
{
name: "Forward Email",
description:
"Free email forwarding for custom domains. For 6 years and counting, we are the go-to email service for thousands of creators, developers, and businesses.",
href: "https://forwardemail.net",
},
{
name: "GitWonk",
description:
@@ -93,6 +93,11 @@ const OSSFriends = [
"Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
href: "https://www.webiny.com",
},
{
name: "Webstudio",
description: "Webstudio is an open source alternative to Webflow",
href: "https://webstudio.is",
},
];
export default function OSSFriendsPage() {

View File

@@ -10,7 +10,7 @@ COPY .env /app/apps/web/
RUN pnpm install
# Build the project
RUN pnpm dlx prisma generate
RUN pnpm prebuild --filter=web...
RUN pnpm turbo run build --filter=web...
FROM node:18-alpine AS runner

View File

@@ -1,21 +1,24 @@
"use client";
import { formbricksEnabled } from "@/lib/formbricks";
import formbricks from "@formbricks/js";
import { useEffect } from "react";
const formbricksEnabled =
typeof process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST && process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
if (typeof window !== "undefined" && formbricksEnabled) {
/* if (typeof window !== "undefined" && formbricksEnabled) {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
logLevel: "debug",
});
}
} */
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 || "",
});
formbricks.setUserId(session.user.id);
formbricks.setEmail(session.user.email);
if (session.user.plan) {

View File

@@ -4,13 +4,15 @@ import FormWrapper from "@/components/auth/FormWrapper";
export default function SignInPage() {
return (
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-200 to-slate-50 lg:grid-cols-2">
<div className="hidden lg:flex">
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
<div className="col-span-2 hidden lg:flex">
<Testimonial />
</div>
<FormWrapper>
<SigninForm />
</FormWrapper>
<div className="col-span-3 flex flex-col items-center justify-center">
<FormWrapper>
<SigninForm />
</FormWrapper>
</div>
</div>
);
}

View File

@@ -5,29 +5,31 @@ import Testimonial from "@/components/auth/Testimonial";
export default function SignUpPage() {
return (
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-200 to-slate-50 lg:grid-cols-2">
<div className="hidden lg:flex">
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
<div className="col-span-2 hidden lg:flex">
<Testimonial />
</div>
<FormWrapper>
{process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1" ? (
<>
<h1 className="leading-2 mb-4 text-center font-bold">Sign up disabled</h1>
<p className="text-center">
The account creation is disabled in this instance. Please contact the site administrator to
create an account.
</p>
<hr className="my-4" />
<Link
href="/"
className="mt-5 flex w-full justify-center rounded-md border border-slate-400 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Login
</Link>
</>
) : (
<SignupForm />
)}
</FormWrapper>
<div className="col-span-3 flex flex-col items-center justify-center">
<FormWrapper>
{process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1" ? (
<>
<h1 className="leading-2 mb-4 text-center font-bold">Sign up disabled</h1>
<p className="text-center">
The account creation is disabled in this instance. Please contact the site administrator to
create an account.
</p>
<hr className="my-4" />
<Link
href="/"
className="mt-5 flex w-full justify-center rounded-md border border-slate-400 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Login
</Link>
</>
) : (
<SignupForm />
)}
</FormWrapper>
</div>
</div>
);
}

View File

@@ -60,6 +60,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
import { formbricksLogout } from "@/lib/formbricks";
interface EnvironmentsNavbarProps {
environmentId: string;
@@ -418,9 +419,10 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
signOut();
onClick={async () => {
setLoading(true);
await signOut();
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />

View File

@@ -4,7 +4,7 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import PosthogIdentify from "./PosthogIdentify";
import FormbricksClient from "./FormbricksClient";
import FormbricksClient from "../../FormbricksClient";
import { PosthogClientWrapper } from "../../PosthogClientWrapper";
export default async function EnvironmentLayout({ children, params }) {

View File

@@ -78,7 +78,12 @@ export default function PreviewSurvey({
frameRef.current = requestAnimationFrame(frame);
} else {
handleStopCountdown();
// close modal
setIsModalOpen(false);
// reopen the modal after 1 second
setTimeout(() => {
setIsModalOpen(true);
setActiveQuestionId(questions[0]?.id || ""); // set first question as active
}, 1500);
}
};
@@ -133,13 +138,13 @@ export default function PreviewSurvey({
case "notEquals":
return answerValue !== logic.value;
case "lessThan":
return answerValue < logic.value;
return logic.value !== undefined && answerValue < logic.value;
case "lessEqual":
return answerValue <= logic.value;
return logic.value !== undefined && answerValue <= logic.value;
case "greaterThan":
return answerValue > logic.value;
return logic.value !== undefined && answerValue > logic.value;
case "greaterEqual":
return answerValue >= logic.value;
return logic.value !== undefined && answerValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
@@ -174,6 +179,8 @@ export default function PreviewSurvey({
}
function getNextQuestion(answer: any): string {
// extract activeQuestionId from answer to make it work when form is collapsed.
const activeQuestionId = Object.keys(answer)[0];
if (!activeQuestionId) return "";
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
@@ -191,16 +198,14 @@ export default function PreviewSurvey({
}
}
}
return questions[currentQuestionIndex + 1]?.id || "";
return questions[currentQuestionIndex + 1]?.id || "end";
}
const gotoNextQuestion = (data) => {
const currentIndex = questions.findIndex((q) => q.id === activeQuestionId);
const nextQuestionId = getNextQuestion(data);
if (currentIndex < questions.length - 1 && nextQuestionId !== "end") {
if (nextQuestionId !== "end") {
setActiveQuestionId(nextQuestionId);
// setActiveQuestionId(questions[currentIndex + 1].id);
} else {
if (thankYouCard?.enabled) {
setActiveQuestionId("thank-you-card");
@@ -240,9 +245,9 @@ export default function PreviewSurvey({
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<p>
{previewType === "modal" && (
<span className="ml-4 font-mono text-sm text-slate-400">Your web app</span>
)}
<span className="ml-4 font-mono text-sm text-slate-400">
{previewType === "modal" ? "Your web app" : "Preview"}
</span>
</p>
</div>

View File

@@ -225,13 +225,13 @@ export default function SurveysList({ environmentId }) {
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<a
<Link
className="flex w-full items-center"
href={`${window.location.protocol}//${window.location.host}/s/${survey.id}?preview=true`}
target="_blank">
<EyeIcon className="mr-2 h-4 w-4" />
Preview Survey
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button

View File

@@ -23,5 +23,5 @@ export default function SurveyResultsTab({ activeId, environmentId, surveyId }:
},
];
return <SecondNavbar tabs={tabs} activeId={activeId} />;
return <SecondNavbar tabs={tabs} activeId={activeId} surveyId={surveyId} environmentId={environmentId} />;
}

View File

@@ -21,12 +21,14 @@ export default function CTAQuestionForm({
lastQuestion,
}: CTAQuestionFormProps): JSX.Element {
const [firstRender, setFirstRender] = useState(true);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -17,8 +17,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { TrashIcon } from "@heroicons/react/24/solid";
import { QuestionMarkCircleIcon, TrashIcon } from "@heroicons/react/24/solid";
import { ChevronDown, SplitIcon } from "lucide-react";
import { useMemo } from "react";
import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs";
@@ -129,10 +128,6 @@ export default function LogicEditor({
},
};
// useEffect(() => {
// console.log(question);
// }, [question]);
const addLogic = () => {
const newLogic: Logic[] = !question.logic ? [] : question.logic;
newLogic.push({
@@ -217,13 +212,13 @@ export default function LogicEditor({
{question?.logic?.map((logic, logicIdx) => (
<div key={logicIdx} className="flex items-center space-x-2 space-y-1 text-sm">
<BsArrowReturnRight className="h-4 w-4" />
<p>If this answer</p>
<p className="text-slate-700">If this answer</p>
<Select
defaultValue={logic.condition}
onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
<SelectTrigger className="min-w-fit flex-1">
<SelectValue placeholder="select condition" />
<SelectValue placeholder="Select condition" />
</SelectTrigger>
<SelectContent>
{conditions[question.type].map(
@@ -244,7 +239,7 @@ export default function LogicEditor({
defaultValue={logic.value}
onValueChange={(e) => updateLogic(logicIdx, { value: e })}>
<SelectTrigger>
<SelectValue placeholder="select match type" />
<SelectValue placeholder="Select match type" />
</SelectTrigger>
<SelectContent>
{logicConditions[logic.condition].values?.map((value) => (
@@ -259,7 +254,7 @@ export default function LogicEditor({
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ">
{logic.value?.length === 0 ? (
<p className="text-slate-400">select match type</p>
<p className="text-slate-400">Select match type</p>
) : (
<p>{logic.value.join(", ")}</p>
)}
@@ -285,19 +280,19 @@ export default function LogicEditor({
</div>
)}
<p>skip to</p>
<p className="text-slate-700">skip to</p>
<Select
defaultValue={logic.destination}
onValueChange={(e) => updateLogic(logicIdx, { destination: e })}>
<SelectTrigger className="w-fit overflow-hidden ">
<SelectValue placeholder="select question" />
<SelectValue placeholder="Select question" />
</SelectTrigger>
<SelectContent>
{localSurvey.questions.map(
(question, idx) =>
idx !== questionIdx && (
<SelectItem key={question.id} value={question.id}>
<SelectItem key={question.id} value={question.id} title={question.headline}>
{idx + 1} - {truncate(question.headline, 14)}
</SelectItem>
)
@@ -314,7 +309,7 @@ export default function LogicEditor({
))}
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
<BsArrowDown className="h-4 w-4" />
<p>All other answers will continue to the next question</p>
<p className="text-slate-700">All other answers will continue to the next question</p>
</div>
</div>
)}
@@ -322,19 +317,21 @@ export default function LogicEditor({
<div className="mt-2 flex items-center space-x-2">
<Button
id="logicJumps"
className="bg-slate-100 hover:bg-slate-50"
type="button"
name="logicJumps"
size="sm"
variant="secondary"
StartIcon={SplitIcon}
onClick={() => addLogic()}>
Add Logic
</Button>
<TooltipProvider>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<QuestionMarkCircleIcon className="h-5 w-5 cursor-default" />
<QuestionMarkCircleIcon className="ml-2 inline h-4 w-4 cursor-default text-slate-500" />
</TooltipTrigger>
<TooltipContent className="max-w-[200px]" side="top">
<TooltipContent className="max-w-[300px]" side="top">
With logic jumps you can skip questions based on the responses users give.
</TooltipContent>
</Tooltip>

View File

@@ -3,6 +3,8 @@ import { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
interface OpenQuestionFormProps {
localSurvey: Survey;
@@ -18,6 +20,10 @@ export default function MultipleChoiceMultiForm({
updateQuestion,
lastQuestion,
}: OpenQuestionFormProps): JSX.Element {
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
const questionRef = useRef<HTMLInputElement>(null);
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
@@ -31,11 +37,27 @@ export default function MultipleChoiceMultiForm({
};
const addChoice = () => {
const newChoices = !question.choices ? [] : question.choices;
setIsNew(false); // This question is no longer new.
let newChoices = !question.choices ? [] : question.choices;
const otherChoice = newChoices.find((choice) => choice.id === "other");
if (otherChoice) {
newChoices = newChoices.filter((choice) => choice.id !== "other");
}
newChoices.push({ id: createId(), label: "" });
if (otherChoice) {
newChoices.push(otherChoice);
}
updateQuestion(questionIdx, { choices: newChoices });
};
const addOther = () => {
if (question.choices.filter((c) => c.id === "other").length === 0) {
const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other");
newChoices.push({ id: "other", label: "Other" });
updateQuestion(questionIdx, { choices: newChoices });
}
};
const deleteChoice = (choiceIdx: number) => {
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
@@ -54,12 +76,26 @@ export default function MultipleChoiceMultiForm({
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
useEffect(() => {
if (lastChoiceRef.current) {
lastChoiceRef.current?.focus();
}
}, [question.choices?.length]);
// This effect will run once on initial render, setting focus to the question input.
useEffect(() => {
if (isNew && questionRef.current) {
questionRef.current.focus();
}
}, [isNew]);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
ref={questionRef}
id="headline"
name="headline"
value={question.headline}
@@ -87,23 +123,35 @@ export default function MultipleChoiceMultiForm({
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
placeholder={`Option ${choiceIdx + 1}`}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
/>
{question.choices && question.choices.length > 2 && (
<TrashIcon
className="ml-2 h-4 w-4 text-slate-400"
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => deleteChoice(choiceIdx)}
/>
)}
</div>
))}
<Button variant="secondary" type="button" onClick={() => addChoice()}>
Add Option
</Button>
<div className="flex items-center space-x-2">
<Button variant="secondary" size="sm" type="button" onClick={() => addChoice()}>
Add Option
</Button>
{question.choices.filter((c) => c.id === "other").length === 0 && (
<>
<p>or</p>
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot; with specify
</Button>
</>
)}
</div>
</div>
</div>

View File

@@ -3,6 +3,8 @@ import { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
interface OpenQuestionFormProps {
localSurvey: Survey;
@@ -18,7 +20,10 @@ export default function MultipleChoiceSingleForm({
updateQuestion,
lastQuestion,
}: OpenQuestionFormProps): JSX.Element {
// console.log(localSurvey);
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
const questionRef = useRef<HTMLInputElement>(null);
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
@@ -32,11 +37,27 @@ export default function MultipleChoiceSingleForm({
};
const addChoice = () => {
const newChoices = !question.choices ? [] : question.choices;
setIsNew(false); // This question is no longer new.
let newChoices = !question.choices ? [] : question.choices;
const otherChoice = newChoices.find((choice) => choice.id === "other");
if (otherChoice) {
newChoices = newChoices.filter((choice) => choice.id !== "other");
}
newChoices.push({ id: createId(), label: "" });
if (otherChoice) {
newChoices.push(otherChoice);
}
updateQuestion(questionIdx, { choices: newChoices });
};
const addOther = () => {
if (question.choices.filter((c) => c.id === "other").length === 0) {
const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other");
newChoices.push({ id: "other", label: "Other" });
updateQuestion(questionIdx, { choices: newChoices });
}
};
const deleteChoice = (choiceIdx: number) => {
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
@@ -55,12 +76,26 @@ export default function MultipleChoiceSingleForm({
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
};
useEffect(() => {
if (lastChoiceRef.current) {
lastChoiceRef.current?.focus();
}
}, [question.choices?.length]);
// This effect will run once on initial render, setting focus to the question input.
useEffect(() => {
if (isNew && questionRef.current) {
questionRef.current.focus();
}
}, [isNew]);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
ref={questionRef}
id="headline"
name="headline"
value={question.headline}
@@ -88,23 +123,35 @@ export default function MultipleChoiceSingleForm({
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
placeholder={`Option ${choiceIdx + 1}`}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
/>
{question.choices && question.choices.length > 2 && (
<TrashIcon
className="ml-2 h-4 w-4 text-slate-400"
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => deleteChoice(choiceIdx)}
/>
)}
</div>
))}
<Button variant="secondary" type="button" onClick={() => addChoice()}>
Add Option
</Button>
<div className="flex items-center space-x-2">
<Button variant="secondary" size="sm" type="button" onClick={() => addChoice()}>
Add Option
</Button>
{question.choices.filter((c) => c.id === "other").length === 0 && (
<>
<p>or</p>
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot; with specify
</Button>
</>
)}
</div>
</div>
</div>

View File

@@ -22,6 +22,7 @@ export default function NPSQuestionForm({
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -22,6 +22,7 @@ export default function OpenQuestionForm({
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -1,5 +1,6 @@
"use client";
import LogicEditor from "@/app/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor";
import { getQuestionTypeName } from "@/lib/questions";
import { cn } from "@formbricks/lib/cn";
import type { Question } from "@formbricks/types/questions";
@@ -26,7 +27,6 @@ import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionDropdown";
import RatingQuestionForm from "./RatingQuestionForm";
import UpdateQuestionId from "./UpdateQuestionId";
import LogicEditor from "@/app/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor";
interface QuestionCardProps {
localSurvey: Survey;
@@ -35,6 +35,7 @@ interface QuestionCardProps {
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
deleteQuestion: (questionIdx: number) => void;
duplicateQuestion: (questionIdx: number) => void;
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
lastQuestion: boolean;
@@ -46,6 +47,7 @@ export default function QuestionCard({
questionIdx,
moveQuestion,
updateQuestion,
duplicateQuestion,
deleteQuestion,
activeQuestionId,
setActiveQuestionId,
@@ -130,6 +132,7 @@ export default function QuestionCard({
<QuestionDropdown
questionIdx={questionIdx}
lastQuestion={lastQuestion}
duplicateQuestion={duplicateQuestion}
deleteQuestion={deleteQuestion}
moveQuestion={moveQuestion}
/>

View File

@@ -1,11 +1,18 @@
"use client";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@formbricks/ui";
import { EllipsisHorizontalIcon, ArrowUpIcon, ArrowDownIcon, TrashIcon } from "@heroicons/react/24/solid";
import {
EllipsisHorizontalIcon,
ArrowUpIcon,
ArrowDownIcon,
TrashIcon,
DocumentDuplicateIcon,
} from "@heroicons/react/24/solid";
interface QuestionDropdownProps {
questionIdx: number;
lastQuestion: boolean;
duplicateQuestion: (questionIdx: number) => void;
deleteQuestion: (questionIdx: number) => void;
moveQuestion: (questionIdx: number, up: boolean) => void;
}
@@ -13,6 +20,7 @@ interface QuestionDropdownProps {
export default function QuestionDropdown({
questionIdx,
lastQuestion,
duplicateQuestion,
deleteQuestion,
moveQuestion,
}: QuestionDropdownProps) {
@@ -22,14 +30,6 @@ export default function QuestionDropdown({
<EllipsisHorizontalIcon className="h-5 w-5 text-slate-600 focus:outline-none active:outline-none" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="justify-between"
onClick={(e) => {
e.stopPropagation();
deleteQuestion(questionIdx);
}}>
Delete <TrashIcon className="ml-3 h-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="justify-between"
onClick={(e) => {
@@ -49,6 +49,22 @@ export default function QuestionDropdown({
Move down
<ArrowDownIcon className="h-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="justify-between"
onClick={(e) => {
e.stopPropagation();
duplicateQuestion(questionIdx);
}}>
Duplicate <DocumentDuplicateIcon className="ml-3 h-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="justify-between"
onClick={(e) => {
e.stopPropagation();
deleteQuestion(questionIdx);
}}>
Delete <TrashIcon className="ml-3 h-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -1,13 +1,14 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { DragDropContext } from "react-beautiful-dnd";
import AddQuestionButton from "./AddQuestionButton";
import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import EditThankYouCard from "./EditThankYouCard";
import { createId } from "@paralleldrive/cuid2";
import { useMemo } from "react";
import { DragDropContext } from "react-beautiful-dnd";
import toast from "react-hot-toast";
import AddQuestionButton from "./AddQuestionButton";
import EditThankYouCard from "./EditThankYouCard";
import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
interface QuestionsViewProps {
localSurvey: Survey;
@@ -73,6 +74,28 @@ export default function QuestionsView({
setActiveQuestionId(localSurvey.questions[questionIdx - 1].id);
}
}
toast.success("Question deleted.");
};
const duplicateQuestion = (questionIdx: number) => {
const questionToDuplicate = JSON.parse(JSON.stringify(localSurvey.questions[questionIdx]));
const newQuestionId = createId();
// create a copy of the question with a new id
const duplicatedQuestion = {
...questionToDuplicate,
id: newQuestionId,
};
// insert the new question right after the original one
const updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.splice(questionIdx + 1, 0, duplicatedQuestion);
setLocalSurvey(updatedSurvey);
setActiveQuestionId(newQuestionId);
internalQuestionIdMap[newQuestionId] = createId();
toast.success("Question duplicated.");
};
const addQuestion = (question: any) => {
@@ -122,6 +145,7 @@ export default function QuestionsView({
questionIdx={questionIdx}
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}
duplicateQuestion={duplicateQuestion}
deleteQuestion={deleteQuestion}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}

View File

@@ -24,6 +24,7 @@ export default function RatingQuestionForm({
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
autoFocus
id="headline"
name="headline"
value={question.headline}

View File

@@ -73,6 +73,12 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
setLocalSurvey(updatedSurvey);
};
const handleTriggerDelay = (e: any) => {
let value = parseInt(e.target.value);
const updatedSurvey: Survey = { ...localSurvey, delay: value };
setLocalSurvey(updatedSurvey);
};
useEffect(() => {
if (localSurvey.type === "link") {
setOpen(false);
@@ -199,6 +205,30 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
Add condition
</Button>
</div>
{localSurvey.type !== "link" && (
<div className="ml-2 flex items-center space-x-1 px-4 pb-4">
<label
htmlFor="triggerDelay"
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="">
<p className="text-sm font-semibold text-slate-700">
Wait
<Input
type="number"
min="0"
id="triggerDelay"
value={localSurvey.delay.toString()}
onChange={(e) => handleTriggerDelay(e)}
className="ml-2 mr-2 inline w-16 text-center text-sm"
/>
seconds before showing the survey.
</p>
</div>
</label>
</div>
)}
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch id="autoClose" checked={autoClose} onCheckedChange={handleCheckMark} />
<Label htmlFor="autoClose" className="cursor-pointer">

View File

@@ -1,43 +1,93 @@
import { MultipleChoiceMultiQuestion, MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui";
import { PersonAvatar, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import Link from "next/link";
import { truncate } from "@/lib/utils";
interface MultipleChoiceSummaryProps {
questionSummary: QuestionSummary<MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion>;
environmentId: string;
surveyType: string;
}
interface ChoiceResult {
id: string;
label: string;
count: number;
percentage?: number;
otherValues?: {
value: string;
person: {
id: string;
name?: string;
email?: string;
};
}[];
}
export default function MultipleChoiceSummary({ questionSummary }: MultipleChoiceSummaryProps) {
export default function MultipleChoiceSummary({
questionSummary,
environmentId,
surveyType,
}: MultipleChoiceSummaryProps) {
const isSingleChoice = questionSummary.question.type === "multipleChoiceSingle";
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {
resultsDict[choice.label] = {
count: 0,
id: choice.id,
label: choice.label,
count: 0,
percentage: 0,
otherValues: [],
};
}
function findEmail(person) {
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
return emailAttribute ? emailAttribute.value : null;
}
const addOtherChoice = (response, value) => {
for (const key in resultsDict) {
if (resultsDict[key].id === "other" && value !== "") {
const email = response.person && findEmail(response.person);
const displayIdentifier = email || truncate(response.personId, 16);
resultsDict[key].otherValues?.push({
value,
person: {
id: response.personId,
email: displayIdentifier,
},
});
resultsDict[key].count += 1;
break;
}
}
};
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
if (isSingleChoice && response.value in resultsDict) {
resultsDict[response.value].count += 1;
} else if (isSingleChoice) {
// if single choice and not in choices, add to other
addOtherChoice(response, response.value);
} else {
// if multi choice add all responses
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
} else {
// if multi choice and not in choices, add to other
addOtherChoice(response, choice);
}
}
}
@@ -49,8 +99,15 @@ export default function MultipleChoiceSummary({ questionSummary }: MultipleChoic
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a: any, b: any) => b.count - a.count);
const results = Object.values(resultsDict).sort((a: any, b: any) => {
if (a.id === "other") return 1; // Always put a after b if a's id is 'other'
if (b.id === "other") return -1; // Always put b after a if b's id is 'other'
// If neither id is 'other', compare counts
return b.count - a.count;
});
return results;
}, [questionSummary, isSingleChoice]);
@@ -103,6 +160,45 @@ export default function MultipleChoiceSummary({ questionSummary }: MultipleChoic
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage} />
{result.otherValues.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6 ">Specified &quot;Other&quot; answers</div>
<div className="col-span-1 pl-6 ">{surveyType === "web" && "User"}</div>
</div>
{result.otherValues
.filter((otherValue) => otherValue !== "")
.map((otherValue, idx) => (
<div key={idx}>
{surveyType === "link" && (
<div
key={idx}
className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "web" && (
<Link
href={
otherValue.person.id
? `/environments/${environmentId}/people/${otherValue.person.id}`
: { pathname: null }
}
key={idx}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.person.id && <PersonAvatar personId={otherValue.person.id} />}
<span>{otherValue.person.email}</span>
</div>
</Link>
)}
</div>
))}
</div>
)}
</div>
))}
</div>

View File

@@ -90,6 +90,8 @@ export default function SummaryList({ environmentId, surveyId }) {
MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion
>
}
environmentId={environmentId}
surveyType={survey.type}
/>
);
}

View File

@@ -20,6 +20,7 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import LinkSurveyModal from "./LinkSurveyModal";
import { timeSinceConditionally } from "@formbricks/lib/time";
export default function SummaryMetadata({ surveyId, environmentId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
@@ -128,7 +129,9 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
</TooltipProvider>
</div>
<div className="flex flex-col justify-between lg:col-span-1">
<div className=""></div>
<div className="text-right text-xs text-slate-400">
Last updated: {timeSinceConditionally(survey.updatedAt)}
</div>
<div className="flex justify-end gap-x-1.5">
{survey.type === "link" && (
<Button
@@ -143,6 +146,7 @@ export default function SummaryMetadata({ surveyId, environmentId }) {
<SurveyStatusDropdown surveyId={surveyId} environmentId={environmentId} />
) : null}
<Button
variant="darkCTA"
className="h-full w-full px-3 lg:px-6"
href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
<PencilSquareIcon className="mr-2 h-5 w-5 text-white" />

View File

@@ -14,6 +14,8 @@ import { SparklesIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { customSurvey, templates } from "./templates";
import { SplitIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
type TemplateList = {
environmentId: string;
@@ -35,12 +37,6 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
const [categories, setCategories] = useState<Array<string>>([]);
/* useEffect(() => {
if (product && templates?.length) {
setActiveTemplate(customSurvey);
}
}, [product]); */
useEffect(() => {
const defaultCategories = [
/* ALL_CATEGORY_NAME, */
@@ -74,7 +70,7 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
return (
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-3 focus:outline-none">
<div className="mb-6 flex flex-wrap space-x-2">
<div className="mb-6 flex flex-wrap gap-2">
{categories.map((category) => (
<button
key={category}
@@ -103,7 +99,7 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
activeTemplate?.name === customSurvey.name
? "ring-brand border-transparent ring-2"
: "hover:border-brand-dark border-dashed border-slate-300",
"duration-120 group relative rounded-lg border-2 bg-transparent p-8 transition-colors duration-150"
"duration-120 group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-150"
)}>
<PlusCircleIcon className="text-brand-dark h-8 w-8 transition-all duration-150 group-hover:scale-110" />
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700 ">{customSurvey.name}</h3>
@@ -138,13 +134,41 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
}}
key={template.name}
className={cn(
activeTemplate?.name === template.name && "ring-brand ring-2",
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105"
activeTemplate?.name === template.name && "ring-2 ring-slate-400",
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105"
)}>
<div className="absolute right-6 top-6 rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500">
{template.category}
<div className="flex">
<div
className={`rounded border px-1.5 py-0.5 text-xs ${
template.category === "Product Experience"
? "border-blue-300 bg-blue-50 text-blue-500"
: template.category === "Exploration"
? "border-pink-300 bg-pink-50 text-pink-500"
: template.category === "Growth"
? "border-orange-300 bg-orange-50 text-orange-500"
: template.category === "Increase Revenue"
? "border-emerald-300 bg-emerald-50 text-emerald-500"
: template.category === "Customer Success"
? "border-violet-300 bg-violet-50 text-violet-500"
: "border-slate-300 bg-slate-50 text-slate-500" // default color
}`}>
{template.category}
</div>
{template.preset.questions.some(
(question) => question.logic && question.logic.length > 0
) && (
<TooltipProvider delayDuration={80}>
<Tooltip>
<TooltipTrigger>
<div>
<SplitIcon className="ml-1.5 h-5 w-5 rounded border border-slate-300 bg-slate-50 p-0.5 text-slate-400" />
</div>
</TooltipTrigger>
<TooltipContent>This survey uses branching logic.</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<template.icon className="h-8 w-8" />
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{template.name}</h3>
<p className="text-left text-xs text-slate-600">{template.description}</p>
{activeTemplate?.name === template.name && (

View File

@@ -1,11 +1,13 @@
"use client";
import { cn } from "@/../../packages/lib/cn";
import { Objective } from "@/../../packages/types/templates";
import Headline from "@/components/preview/Headline";
import Subheader from "@/components/preview/Subheader";
import { formbricksEnabled, updateResponse } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { ResponseId } from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { Objective } from "@formbricks/types/templates";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -13,6 +15,7 @@ import { toast } from "react-hot-toast";
type ObjectiveProps = {
next: () => void;
skip: () => void;
formbricksResponseId?: ResponseId;
};
type ObjectiveChoice = {
@@ -20,7 +23,7 @@ type ObjectiveChoice = {
id: Objective;
};
const Objective: React.FC<ObjectiveProps> = ({ next, skip }) => {
const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId }) => {
const objectives: Array<ObjectiveChoice> = [
{ label: "Increase conversion", id: "increase_conversion" },
{ label: "Improve user retention", id: "improve_user_retention" },
@@ -46,6 +49,22 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip }) => {
console.error(e);
toast.error("An error occured saving your settings");
}
if (
formbricksEnabled &&
process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID &&
formbricksResponseId
) {
const res = await updateResponse(
formbricksResponseId,
{
objective: selectedObjective.label,
},
true
);
if (!res.ok) {
console.error("Error updating response", res.error);
}
}
next();
}
}

View File

@@ -15,6 +15,7 @@ import Greeting from "./Greeting";
import Objective from "./Objective";
import Product from "./Product";
import Role from "./Role";
import { ResponseId } from "@formbricks/js";
const MAX_STEPS = 6;
@@ -26,6 +27,7 @@ export default function Onboarding({ session }: OnboardingProps) {
const { data: environment, error } = useSWR(`/api/v1/environments/find-first`, fetcher);
const { profile } = useProfile();
const { triggerProfileMutate } = useProfileMutation();
const [formbricksResponseId, setFormbricksResponseId] = useState<ResponseId | undefined>();
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
@@ -94,8 +96,12 @@ export default function Onboarding({ session }: OnboardingProps) {
</div>
<div className="flex grow items-center justify-center">
{currentStep === 1 && <Greeting next={next} skip={doLater} name={profile.name} session={session} />}
{currentStep === 2 && <Role next={next} skip={skipStep} />}
{currentStep === 3 && <Objective next={next} skip={skipStep} />}
{currentStep === 2 && (
<Role next={next} skip={skipStep} setFormbricksResponseId={setFormbricksResponseId} />
)}
{currentStep === 3 && (
<Objective next={next} skip={skipStep} formbricksResponseId={formbricksResponseId} />
)}
{currentStep === 4 && <Product done={done} environmentId={environment.id} isLoading={isLoading} />}
</div>
</div>

View File

@@ -3,15 +3,19 @@
import { cn } from "@/../../packages/lib/cn";
import Headline from "@/components/preview/Headline";
import Subheader from "@/components/preview/Subheader";
import { createResponse, formbricksEnabled } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { SurveyId } from "@formbricks/js";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { ResponseId } from "@formbricks/js";
type Role = {
type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: ResponseId) => void;
};
type RoleChoice = {
@@ -19,7 +23,7 @@ type RoleChoice = {
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
const Role: React.FC<Role> = ({ next, skip }) => {
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const { profile } = useProfile();
@@ -44,6 +48,20 @@ const Role: React.FC<Role> = ({ next, skip }) => {
toast.error("An error occured saving your settings");
console.error(e);
}
if (formbricksEnabled && process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
const res = await createResponse(
process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID as SurveyId,
{
role: selectedRole.label,
}
);
if (res.ok) {
const response = res.data;
setFormbricksResponseId(response.id);
} else {
console.error("Error sending response to Formbricks", res.error);
}
}
next();
}
}

View File

@@ -1,8 +1,14 @@
import { getServerSession } from "next-auth";
import Onboarding from "./Onboarding";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import FormbricksClient from "@/app/FormbricksClient";
export default async function OnboardingPage() {
const session = await getServerSession(authOptions);
return <Onboarding session={session} />;
return (
<>
<FormbricksClient session={session} />
<Onboarding session={session} />
</>
);
}

View File

@@ -72,13 +72,13 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
case "notEquals":
return answerValue !== logic.value;
case "lessThan":
return answerValue < logic.value;
return logic.value !== undefined && answerValue < logic.value;
case "lessEqual":
return answerValue <= logic.value;
return logic.value !== undefined && answerValue <= logic.value;
case "greaterThan":
return answerValue > logic.value;
return logic.value !== undefined && answerValue > logic.value;
case "greaterEqual":
return answerValue >= logic.value;
return logic.value !== undefined && answerValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
@@ -140,11 +140,9 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
const submitResponse = async (data: { [x: string]: any }) => {
setLoadingElement(true);
const questionIdx = survey.questions.findIndex((e) => e.id === currentQuestion?.id);
const nextQuestionId = getNextQuestionId(data);
console.log(nextQuestionId);
const finished = nextQuestionId === "end" || questionIdx === survey.questions.length - 1;
const finished = nextQuestionId === "end";
// build response
const responseRequest = {
surveyId: survey.id,

View File

@@ -4,7 +4,7 @@ export default function FormWrapper({ children }: { children: React.ReactNode })
return (
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
<div className="mx-auto w-full max-w-sm rounded-xl bg-white p-8 shadow-xl lg:w-96">
<div className="mb-6 text-center">
<div className="mb-8 text-center">
<Logo className="mx-auto w-3/4" />
</div>
{children}

View File

@@ -11,6 +11,7 @@ import { GithubButton } from "./GithubButton";
export const SigninForm = () => {
const searchParams = useSearchParams();
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e) => {
setLoggingIn(true);
@@ -38,7 +39,7 @@ export const SigninForm = () => {
return (
<>
<div className="text-center">
<p className="mb-8 text-lg text-slate-700">Log in to your account</p>
<h1 className="mb-4 text-slate-700">Log in to your account</h1>
<div className="space-y-2">
<form onSubmit={handleSubmit} ref={formRef} className="space-y-2" onChange={checkFormValidity}>
{showLogin && (
@@ -48,6 +49,7 @@ export const SigninForm = () => {
Email address
</label>
<input
ref={emailRef}
id="email"
name="email"
type="email"
@@ -90,6 +92,8 @@ export const SigninForm = () => {
if (!showLogin) {
setShowLogin(true);
setButtonEnabled(false);
// Add a slight delay before focusing the input field to ensure it's visible
setTimeout(() => emailRef.current?.focus(), 100);
} else if (formRef.current) {
formRef.current.requestSubmit();
}
@@ -98,7 +102,7 @@ export const SigninForm = () => {
className="w-full justify-center"
loading={loggingIn}
disabled={!isButtonEnabled}>
Continue with Email
Login with Email
</Button>
</form>
@@ -114,9 +118,11 @@ export const SigninForm = () => {
)}
</div>
{process.env.NEXT_PUBLIC_SIGNUP_DISABLED !== "1" && (
<div className="mt-3 text-center text-xs text-slate-600">
<Link href="/auth/signup" className="font-semibold underline">
Create new account
<div className="mt-9 text-center text-xs ">
<span className="leading-5 text-slate-500">New to Formbricks?</span>
<br />
<Link href="/auth/signup" className="font-semibold text-slate-600 underline hover:text-slate-700">
Create an account
</Link>
</div>
)}

View File

@@ -14,6 +14,7 @@ export const SignupForm = () => {
const router = useRouter();
const [error, setError] = useState<string>("");
const [signingUp, setSigningUp] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e: any) => {
setSigningUp(true);
@@ -67,7 +68,7 @@ export const SignupForm = () => {
</div>
)}
<div className="text-center">
<p className="mb-8 text-lg text-slate-700">Create your Formbricks account</p>
<h1 className="mb-4 text-slate-700">Create your Formbricks account</h1>
<div className="space-y-2">
<form onSubmit={handleSubmit} ref={formRef} className="space-y-2" onChange={checkFormValidity}>
{showLogin && (
@@ -78,6 +79,7 @@ export const SignupForm = () => {
</label>
<div className="mt-1">
<input
ref={nameRef}
id="name"
name="name"
type="text"
@@ -136,6 +138,8 @@ export const SignupForm = () => {
if (!showLogin) {
setShowLogin(true);
setButtonEnabled(false);
// Add a slight delay before focusing the input field to ensure it's visible
setTimeout(() => nameRef.current?.focus(), 100);
} else if (formRef.current) {
formRef.current.requestSubmit();
}
@@ -189,9 +193,10 @@ export const SignupForm = () => {
</div>
)}
<div className="mt-3 text-center text-xs text-slate-600">
Have an account?{" "}
<Link href="/auth/login" className="font-semibold underline">
<div className="mt-9 text-center text-xs ">
<span className="leading-5 text-slate-500">Have an account?</span>
<br />
<Link href="/auth/login" className="font-semibold text-slate-600 underline hover:text-slate-700">
Log in.
</Link>
</div>

View File

@@ -5,10 +5,10 @@ import CalComLogo from "@/images/cal-logo-light.svg";
export default function Testimonial() {
return (
<div className="flex flex-col items-center justify-center">
<div className="mb-10 w-3/4 space-y-8 2xl:w-1/2">
<div className="flex flex-col items-center justify-center bg-gradient-to-tr from-slate-100 to-slate-300">
<div className="3xl:w-2/3 mb-10 space-y-8 px-12 xl:px-20 ">
<div>
<h2 className="text-3xl font-bold text-slate-900">
<h2 className="text-3xl font-bold text-slate-800">
Versatile in-app surveys. Valuable user insights.
</h2>
</div>
@@ -19,19 +19,19 @@ export default function Testimonial() {
<div className="space-y-2">
<div className="flex space-x-2">
<CheckCircleIcon className="text-brand-dark h-6 w-6" />
<p className="inline text-lg text-slate-900">All features included</p>
<p className="inline text-lg text-slate-800">All features included</p>
</div>
<div className="flex space-x-2">
<CheckCircleIcon className="text-brand-dark h-6 w-6" />
<p className="inline text-lg text-slate-900">Free and open-source</p>
<p className="inline text-lg text-slate-800">Free and open-source</p>
</div>
<div className="flex space-x-2">
<CheckCircleIcon className="text-brand-dark h-6 w-6" />
<p className="inline text-lg text-slate-900">No credit card required</p>
<p className="inline text-lg text-slate-800">No credit card required</p>
</div>
</div>
<div className="border-1 rounded-xl border-slate-200 bg-slate-50 p-6 shadow-sm">
<div className="rounded-xl border border-slate-200 bg-gradient-to-tr from-slate-100 to-slate-200 p-8">
<p className="italic text-slate-700">
We measure the clarity of our docs and learn from churn all on one platform. Great product, very
responsive team!

View File

@@ -1,16 +1,30 @@
import { cn } from "@formbricks/lib/cn";
import SurveyNavBarName from "@/components/shared/SurveyNavBarName";
import Link from "next/link";
interface SecondNavbarProps {
tabs: { id: string; label: string; href: string; icon?: React.ReactNode }[];
activeId: string;
surveyId?: string;
environmentId?: string;
}
export default function SecondNavbar({ tabs, activeId, ...props }: SecondNavbarProps) {
export default function SecondNavbar({
tabs,
activeId,
surveyId,
environmentId,
...props
}: SecondNavbarProps) {
return (
<div {...props}>
<div className="flex h-14 w-full items-center justify-center border-b bg-white">
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
<div className="grid h-14 w-full grid-cols-3 items-center justify-items-stretch border-b bg-white px-4">
<div className="justify-self-start">
{surveyId && environmentId && (
<SurveyNavBarName surveyId={surveyId} environmentId={environmentId} />
)}
</div>{" "}
<nav className="flex h-full items-center space-x-4 justify-self-center" aria-label="Tabs">
{tabs.map((tab) => (
<Link
key={tab.id}
@@ -27,6 +41,7 @@ export default function SecondNavbar({ tabs, activeId, ...props }: SecondNavbarP
</Link>
))}
</nav>
<div className="justify-self-end"></div>
</div>
</div>
);

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from "react";
import { Input } from "@/../../packages/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { cn } from "@formbricks/lib/cn";
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import { useEffect, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
interface MultipleChoiceMultiProps {
question: MultipleChoiceMultiQuestion;
@@ -20,16 +21,22 @@ export default function MultipleChoiceMultiQuestion({
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [isAtLeastOneChecked, setIsAtLeastOneChecked] = useState(false);
const [showOther, setShowOther] = useState(false);
const [otherSpecified, setOtherSpecified] = useState("");
useEffect(() => {
setIsAtLeastOneChecked(selectedChoices.length > 0);
}, [selectedChoices]);
setIsAtLeastOneChecked(selectedChoices.length > 0 || otherSpecified.length > 0);
}, [selectedChoices, otherSpecified]);
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
@@ -37,8 +44,12 @@ export default function MultipleChoiceMultiQuestion({
const data = {
[question.id]: selectedChoices,
};
onSubmit(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -48,39 +59,63 @@ export default function MultipleChoiceMultiQuestion({
<div className="relative space-y-2 rounded-md bg-white">
{question.choices &&
question.choices.map((choice) => (
<label
key={choice.id}
className={cn(
selectedChoices.includes(choice.label)
? "z-10 border-slate-400 bg-slate-50"
: "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-50 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
type="checkbox"
id={choice.id}
name={question.id}
value={choice.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
checked={selectedChoices.includes(choice.label)}
onChange={(e) => {
if (e.currentTarget.checked) {
setSelectedChoices([...selectedChoices, e.currentTarget.value]);
} else {
setSelectedChoices(
selectedChoices.filter((label) => label !== e.currentTarget.value)
);
}
}}
style={{ borderColor: brandColor, color: brandColor }}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
<>
<label
key={choice.id}
className={cn(
selectedChoices.includes(choice.label) || (choice.id === "other" && showOther)
? "z-10 border-slate-400 bg-slate-50"
: "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-50 focus:outline-none"
)}>
<span className="flex flex-col text-sm">
<span className="flex items-center">
<input
type="checkbox"
id={choice.id}
name={question.id}
value={choice.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
checked={
selectedChoices.includes(choice.label) || (choice.id === "other" && showOther)
}
onChange={(e) => {
if (choice.id === "other") {
setShowOther(e.currentTarget.checked);
return;
}
if (e.currentTarget.checked) {
setSelectedChoices([...selectedChoices, e.currentTarget.value]);
} else {
setSelectedChoices(
selectedChoices.filter((label) => label !== e.currentTarget.value)
);
}
}}
style={{ borderColor: brandColor, color: brandColor }}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
</span>
</span>
{choice.id === "other" && showOther && (
<Input
id={`${choice.id}-label`}
name={question.id}
className="mt-2 bg-white focus:border-slate-300"
placeholder="Please specify"
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}
required={question.required}
autoFocus
/>
)}
</span>
</span>
</label>
</label>
</>
))}
</div>
</fieldset>

View File

@@ -1,9 +1,10 @@
import { Input } from "@/../../packages/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { cn } from "@formbricks/lib/cn";
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
interface MultipleChoiceSingleProps {
question: MultipleChoiceSingleQuestion;
@@ -19,14 +20,15 @@ export default function MultipleChoiceSingleQuestion({
brandColor,
}: MultipleChoiceSingleProps) {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = e.currentTarget[question.id].value;
const data = {
[question.id]: e.currentTarget[question.id].value,
[question.id]: value,
};
onSubmit(data);
setSelectedChoice(null); // reset form
}}>
@@ -52,10 +54,8 @@ export default function MultipleChoiceSingleQuestion({
value={choice.label}
className="h-4 w-4 border border-gray-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
checked={selectedChoice === choice.label}
onChange={() => setSelectedChoice(choice.id)}
checked={selectedChoice === choice.id}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
@@ -63,6 +63,17 @@ export default function MultipleChoiceSingleQuestion({
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "other" && (
<Input
id={`${choice.id}-label`}
name={question.id}
placeholder="Please specify"
className="mt-3 bg-white focus:border-slate-300"
required={question.required}
aria-labelledby={`${choice.id}-label`}
autoFocus
/>
)}
</label>
))}
</div>

View File

@@ -34,6 +34,7 @@ export default function OpenTextQuestion({
<Subheader subheader={question.subheader} questionId={question.id} />
<div className="mt-4">
<textarea
autoFocus
rows={3}
name={question.id}
id={question.id}

View File

@@ -1,9 +1,20 @@
export default function Progress({ progress, brandColor }: { progress: number; brandColor: string }) {
import React from "react";
const ProgressComponent = ({ progress, brandColor }) => {
return (
<div className="h-1 w-full rounded-full bg-slate-200">
<div
className="h-1 rounded-full bg-slate-700"
className="transition-width h-1 rounded-full duration-500"
style={{ backgroundColor: brandColor, width: `${Math.floor(progress * 100)}%` }}></div>
</div>
);
}
};
ProgressComponent.displayName = "Progress";
const Progress = React.memo(ProgressComponent, (prevProps, nextProps) => {
// Only re-render if progress or brandColor changes
return prevProps.progress === nextProps.progress && prevProps.brandColor === nextProps.brandColor;
});
export default Progress;

View File

@@ -51,7 +51,7 @@ export default function RatingQuestion({
type="radio"
name="rating"
value={number}
className="absolute h-full w-full cursor-pointer opacity-0"
className="absolute left-0 h-full w-full cursor-pointer opacity-0"
onChange={() => handleSelect(number)}
required={question.required}
/>

View File

@@ -0,0 +1,37 @@
"use client";
import { useProduct } from "@/lib/products/products";
import { useSurvey } from "@/lib/surveys/surveys";
interface SurveyNavBarNameProps {
surveyId: string;
environmentId: string;
}
export default function SurveyNavBarName({ surveyId, environmentId }: SurveyNavBarNameProps) {
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
if (isLoadingSurvey || isLoadingProduct) {
return null;
}
if (isErrorProduct || isErrorSurvey) {
return null;
}
return (
<div className="hidden items-center space-x-2 whitespace-nowrap md:flex">
{/* <Button
variant="secondary"
StartIcon={ArrowLeftIcon}
onClick={() => {
router.back();
}}>
Back
</Button> */}
<p className="pl-4 font-semibold">{product.name} / </p>
<span>{survey.name}</span>
</div>
);
}

View File

@@ -108,6 +108,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
},
thankYouCard: true,
autoClose: true,
delay: true,
},
});
@@ -182,6 +183,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
triggers: survey.triggers,
thankYouCard: JSON.parse(JSON.stringify(survey.thankYouCard)),
autoClose: survey.autoClose,
delay: survey.delay,
};
});

View File

@@ -125,7 +125,9 @@ export const sendResponseFinishedEmail = async (
const personEmail = person?.attributes?.find((a) => a.attributeClass?.name === "email")?.value;
await sendEmail({
to: email,
subject: `A response for ${survey.name} was completed ✅`,
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail || process.env.MAIL_FROM,
html: withEmailTemplate(`<h1>Survey completed</h1>Someone just completed your survey "${survey.name}"<br/>
@@ -134,8 +136,6 @@ export const sendResponseFinishedEmail = async (
${getQuestionResponseMapping(survey, response)
.map((question) => `<p><strong>${question.question}</strong></p><p>${question.answer}</p>`)
.join("")}
<hr/>

View File

@@ -0,0 +1,36 @@
import formbricks, { PersonId, SurveyId, ResponseId } from "@formbricks/js";
export const formbricksEnabled =
typeof process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST && process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
export const createResponse = async (
surveyId: SurveyId,
data: { [questionId: string]: any },
finished: boolean = false
) => {
const api = formbricks.getApi();
const personId = formbricks.getPerson()?.id as PersonId;
return await api.createResponse({
surveyId,
personId,
finished,
data,
});
};
export const updateResponse = async (
responseId: ResponseId,
data: { [questionId: string]: any },
finished: boolean = false
) => {
const api = formbricks.getApi();
return await api.updateResponse({
responseId,
finished,
data,
});
};
export const formbricksLogout = async () => {
return await formbricks.logout();
};

View File

@@ -24,6 +24,8 @@ export const questionTypes: QuestionType[] = [
description: "A single line of text",
icon: ChatBubbleBottomCenterTextIcon,
preset: {
headline: "Who let the dogs out?",
subheader: "Who? Who? Who?",
placeholder: "Type your answer here...",
},
},

View File

@@ -59,7 +59,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
});
// if person exists, reconnect ression and delete old user
// if person exists, reconnect session and delete old user
if (existingPerson) {
// reconnect session to new person
await prisma.session.update({

View File

@@ -60,13 +60,11 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
}
// if survey exists, return survey
return res
.status(200)
.json({
...survey,
brandColor: product?.brandColor,
formbricksSignature: product?.formbricksSignature,
});
return res.status(200).json({
...survey,
brandColor: product?.brandColor,
formbricksSignature: product?.formbricksSignature,
});
}
// Unknown HTTP Method

View File

@@ -12,6 +12,8 @@
"scripts": {
"clean": "turbo run clean && rimraf node_modules",
"build": "turbo run build",
"prebuild": "turbo run prebuild",
"db:migrate:dev": "turbo run db:migrate:dev",
"db:migrate:deploy": "turbo run db:migrate:deploy",
"db:migrate:vercel": "turbo run db:migrate:vercel",
"db:push": "turbo run db:push",

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["formbricks"],
};

55
packages/api/README.md Normal file
View File

@@ -0,0 +1,55 @@
# @formbricks/api - API Wrapper for Formbricks
## Installation
```bash
npm install @formbricks/api
```
## Usage
<details>
<summary>Create API Client</summary>
```ts
import { FormbricksAPI, EnvironmentId } from "@formbricks/api";
const api = new FormbricksAPI({
apiHost: "http://localhost:3000",
environmentId: "clgwh8maj0005n2f66pwzev3r" as EnvironmentId,
});
```
</details>
> **Note**
> All of the following methods return a `Result` from the `@formbricks/errors` package.
<details>
<summary>Create a new response</summary>
```ts
const response = await api.createResponse({
surveyId: "......" as SurveyId,
personId: "......" as PersonId,
data: {
questionId: "response",
},
});
```
</details>
<details>
<summary>Update an existing response</summary>
```ts
const response = await api.updateResponse({
responseId: "......" as ResponseId, // If you pass response.value.id from createResponse, you dont need 'as ResponseId'
data: {
questionId: "response",
},
});
```
</details>

30
packages/api/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@formbricks/api",
"version": "0.1.0",
"description": "A Typescript API-wrapper for managing the Formbricks Client API.",
"license": "MIT",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"files": [
"dist/**"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint ./src --fix",
"clean": "rimraf .turbo node_modules dist"
},
"dependencies": {
"@formbricks/errors": "workspace:*"
},
"devDependencies": {
"@formbricks/types": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"eslint": "^8.41.0",
"eslint-config-formbricks": "workspace:*",
"rimraf": "^5.0.1",
"tsup": "^6.7.0"
}
}

View File

@@ -0,0 +1 @@
export * from "./responses";

View File

@@ -0,0 +1,24 @@
import { KeyValueData, PersonId, ResponseId, SurveyId } from "../types";
export interface CreateResponseResponse {
id: ResponseId;
}
export interface UpdateResponseResponse {
id: ResponseId;
createdAt: string;
updatedAt: string;
finished: boolean;
surveyId: SurveyId;
personId: PersonId;
data: KeyValueData;
meta: {}; //TODO: figure out what this is
userAttributes: string[]; //TODO: figure out what this is
tags: string[]; //TODO: figure out what this is
}
export interface UpdateResponseResponseFormatted
extends Omit<UpdateResponseResponse, "createdAt" | "updatedAt"> {
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,70 @@
import { Result, ok } from "@formbricks/errors";
import { ResponseCreateRequest, ResponseUpdateRequest } from "@formbricks/types/js";
import {
CreateResponseResponse,
UpdateResponseResponse,
UpdateResponseResponseFormatted,
} from "../dtos/responses";
import { NetworkError } from "../errors";
import { EnvironmentId, KeyValueData, PersonId, RequestFn, ResponseId, SurveyId } from "../types";
export interface CreateResponseOptions {
environmentId: EnvironmentId;
surveyId: SurveyId;
personId?: PersonId;
data: KeyValueData;
finished?: boolean;
}
export const createResponse = async (
request: RequestFn,
options: CreateResponseOptions
): Promise<Result<CreateResponseResponse, NetworkError>> => {
const result = await request<CreateResponseResponse, any, ResponseCreateRequest>(
`/api/v1/client/environments/${options.environmentId}/responses`,
{
surveyId: options.surveyId,
personId: options.personId,
response: {
data: options.data,
finished: options.finished || false,
},
},
{ method: "POST" }
);
return result;
};
export interface UpdateResponseOptions {
environmentId: EnvironmentId;
data: KeyValueData;
responseId: ResponseId;
finished?: boolean;
}
export const updateResponse = async (request: RequestFn, options: UpdateResponseOptions) => {
const result = await request<UpdateResponseResponse, any, ResponseUpdateRequest>(
`/api/v1/client/environments/${options.environmentId}/responses/${options.responseId}`,
{
response: {
data: options.data,
finished: options.finished || false,
},
},
{
method: "PUT",
}
);
if (result.ok === false) return result;
// convert timestamps to Dates
const newResponse: UpdateResponseResponseFormatted = {
...result.data,
createdAt: new Date(result.data.createdAt),
updatedAt: new Date(result.data.updatedAt),
};
return ok(newResponse);
};

View File

@@ -0,0 +1,6 @@
export type NetworkError = {
code: "network_error";
message: string;
status: number;
url: URL;
};

View File

@@ -0,0 +1,6 @@
export * from "./dtos/";
export * from "./errors";
export * from "./lib";
export { FormbricksAPI as default } from "./lib";
// do not export RequestFn or Brand, they are internal
export type { EnvironmentId, KeyValueData, PersonId, ResponseId, SurveyId } from "./types";

85
packages/api/src/lib.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Result, err, ok, wrapThrows } from "@formbricks/errors";
import { CreateResponseResponse, UpdateResponseResponseFormatted } from "./dtos/responses";
import { NetworkError } from "./errors";
import {
CreateResponseOptions,
UpdateResponseOptions,
createResponse,
updateResponse,
} from "./endpoints/response";
import { EnvironmentId, RequestFn } from "./types";
export interface FormbricksAPIOptions {
apiHost?: string;
environmentId: EnvironmentId;
}
export class FormbricksAPI {
private readonly baseUrl: string;
private readonly environmentId: EnvironmentId;
constructor(options: FormbricksAPIOptions) {
this.baseUrl = options.apiHost || "https://app.formbricks.com";
this.environmentId = options.environmentId;
this.request = this.request.bind(this);
}
async createResponse(
options: Omit<CreateResponseOptions, "environmentId">
): Promise<Result<CreateResponseResponse, NetworkError>> {
return this.runWithEnvironmentId(createResponse, options);
}
async updateResponse(
options: Omit<UpdateResponseOptions, "environmentId">
): Promise<Result<UpdateResponseResponseFormatted, NetworkError>> {
return this.runWithEnvironmentId(updateResponse, options);
}
/*
This was added to reduce code duplication
It checks that the function passed has the environmentId in the Options type
and automatically adds it to the options
*/
private runWithEnvironmentId<T, E, Options extends { environmentId: EnvironmentId }>(
fn: (request: RequestFn, options: Options) => Promise<Result<T, E>>,
options: Omit<Options, "environmentId">
): Promise<Result<T, E>> {
const newOptions = { environmentId: this.environmentId, ...options } as Options;
return fn(this.request, newOptions);
}
private async request<T = any, E = any, Data = any>(
path: string,
data: Data,
options?: RequestInit
): Promise<Result<T, E | NetworkError | Error>> {
const url = new URL(path, this.baseUrl);
const headers: HeadersInit = {
"Content-Type": "application/json",
};
const body = JSON.stringify(data);
const res = wrapThrows(fetch)(url, { headers, body, ...options });
if (res.ok === false) return err(res.error);
const response = await res.data;
const resJson = await response.json();
if (!response.ok) {
return err({
code: "network_error",
message: response.statusText,
status: response.status,
url,
});
}
return ok(resJson as T);
}
}

27
packages/api/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
import { Result } from "@formbricks/errors";
import { NetworkError } from "./errors";
// by using Brand, we can check that you can't pass to an environmentId a surveyId
type Brand<T, B> = T & { __brand: B };
export type EnvironmentId = Brand<string, "EnvironmentId">;
export type SurveyId = Brand<string, "SurveyId">;
export type PersonId = Brand<string, "PersonId">;
export type ResponseId = Brand<string, "ResponseId">;
export type KeyValueData = { [key: string]: string | number | string[] | number[] | undefined };
export type RequestFn = <T = any, E = any, Data = any>(
path: string,
data: Data,
options?: RequestInit
) => Promise<Result<T, E | NetworkError | Error>>;
// https://github.com/formbricks/formbricks/blob/fbfc80dd4ed5d768f0c549e179fd1aa10edc400a/apps/web/lib/api/response.ts
export interface ApiErrorResponse {
code: string;
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
}
}

View File

@@ -0,0 +1,5 @@
{
"extends": "@formbricks/tsconfig/js-library.json",
"include": ["**/*.ts", "tsup.config.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from "tsup";
const isProduction = process.env.NODE_ENV === "production";
export default defineConfig({
clean: true,
dts: true,
splitting: true,
format: ["cjs", "esm"],
entry: ["src/index.ts"],
minify: isProduction,
sourcemap: true,
});

View File

@@ -1,8 +1,7 @@
{
"name": "@formbricks/database",
"private": true,
"version": "1.0.0",
"license": "MIT",
"version": "0.1.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -14,7 +13,7 @@
"clean": "rimraf .turbo node_modules dist",
"db:migrate:deploy": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:migrate:vercel": "DATABASE_URL=\"$MIGRATE_DATABASE_URL\" prisma migrate deploy",
"db:migrate:vercel": "if test \"$NEXT_PUBLIC_VERCEL_ENV\" = \"preview\" ; then env DATABASE_URL=\"$MIGRATE_DATABASE_URL\" prisma db push --accept-data-loss ; else env DATABASE_URL=\"$MIGRATE_DATABASE_URL\" prisma migrate deploy ; fi",
"db:push": "prisma db push --accept-data-loss",
"dev": "tsup --watch",
"format": "prisma format",
@@ -25,13 +24,15 @@
"studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^4.14.1"
"@formbricks/types": "workspace:*",
"@prisma/client": "^4.15.0",
"prisma-json-types-generator": "^2.4.0"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"eslint": "^8.41.0",
"eslint-config-formbricks": "workspace:*",
"prisma": "^4.14.1",
"prisma": "^4.15.0",
"prisma-dbml-generator": "^0.10.0",
"rimraf": "^5.0.1",
"tsup": "^6.7.0",

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `userAttributes` on the `Response` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Response" DROP COLUMN "userAttributes";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "delay" INTEGER NOT NULL DEFAULT 0;

View File

@@ -12,6 +12,16 @@ generator client {
//provider = "prisma-dbml-generator"
}
generator json {
provider = "prisma-json-types-generator"
// namespace = "PrismaJson"
// clientOutput = "<finds it automatically>"
// (./ -> relative to schema, or an importable path to require() it)
// useType = "MyType"
// In case you need to use a type, export it inside the namespace and we will add a index signature to it
// (e.g. export namespace PrismaJson { export type MyType = {a: 1, b: 2} }; will generate namespace.MyType["TYPE HERE"])
}
enum PipelineTriggers {
responseCreated
responseUpdated
@@ -75,17 +85,18 @@ model Person {
}
model Response {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String?
data Json @default("{}")
meta Json @default("{}")
userAttributes Json @default("[]")
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String?
/// [ResponseData]
data Json @default("{}")
/// [ResponseMeta]
meta Json @default("{}")
}
enum SurveyStatus {
@@ -165,7 +176,9 @@ model Survey {
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
status SurveyStatus @default(draft)
/// [SurveyQuestions]
questions Json @default("[]")
/// [SurveyThankYouCard]
thankYouCard Json @default("{\"enabled\": false}")
responses Response[]
displayOption displayOptions @default(displayOnce)
@@ -173,7 +186,8 @@ model Survey {
triggers SurveyTrigger[]
attributeFilters SurveyAttributeFilter[]
displays Display[]
autoClose Int?
autoClose Int?
delay Int @default(0)
}
model Event {
@@ -183,6 +197,7 @@ model Event {
eventClassId String?
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
sessionId String
/// [EventProperties]
properties Json @default("{}")
}
@@ -200,6 +215,7 @@ model EventClass {
description String?
type EventType
events Event[]
/// [EventClassNoCodeConfig]
noCodeConfig Json?
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
@@ -384,5 +400,6 @@ model User {
invitesAccepted Invite[] @relation("inviteAcceptedBy")
role Role?
objective Objective?
/// [UserNotificationSettings]
notificationSettings Json @default("{}")
}

View File

@@ -1,4 +1,5 @@
import { PrismaClient } from "@prisma/client";
import "../types/jsonTypes";
declare global {
var prisma: PrismaClient | undefined;

View File

@@ -0,0 +1,16 @@
import { NoCodeConfig } from "@formbricks/types/events";
import { Question } from "@formbricks/types/questions";
import { ThankYouCard } from "@formbricks/types/surveys";
import { NotificationSettings } from "@formbricks/types/users";
declare global {
namespace PrismaJson {
export type EventProperties = { [key: string]: string };
export type EventClassNoCodeConfig = NoCodeConfig;
export type ResponseData = { [questionId: string]: string };
export type ResponseMeta = { [key: string]: string };
export type SurveyQuestions = Question[];
export type SurveyThankYouCard = ThankYouCard;
export type UserNotificationSettings = NotificationSettings;
}
}

View File

@@ -12,13 +12,13 @@
"devDependencies": {
"@formbricks/tsconfig": "*",
"@formbricks/types": "*",
"eslint": "^8.27.0",
"eslint": "^8.41.0",
"eslint-config-formbricks": "workspace:*",
"rimraf": "^5.0.0",
"typescript": "^4.9.4"
"rimraf": "^5.0.1",
"typescript": "^5.0.4"
},
"dependencies": {
"@formbricks/database": "workspace:*",
"next": "^13.2.4"
"next": "^13.4.4"
}
}

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["formbricks"],
};

57
packages/errors/README.md Normal file
View File

@@ -0,0 +1,57 @@
# @formbricks/errors
> Error handling for @formbricks packages
## Installation
```bash
npm install @formbricks/errors
```
## Usage
```ts
import { Result, ok, err, okVoid } from "@formbricks/errors";
type CustomError = {
code: "custom_error";
message: string;
};
type AnotherCustomError = {
code: "another_custom_error";
message: string;
anotherField: number;
};
type SuccessType = {
id: string;
};
const test = (): Result<SuccessType, CustomError | AnotherCustomError> => {
/* There are 4 ways to return a Result from this function */
// return ok({ id: '123' })
// return err({ code: 'custom_error', message: 'Custom error message' })
// return err({ code: 'another_custom_error', message: 'Another custom error message', anotherField: 123 })
/*
If SuccessType is void
*/
// return okVoid()
};
const result = test();
if (result.ok === true) {
console.log(result.value.id); // you have full type safety here
} else if (result.error.code === "custom_error") {
console.log(result.error.message); // you have full type safety here
} else if (result.error.code === "another_custom_error") {
console.log(result.error.anotherField); // you have full type safety here
console.log(result.error.message);
}
```
## Inspiration
- [Rust Result](https://doc.rust-lang.org/std/result/enum.Result.html)
- [true-myth](https://github.com/true-myth/true-myth)

View File

@@ -0,0 +1,26 @@
{
"name": "@formbricks/errors",
"description": "A helper package containing general error classes for Formbricks",
"private": true,
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"files": [
"dist/**"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint ./src --fix",
"clean": "rimraf .turbo node_modules dist"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"eslint": "^8.41.0",
"eslint-config-formbricks": "workspace:*",
"rimraf": "^5.0.1",
"tsup": "^6.7.0"
}
}

View File

@@ -0,0 +1,116 @@
import { Result } from "./result";
/**
* Applies the given function `fn` to the data property of the input `result` object
* and returns a new `Result` object with the transformed data property.
*
* @template T The type of the input data.
* @template R The type of the output data.
*
* @param {function(value: T): R} fn The function to apply to the data property of the input `result` object.
* @returns {function(result: Result<T>): Result<R>} A new function that takes in a `Result<T>` object and returns a new `Result<R>` object.
*
* @example
* const divideByTwo = (num: number): Result<number> => {
* if (num === 0) {
* return { ok: false, error: "Cannot divide zero" };
* }
*
* return { ok: true, data: num / 2 };
* }
*
* const wrappedDivideByTwo = wrap(divideByTwo);
*
* const result1: Result<number> = { ok: true, data: 10 };
* const result2: Result<number> = { ok: false, error: "Invalid input" };
* const result3: Result<number> = { ok: true, data: 0 };
*
* console.log(wrappedDivideByTwo(result1)); // { ok: true, data: 5 }
* console.log(wrappedDivideByTwo(result2)); // { ok: false, error: "Invalid input" }
* console.log(wrappedDivideByTwo(result3)); // { ok: false, error: "Cannot divide zero" }
*/
export const wrap =
<T, R>(fn: (value: T) => R) =>
(result: Result<T>): Result<R> =>
result.ok === true ? { ok: true, data: fn(result.data) } : result;
/**
* Matches the given `result` object against its `ok` property and invokes the `onSuccess` function
* if `ok` is `true`, or the `onError` function if `ok` is `false`. Returns the result of the invoked function. Match a Result object and run a function depending on the result.
*
* @template TSuccess - Type of the success value
* @template TError - Type of the error value
* @template TReturn - Type of the return value
*
* @param {Result<TSuccess, TError>} result The `Result` object to match against.
* @param {(value: TSuccess) => TReturn} onSuccess The function to invoke if `result.ok` is `true`.
* @param {(error: TError) => TReturn} onError The function to invoke if `result.ok` is `false`.
*
* @returns {TReturn} The result of the invoked function.
*
* @example
* const test = (): Result<string, Error> => {
* return err(new Error("error happened"));
* }
*
* const result = test();
*
* match(result, (value) => {
* console.log(value); // never run with this example
* }, (error) => {
* console.log(error); // Error: error happened
* });
*/
export function match<TSuccess, TError, TReturn>(
result: Result<TSuccess, TError>,
onSuccess: (value: TSuccess) => TReturn,
onError: (error: TError) => TReturn
): TReturn {
if (result.ok === true) {
return onSuccess(result.data);
}
return onError(result.error);
}
/**
* Wraps a function `fn` that may throw an error into a new function that returns a `Result` object.
* If the wrapped function throws an error, the returned `Result` object will have an `ok` property of `false`
* and an `error` property containing the thrown error. Otherwise, the returned `Result` object will have an
* `ok` property of `true` and a `data` property containing the result of the wrapped function.
*
* @template T The type of the result value.
* @template A An array of the types of the arguments expected by the wrapped function.
*
* @param {(...args: A) => T} fn The function to wrap.
* @returns {(...args: A) => Result<T>} A new function that returns a `Result` object.
*
* @example
* function divideByTwo(num: number): number {
* if (num === 0) {
* throw new Error("Cannot divide zero");
* }
* return num / 2;
* }
*
* const wrappedDivideByTwo = wrapThrows(divideByTwo);
*
* const result1: Result<number> = wrappedDivideByTwo(10); // { ok: true, data: 5 }
* const result2: Result<number> = wrappedDivideByTwo(0); // { ok: false, error: Error("Cannot divide zero") }
*/
export const wrapThrows =
<T, A extends any[]>(fn: (...args: A) => T): ((...args: A) => Result<T>) =>
(...args: A): Result<T> => {
try {
return {
ok: true,
data: fn(...args),
};
} catch (error: any) {
return {
ok: false,
error,
};
}
};

View File

@@ -0,0 +1,2 @@
export * from "./functions";
export * from "./result";

View File

@@ -0,0 +1,10 @@
export type Result<T, E = Error> = { ok: true; data: T } | { ok: false; error: E };
export const ok = <T, E>(data: T): Result<T, E> => ({ ok: true, data });
export const okVoid = <E>(): Result<void, E> => ({ ok: true, data: undefined });
export const err = <E = Error>(error: E): Result<never, E> => ({
ok: false,
error,
});

View File

@@ -0,0 +1,5 @@
{
"extends": "@formbricks/tsconfig/node16.json",
"include": ["**/*.ts", "tsup.config.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from "tsup";
const isProduction = process.env.NODE_ENV === "production";
export default defineConfig({
clean: true,
dts: true,
splitting: true,
format: ["cjs", "esm"],
entry: ["src/index.ts"],
minify: isProduction,
sourcemap: true,
});

View File

@@ -8,13 +8,13 @@
"clean": "rimraf node_modules .turbo"
},
"dependencies": {
"eslint": "^8.37.0",
"eslint-config-next": "^13.2.4",
"eslint": "^8.41.0",
"eslint-config-next": "^13.4.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "7.32.2",
"eslint-config-turbo": "latest"
},
"devDependencies": {
"rimraf": "^5.0.0"
"rimraf": "^5.0.1"
}
}

View File

@@ -13,7 +13,7 @@
setTimeout(function () {
window.formbricks = window.js;
window.formbricks.init({
environmentId: "clham12520003yz4zeng786vq",
environmentId: "clhkhwyc60003yz5rpgsgrebq",
apiHost: "http://localhost:3000",
logLevel: "debug",
});

View File

@@ -1,6 +1,7 @@
{
"name": "@formbricks/js",
"version": "0.1.19",
"license": "MIT",
"version": "0.1.20",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",
@@ -22,10 +23,7 @@
},
"scripts": {
"clean": "rimraf .turbo node_modules dist",
"dev": "microbundle watch --css inline",
"start": "cross-env NODE_OPTIONS=--openssl-legacy-provider preact watch",
"build:widget": "microbundle --css inline",
"build:lib": "microbundle build -i src/component.tsx",
"dev": "microbundle --css inline",
"lint": "eslint '{src,tests}/**/*.{ts,tsx}'",
"test": "jest",
"build": "microbundle --css inline"
@@ -44,30 +42,31 @@
]
},
"author": "Formbricks <hola@formbricks.com>",
"license": "MIT",
"dependencies": {},
"devDependencies": {
"@formbricks/api": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/enzyme": "^3.10.13",
"@types/jest": "^29.5.1",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"@types/jest": "^29.5.2",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"enzyme": "^3.11.0",
"enzyme-adapter-preact-pure": "^4.1.0",
"eslint": "^8.40.0",
"eslint": "^8.42.0",
"eslint-config-formbricks": "workspace:*",
"eslint-config-preact": "^1.3.0",
"jest": "^29.5.0",
"jest-preset-preact": "^4.0.5",
"microbundle": "^0.15.1",
"postcss": "^8.4.23",
"preact": "10.13.2",
"postcss": "^8.4.24",
"preact": "10.15.1",
"preact-cli": "^3.4.5",
"preact-render-to-string": "^6.0.3",
"rimraf": "^5.0.0",
"preact-render-to-string": "^6.1.0",
"rimraf": "^5.0.1",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.4"
"typescript": "^5.1.3"
},
"jest": {
"preset": "jest-preset-preact",

View File

@@ -1,6 +1,6 @@
import type { MultipleChoiceMultiQuestion } from "../../../types/questions";
import { h } from "preact";
import { useState } from "preact/hooks";
import { useState, useRef, useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
@@ -20,16 +20,32 @@ export default function MultipleChoiceMultiQuestion({
brandColor,
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [showOther, setShowOther] = useState(false);
const [otherSpecified, setOtherSpecified] = useState("");
const otherInputRef = useRef(null);
const isAtLeastOneChecked = () => {
return selectedChoices.length > 0;
return selectedChoices.length > 0 || otherSpecified.length > 0;
};
useEffect(() => {
if (showOther && otherInputRef.current) {
otherInputRef.current.focus();
}
}, [showOther]);
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (!isAtLeastOneChecked() && question.required) return;
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
const data = {
[question.id]: selectedChoices,
@@ -37,6 +53,8 @@ export default function MultipleChoiceMultiQuestion({
onSubmit(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -52,7 +70,7 @@ export default function MultipleChoiceMultiQuestion({
selectedChoices.includes(choice.label)
? "fb-z-10 fb-border-slate-400 fb-bg-slate-50"
: "fb-border-gray-200",
"fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-rounded-md fb-border fb-p-4 hover:fb-bg-slate-50 focus:fb-outline-none"
"fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-space-y-3 fb-rounded-md fb-border fb-p-4 hover:fb-bg-slate-50 focus:fb-outline-none"
)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
@@ -63,6 +81,12 @@ export default function MultipleChoiceMultiQuestion({
className="fb-h-4 fb-w-4 fb-border fb-border-slate-300 focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
if (choice.id === "other") {
setShowOther(e.currentTarget.checked);
return;
}
if (e.currentTarget.checked) {
setSelectedChoices([...selectedChoices, e.currentTarget.value]);
} else {
@@ -71,13 +95,27 @@ export default function MultipleChoiceMultiQuestion({
);
}
}}
checked={selectedChoices.includes(choice.label)}
checked={selectedChoices.includes(choice.label) || (choice.id === "other" && showOther)}
style={{ borderColor: brandColor, color: brandColor }}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-font-medium">
{choice.label}
</span>
</span>
{choice.id === "other" && showOther && (
<input
ref={otherInputRef}
id={`${choice.id}-label`}
name={question.id}
placeholder="Please specify"
className={cn(
"fb-mt-3 fb-flex fb-h-10 fb-w-full fb-rounded-md fb-border fb-bg-white fb-border-slate-300 fb-bg-transparent fb-px-3 fb-py-2 fb-text-sm fb-text-slate-800 placeholder:fb-text-slate-400 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-400 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300"
)}
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}
required={question.required}
/>
)}
</label>
))}
</div>

View File

@@ -1,5 +1,5 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { useRef, useState, useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
import type { MultipleChoiceSingleQuestion } from "../../../types/questions";
import Headline from "./Headline";
@@ -20,13 +20,24 @@ export default function MultipleChoiceSingleQuestion({
brandColor,
}: MultipleChoiceSingleProps) {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const otherSpecify = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedChoice === "other") {
otherSpecify.current?.focus();
}
}, [selectedChoice]);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: e.currentTarget[question.id].value,
[question.id]: value,
};
onSubmit(data);
setSelectedChoice(null); // reset form
}}>
@@ -55,9 +66,9 @@ export default function MultipleChoiceSingleQuestion({
className="fb-h-4 fb-w-4 fb-border fb-border-slate-300 focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
setSelectedChoice(choice.id);
}}
checked={selectedChoice === choice.label}
checked={selectedChoice === choice.id}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
@@ -65,6 +76,17 @@ export default function MultipleChoiceSingleQuestion({
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "other" && (
<input
ref={otherSpecify}
id={`${choice.id}-label`}
name={question.id}
placeholder="Please specify"
className="fb-mt-3 fb-flex fb-h-10 fb-w-full fb-rounded-md fb-border fb-bg-white fb-border-slate-300 fb-bg-transparent fb-px-3 fb-py-2 fb-text-sm fb-text-slate-800 placeholder:fb-text-slate-400 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-400 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300"
required={question.required}
aria-labelledby={`${choice.id}-label`}
/>
)}
</label>
))}
</div>

View File

@@ -2,9 +2,9 @@ import { h } from "preact";
export default function Progress({ progress, brandColor }: { progress: number; brandColor: string }) {
return (
<div className="fb-h-1 fb-w-full fb-rounded-full bg-slate-200">
<div className="fb-h-1 fb-w-full fb-rounded-full fb-bg-slate-200">
<div
className="fb-h-1 fb-rounded-full"
className="fb-h-1 fb-rounded-full fb-transition-width fb-duration-500"
style={{ backgroundColor: brandColor, width: `${Math.floor(progress * 100)}%` }}></div>
</div>
);

View File

@@ -49,7 +49,7 @@ export default function RatingQuestion({
type="radio"
name="rating"
value={number}
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0 fb-left-0"
onChange={() => handleSelect(number)}
required={question.required}
/>

View File

@@ -101,13 +101,13 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
case "notEquals":
return answerValue !== logic.value;
case "lessThan":
return answerValue < logic.value;
return logic.value !== undefined && answerValue < logic.value;
case "lessEqual":
return answerValue <= logic.value;
return logic.value !== undefined && answerValue <= logic.value;
case "greaterThan":
return answerValue > logic.value;
return logic.value !== undefined && answerValue > logic.value;
case "greaterEqual":
return answerValue >= logic.value;
return logic.value !== undefined && answerValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
@@ -159,15 +159,14 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
}
}
}
return questions[currentQuestionIndex + 1]?.id || "";
return questions[currentQuestionIndex + 1]?.id || "end";
}
const submitResponse = async (data: { [x: string]: any }) => {
setLoadingElement(true);
const questionIdx = survey.questions.findIndex((e) => e.id === activeQuestionId);
const nextQuestionId = getNextQuestion(data);
const finished = nextQuestionId === "end" || questionIdx === survey.questions.length - 1;
const finished = nextQuestionId === "end";
// build response
const responseRequest = {
surveyId: survey.id,
@@ -193,7 +192,6 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
setLoadingElement(false);
if (!finished && nextQuestionId !== "end") {
// setActiveQuestionId(survey.questions[questionIdx + 1].id);
setActiveQuestionId(nextQuestionId);
} else {
setProgress(100);

View File

@@ -1,51 +1,73 @@
import type { InitConfig } from "../../types/js";
import { getApi } from "./lib/api";
import { CommandQueue } from "./lib/commandQueue";
import { ErrorHandler } from "./lib/errors";
import { trackEvent } from "./lib/event";
import { initialize } from "./lib/init";
import { Logger } from "./lib/logger";
import { checkPageUrl } from "./lib/noCodeEvents";
import { resetPerson, setPersonAttribute, setPersonUserId } from "./lib/person";
import { resetPerson, setPersonAttribute, setPersonUserId, getPerson } from "./lib/person";
import { refreshSettings } from "./lib/settings";
export type { EnvironmentId, KeyValueData, PersonId, ResponseId, SurveyId } from "@formbricks/api";
const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
const init = (initConfig: InitConfig) => {
const init = async (initConfig: InitConfig) => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(false, initialize, initConfig);
await queue.wait();
};
const setUserId = (userId: string): void => {
const setUserId = async (userId: string): Promise<void> => {
queue.add(true, setPersonUserId, userId);
await queue.wait();
};
const setEmail = (email: string): void => {
const setEmail = async (email: string): Promise<void> => {
setAttribute("email", email);
await queue.wait();
};
const setAttribute = (key: string, value: string): void => {
const setAttribute = async (key: string, value: string): Promise<void> => {
queue.add(true, setPersonAttribute, key, value);
await queue.wait();
};
const logout = (): void => {
const logout = async (): Promise<void> => {
queue.add(true, resetPerson);
await queue.wait();
};
const track = (eventName: string, properties: any = {}): void => {
const track = async (eventName: string, properties: any = {}): Promise<void> => {
queue.add(true, trackEvent, eventName, properties);
await queue.wait();
};
const refresh = (): void => {
const refresh = async (): Promise<void> => {
queue.add(true, refreshSettings);
await queue.wait();
};
const registerRouteChange = (): void => {
const registerRouteChange = async (): Promise<void> => {
queue.add(true, checkPageUrl);
await queue.wait();
};
const formbricks = { init, setUserId, setEmail, setAttribute, track, logout, refresh, registerRouteChange };
const formbricks = {
init,
setUserId,
setEmail,
setAttribute,
track,
logout,
refresh,
registerRouteChange,
getApi,
getPerson,
};
export default formbricks;
export { formbricks as default };

View File

@@ -0,0 +1,16 @@
import { FormbricksAPI, EnvironmentId } from "@formbricks/api";
import { Config } from "./config";
export const getApi = (): FormbricksAPI => {
const config = Config.getInstance();
const { environmentId, apiHost } = config.get();
if (!environmentId || !apiHost) {
throw new Error("formbricks.init() must be called before getApi()");
}
return new FormbricksAPI({
apiHost,
environmentId: environmentId as EnvironmentId,
});
};

View File

@@ -8,6 +8,8 @@ export class CommandQueue {
commandArgs: any[];
}[] = [];
private running: boolean = false;
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise<void> | null = null;
public add<A>(
checkInitialized: boolean = true,
@@ -17,7 +19,16 @@ export class CommandQueue {
this.queue.push({ command, checkInitialized, commandArgs: args });
if (!this.running) {
this.run();
this.commandPromise = new Promise((resolve) => {
this.resolvePromise = resolve;
this.run();
});
}
}
public async wait() {
if (this.running) {
await this.commandPromise;
}
}
@@ -38,14 +49,13 @@ export class CommandQueue {
if (!result) continue;
/* logger.debug(
`Command result: ${result.ok === true ? "OK" : "Something went really wrong"}, ${
currentItem.command.name
}`
); */
if (result.ok !== true) errorHandler.handle(result.error);
}
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();
this.resolvePromise = null;
this.commandPromise = null;
}
}
}

View File

@@ -10,7 +10,7 @@ const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
logger.debug("Checking page url");
logger.debug(`Checking page url: ${window.location.href}`);
const { settings } = config.get();
const pageUrlEvents: Event[] = settings?.noCodeEvents.filter((e) => e.noCodeConfig?.type === "pageUrl");

View File

@@ -217,3 +217,7 @@ export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
return okVoid();
};
export const getPerson = (): Person => {
return config.get().person;
};

View File

@@ -19,10 +19,16 @@ export const renderWidget = (survey: Survey) => {
}
surveyRunning = true;
render(
h(App, { config: config.get(), survey, closeSurvey, errorHandler: errorHandler.handle }),
document.getElementById(containerId)
);
if (survey.delay) {
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
}
setTimeout(() => {
render(
h(App, { config: config.get(), survey, closeSurvey, errorHandler: errorHandler.handle }),
document.getElementById(containerId)
);
}, survey.delay * 1000);
};
export const closeSurvey = async (): Promise<void> => {

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